' ;
+
+ const reCastMIspellCmd = /!magic\s+--cast-spell\s+MI\s*\|/im;
+ const reCastMIpowerCmd = /!magic\s+--cast-spell\s+MI-POWERS?\s*\|/im;
+ const reSpecs = /}}\s*Specs=\s*?(\[[^{]*?\])\s*?{{/im;
+ const reSpecsAll = /\[\s*?(\w[-\+\s\w\|]*?)\s*?,\s*?(\w[-\s\w\|]*?\w)\s*?,\s*?(\w[\s\w\|]*?\w)\s*?,\s*?(\w[-\+\s\w\|]*?\w)\s*?(?:,\s*?(\w[-\+\s\w\|]*?\w)\s*?)?\]/g;
+ const reData = /}}\s*?\w*?data\s*?=(.*?){{/im;
+ const reDataAll = /\[.*?\]/g;
+ const reSpecClass = /\[\s*?\w[\s\|\w\-\+]*?\s*?,\s*?(\w[\s\|\w\-]*?)\s*?,.*?\]/g;
+ const reSpecSuperType = /}}\s*Specs=\s*?\[\s*?\w[\s\|\w\-\+]*?\s*?,\s*?\w[\s\|\w\-]*?\w\s*?,\s*?\d+H(?:\|\d*H)\s*?,\s*?(\w[\s\|\w\-]*?\w)\s*?(?:,\s*?(\w[-\+\s\w\|]*?\w)\s*?)?\]/im;
+ const reDataSpeed = /}}\s*?\w*?data\s*?=.*?[\[,]\s*?sp:([d\d\+\-\*\/.]+?)[,\s\]]/im;
+ const reDataCharge = /}}\s*?\w*?data\s*?=.*?[\[,]\s*?rc:([\w\+\-]+?)[,\s\]]/im;
+ const reDataCost = /}}\s*?\w*?data\s*?=.*?[\[,]\s*?gp:(\d+?\.?\d*?)[,\s\]]/im;
+ const reDataLevel = /}}\s*?\w*?data\s*?=.*?[\[,]\s*?lv:(\d+?)[,\s\]]/im;
+ const reName = /[\[,]\s*?w:\s*[^\],]+?[,\]]/im;
+ const reLevel = /[\[,]\s*?lv:(\d+?)[,\s\]]/im;
+ const reClassData = /}}\s*?ClassData\s*?=(.*?){{/im;
+ const reClassRaceData = /}}\s*?(?:Class|Race)Data\s*?=.*?{{/im;
+ const reSpellData = /}}\s*?SpellData\s*?=(.*?){{/im;
+ const reRepeatingTable = /^(repeating_.*)_\$(\d+)_.*$/;
+ const reNumSpellsData = /}}[\s\w\-]*?(?"],
+ [/\\lt;?/gm, "<"],
+ [/<<|«/g, "["],
+ [/\\lbrak;?/g, "["],
+ [/>>|»/g, "]"],
+ [/\\rbrak;?/g, "]"],
+ [/\\\^/g, "?"],
+ [/\\ques;?/g, "?"],
+ [/`/g, "@"],
+ [/\\at;?/g, "@"],
+ [/~/g, "-"],
+ [/\\dash;?/g, "-"],
+ [/\\n/g, "\n"],
+ [/¦/g, "|"],
+ [/\\vbar;?/g, "|"],
+ [/\\clon;?/g, ":"],
+ [/\\amp;?/g, "&"],
+ [/\\lpar;?/g, "("],
+ [/\\rpar;?/g, ")"],
+ [/\\cr;?/g, "
"],
+ [/&&/g, "/"],
+ [/%%/g, "%"],
+ [/\\comma;?/g, ","],
+ [/\\fs;?/g, "\\"],
+ ];
+
+ const dbEncoders = [
+ [/\\/gm,"\\\\"],
+ [/\r?\n/gm,'\\n'],
+ [/'/gm,"\\'"],
+ [/&/gm,"\\\\amp"],
+ [/>/gm,"\\\\gt"],
+ [/"],
+ [/\\lt/gm, "<"],
+ ];
+
+ const pallet = Object.freeze({
+ fancy: {
+ def: {outer:'yellow',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'transparent',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'5% 5% 2px 5%',bodypad:'4px 5% 10% 10%',bodybox:'',rowbox:'purple',rowdark:'transparent',rowdarktext:'black',rowlight:'transparent',rowlighttext:'black',rowpad:'4px',outerimg:'https://s3.amazonaws.com/files.d20.io/images/279722596/LxsTe-cbwk5j9L0ipM3GLw/thumb.jpg?1649510600',titleimg:'https://s3.amazonaws.com/files.d20.io/images/279800986/SqFez5dbn2roAsokDaBAPw/thumb.jpg?1649536002',bodyimg:'https://s3.amazonaws.com/files.d20.io/images/279800959/KyHThjxjXeZQ-b_uC6yCjQ/thumb.jpg?1649535995'},
+ spell: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'transparent',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'20% 10% 2px 10%',bodypad:'4px 12% 5% 12%',bodybox:'',rowbox:'purple',rowdark:'transparent',rowdarktext:'black',rowlight:'transparent',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'https://s3.amazonaws.com/files.d20.io/images/279801150/vQ_1KKR72-7DTAusJzkt0w/thumb.png?1649536043',bodyimg:'https://s3.amazonaws.com/files.d20.io/images/279801125/opM7Y6m20DGLPeP-hrXpCA/thumb.png?1649536037'},
+ potion: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'transparent',titletext:'black; text-shadow: 1px 1px 1px gray',titlepad:'60% 10% 2px 10%',bodypad:'10% 5% 15% 8%',bodybox:'',rowbox:'mediumturquoise',rowdark:'transparent',rowdarktext:'white',rowlight:'transparent',rowlighttext:'white',rowpad:'4px',outerimg:'',titleimg:'https://s3.amazonaws.com/files.d20.io/images/279798022/Qgs1fGmOup8_9mtzoEeSxw/thumb.png?1649535031',bodyimg:'https://s3.amazonaws.com/files.d20.io/images/279798050/hQ4nWnVGPDINtjidt8-1eg/thumb.png?1649535040'},
+ weapon: {outer:'yellow',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'transparent',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'5% 5% 2px 5%',bodypad:'4px 5% 10% 10%',bodybox:'',rowbox:'purple',rowdark:'transparent',rowdarktext:'black; text-shadow: 1px 1px 1px white',rowlight:'transparent',rowlighttext:'black; text-shadow: 1px 1px 1px white',rowpad:'4px',outerimg:'https://s3.amazonaws.com/files.d20.io/images/279722596/LxsTe-cbwk5j9L0ipM3GLw/thumb.jpg?1649510600',titleimg:'',bodyimg:'https://s3.amazonaws.com/files.d20.io/images/257648113/iUlG62xcBc6AdUj5lv32Ww/max.png?1638047575'},
+ attack: {outer:'purple',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'4px solid maroon',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'4px 4px 1px 4px',bodypad:'0px',bodybox:'4px solid gray',rowbox:'none',rowdark:'white',rowdarktext:'black',rowlight:'white',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',dmgslabel:'S/M',dmgllabel:'L'},
+ menu: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'blue',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'teal',rowdark:'lightblue',rowdarktext:'black',rowlight:'lightcyan',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:''},
+ message: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'white',titletext:'black',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'1px solid black',rowbox:'',rowdark:'white',rowdarktext:'black',rowlight:'white',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:''},
+ warning: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'crimson',titletext:'white',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'pink',rowdarktext:'black',rowlight:'mistyrose',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ },
+ plain: {
+ def: {outer:'blue',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'blue',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'lightblue',rowdarktext:'black',rowlight:'lightcyan',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ spell: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'firebrick',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'pink',rowdarktext:'black',rowlight:'mistyrose',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ potion: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'forestgreen',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'mediumturquoise',rowdark:'khaki',rowdarktext:'black',rowlight:'lightgoldenrodyellow',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ weapon: {outer:'yellow',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'gray',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'gainsboro',rowdarktext:'black; text-shadow: 1px 1px 1px white',rowlight:'ghostwhite',rowlighttext:'black; text-shadow: 1px 1px 1px white',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ attack: {outer:'purple',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'4px solid maroon',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'4px 4px 1px 4px',bodypad:'0px',bodybox:'4px solid gray',rowbox:'none',rowdark:'white',rowdarktext:'black',rowlight:'white',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',dmgslabel:'S/M',dmgllabel:'L'},
+ menu: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'blue',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'teal',rowdark:'lightblue',rowdarktext:'black',rowlight:'lightcyan',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ message: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'white',titletext:'black',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'1px solid black',rowbox:'',rowdark:'white',rowdarktext:'black',rowlight:'white',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:''},
+ warning: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'crimson',titletext:'white',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'pink',rowdarktext:'black',rowlight:'mistyrose',rowlighttext:'black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ },
+ dark: {
+ def: {outer:'darkgoldenrod',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'blue',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'navy',rowdarktext:'white',rowlight:'blueviolet',rowlighttext:'white',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ spell: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'firebrick',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'darkmagenta',rowdarktext:'white',rowlight:'darkorchid',rowlighttext:'white',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ potion: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'forestgreen',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'mediumturquoise',rowdark:'darkgoldenrod',rowdarktext:'white; text-shadow: 1px 1px 1px black',rowlight:'goldenrod',rowlighttext:'white; text-shadow: 1px 1px 1px black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ weapon: {outer:'darkgoldenrod',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'gray',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'dimgray',rowdarktext:'white; text-shadow: 1px 1px 1px black',rowlight:'darkgray',rowlighttext:'white; text-shadow: 1px 1px 1px black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ attack: {outer:'purple',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'4px solid maroon',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'4px 4px 1px 4px',bodypad:'0px',bodybox:'4px solid gray',rowbox:'none',rowdark:'dimgray',rowdarktext:'white; text-shadow: 1px 1px 1px black',rowlight:'dimgray',rowlighttext:'white; text-shadow: 1px 1px 1px black',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',dmgslabel:'S/M',dmgllabel:'L'},
+ menu: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'blue',titletext:'white; text-shadow: 1px 1px 1px gray',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'teal',rowdark:'navy',rowdarktext:'white',rowlight:'blueviolet',rowlighttext:'white',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ message: {outer:'white',outerpad:'',shadow:'rgba(0,0,0,0) 0px 0px',titlebox:'black',titletext:'white',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'1px solid white',rowbox:'',rowdark:'black',rowdarktext:'white',rowlight:'black',rowlighttext:'white',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:''},
+ warning: {outer:'black',outerpad:'',shadow:'rgba(0,0,0,0.4) 3px 3px',titlebox:'crimson',titletext:'white',titlepad:'1px 1px 1px 1px',bodypad:'4px 4px 4px 4px',bodybox:'',rowbox:'purple',rowdark:'darkmagenta',rowdarktext:'white',rowlight:'darkorchid',rowlighttext:'white',rowpad:'4px',outerimg:'',titleimg:'',bodyimg:'',rowimg:''},
+ }
+ });
+
+ const acImg = 'https://s3.amazonaws.com/files.d20.io/images/280889787/N6NbbkLDe92C4e5DDtmkaw/thumb.png?1650135426';
+ const dmgImg = 'https://s3.amazonaws.com/files.d20.io/images/280890292/ZBDEOKwQHCPeY2yQJuhkeA/thumb.png?1650135612';
+ const hpImg = 'https://s3.amazonaws.com/files.d20.io/images/281063429/1ySUC06qy_MuhY-_Be_pVQ/thumb.png?1650223020';
+ const slashImg = 'https://s3.amazonaws.com/files.d20.io/images/281331848/XnspIFctdnld8LVG3_m5RQ/thumb.png?1650393752';
+ const pierceImg = 'https://s3.amazonaws.com/files.d20.io/images/281331832/DYgW_xqlORNJ77oigkFqAA/thumb.png?1650393745';
+ const bludgeonImg = 'https://s3.amazonaws.com/files.d20.io/images/281331818/rSZVRXYkRNR4K9Ru6CXbVw/thumb.png?1650393737';
+ const sacImg = 'https://s3.amazonaws.com/files.d20.io/images/281054605/oNYktYKEmF9_ngePXyUcPw/thumb.png?1650219538';
+ const pacImg = 'https://s3.amazonaws.com/files.d20.io/images/281054578/FeeVqF8X-rgeEP6fg4CWKg/thumb.png?1650219526';
+ const bacImg = 'https://s3.amazonaws.com/files.d20.io/images/281054552/-i1SuQ4Rx1OO7cXPtlggNg/thumb.png?1650219515';
+ const heart = ['https://s3.amazonaws.com/files.d20.io/images/281063429/1ySUC06qy_MuhY-_Be_pVQ/thumb.png?1650223020',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334582/74iFWTTF47pFyvmGd1WWnw/thumb.png?1650395033',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334596/8yhpUHhYL7bQIlLZfHGqjw/thumb.png?1650395041',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334612/FpyTBj_oaJS6_GaZmlsfvA/thumb.png?1650395047',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334628/-4nbQ36qch58EK0BWgytRA/thumb.png?1650395055',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334655/OH7HS1U-xYFUTJjIAypqFQ/thumb.png?1650395061',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334666/CByDv4kCKWcaV9LabxwV-A/thumb.png?1650395067',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334682/xbcpGhbmeEAf3ZmKEgs3Ow/thumb.png?1650395074',
+ 'https://s3.amazonaws.com/files.d20.io/images/281334693/xTlsD3NHddK4g3-nlQVCDg/thumb.png?1650395080'
+ ];
+
+ var DBindex;
+ var classesParsed = false;
+ var magicList = {};
+ var RPGMap = {};
+ var apis = {magic:false,attk:false,init:false};
+ var lastMsg = [];
+ var doneRNmsg = false;
+ var waitList = {};
+ var msg_orig = {};
+ var showMoreObj = {};
+
+ const isString = (s) => 'string' === typeof s || s instanceof String;
+ const isArray = (a) => Array.isArray(a);
+ const flatten = (a) => isArray(a) ? a.reduce((m,e)=>[...m, ...flatten(e)],[]) : [a];
+
+ /**
+ * In the inline roll evaluator from ChatSetAttr script v1.9
+ * by Joe Singhaus and C Levett.
+ **/
+
+ var processInlinerolls = function (msg) {
+ if (msg.inlinerolls && msg.inlinerolls.length) {
+ return msg.inlinerolls.map(v => {
+ const ti = v.results.rolls.filter(v2 => v2.table)
+ .map(v2 => v2.results.map(v3 => v3.tableItem.name).join(", "))
+ .join(", ");
+ return (ti.length && ti) || v.results.total || 0;
+ })
+ .reduce((m, v, k) => m.replace(new RegExp('\\$\\[\\['+k+'\\]\\]','img'),'[['+v+'['+msg.inlinerolls[k].expression+'] ]]'), msg.content);
+ } else {
+ return msg.content;
+ }
+ };
+
+ /*
+ * Check the version of a Character Sheet database against
+ * the current version in the API. Delete old versions so
+ * API versions are used and indexed.
+ */
+
+ var del_Old_DBs = function() {
+
+ var update = false;
+
+ _.each( dbNames, (db,dbName) => {
+ let dbFullName = dbName.replace(/_/g,'-'),
+ dbCS = findObjs({ type:'character', name:dbFullName },{caseInsensitive:true}),
+ dbVersion = 0.0,
+ msg, versionObj;
+
+ if (!dbCS || !dbCS.length) return;
+
+ dbCS = dbCS[0];
+
+ if (_.isUndefined(LibFunctions.attrLookup( dbCS, fields.dbVersion ))) {
+ setTimeout( () => del_Old_DBs(), 5000 );
+ return;
+
+ } else {
+
+ dbVersion = parseFloat(LibFunctions.attrLookup( dbCS, fields.dbVersion )) || dbVersion;
+
+ if (dbVersion >= (parseFloat(db.version) || 0)) return;
+
+ log('Deleting '+dbFullName+' v'+dbVersion);
+ dbCS.remove();
+ update = true;
+ }
+ });
+ if (update) LibFunctions.updateDBindex();
+ return;
+ }
+
+ /*
+ * Clear any waiting timer action saved in the playerConfig
+ */
+
+ var clearWaitTimer = function(pid) {
+ if (pid && waitList[pid]) {
+ clearTimeout(waitList[pid]);
+ waitList[pid] = undefined;
+ } else if (!pid) {
+ _.each(waitList,w=>clearTimeout(w));
+ waitList = {};
+ }
+ return;
+ };
+
+
+ /*
+ * Function to generate unique IDs for creating objects in Roll20
+ */
+
+ const generateUUID = function () {
+ var a = 0,
+ b = [];
+ return function () {
+ var c = (new Date()).getTime() + 0,
+ d = c === a;
+ a = c;
+ for (var e = new Array(8), f = 7; 0 <= f; f--) {
+ e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(c % 64);
+ c = Math.floor(c / 64);
+ }
+ c = e.join("");
+ if (d) {
+ for (f = 11; 0 <= f && 63 === b[f]; f--) {
+ b[f] = 0;
+ }
+ b[f]++;
+ } else {
+ for (f = 0; 12 > f; f++) {
+ b[f] = Math.floor(64 * Math.random());
+ }
+ }
+ for (f = 0; 12 > f; f++) {
+ c += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(b[f]);
+ }
+ return c;
+ };
+ }();
+ const generateRowID = function () {
+ return generateUUID().replace(/_/g, "Z");
+ };
+
+ /**
+ * Find the GM, generally when a player can't be found
+ */
+
+ var findTheGM = function() {
+ var playerGM,
+ players = findObjs({ _type:'player' });
+
+ if (players.length !== 0) {
+ if (!_.isUndefined(playerGM = _.find(players, function(p) {
+ var player = p;
+ if (player) {
+ if (playerIsGM(player.id)) {
+ return player.id;
+ }
+ }
+ }))) {
+ return playerGM.id;
+ }
+ }
+ return undefined;
+ }
+
+ /*
+ * Display a message with a link to the Release Notes
+ */
+
+ var displayReleaseNotesLink = function() {
+ var handoutIDs = LibFunctions.getHandoutIDs();
+ if (!doneRNmsg) {
+ doneRNmsg = true;
+ LibFunctions.sendFeedback(waitMsgDiv+'You can read the latest **[Release Notes here]('+fields.journalURL+handoutIDs.RPGMReleaseNotes+')**, version '+handouts.RPGM_Release_Notes.version+' updated '+(new Date(lastUpdate*1000).toDateString())+'
');
+ }
+ }
+
+ String.prototype.dbName = function() {
+ return this.toLowerCase().replace(reIgnore,'');
+ }
+
+ String.prototype.dispName = function() {
+ return (this || '').replace(/[-_]/g,' ');
+ }
+
+ String.prototype.hyphened = function() {
+ return (this || '').replace(/\s/g,'-');
+ }
+
+ String.prototype.trueCompare = function(txt) {
+ return (this || '').dbName() === (String(txt) || '').dbName();
+ }
+
+ class AbilityObj {
+ constructor( dBname, abilityObj, ctObj, source ) {
+ this.dB = dBname;
+ this.obj = (_.isUndefined(abilityObj) || !_.isArray(abilityObj) || abilityObj.length < 2) ? abilityObj : [_.clone(abilityObj[0]),_.clone(abilityObj[1])];
+ this.ct = ctObj;
+ this.source = source;
+ this.api = (abilityObj && abilityObj[1]) ? (abilityObj[1].body.trim()[0] == '!') : false;
+ }
+
+ specs(re = reSpecs) {
+ let specStr = this.obj[1].body.match(re);
+ return specStr ? [...('['+specStr[0]+']').matchAll(reSpecsAll)] : undefined;
+ }
+ data(re = reData) {
+ let specStr = this.obj[1].body.match(re);
+ return specStr ? [...('['+specStr[0]+']').matchAll(reDataAll)] : undefined;
+ }
+ hands(re = reSpecs) {
+ let specStr = this.obj[1].body.match(re);
+ return specStr ? [...('['+specStr[0]+']').matchAll(reHands)].concat([...('['+specStr[0]+']').matchAll(reHands2)] || []) : undefined;
+ }
+ classes() {
+ /**
+ * Search a database object body for the object "class"
+ **/
+ let objType = [],
+ specs = this.obj[1].body.match(/}}\s*?specs\s*?=(.*?){{/im);
+ specs = specs ? [...('['+specs[0]+']').matchAll(reSpecClass)] : [];
+ for (let i=0; i < specs.length; i++) {
+ objType.push(specs[i][1]);
+ }
+ return _.uniq(objType.join('|').toLowerCase().split('|')).join('|');
+ }
+
+
+ }
+
+ class CharTable {
+ constructor( property, attrs, defaultVal ) {
+ if (!property || !isArray(property) || property.length < 2) throw new Error('Invalid attribute definition in table constructor');
+ this.property = property;
+ this.attrs = attrs || {};
+ this.defaultVal = defaultVal || {current:'',max:''};
+ }
+ }
+
+ class CharTableArray {
+ constructor( character, table, col ) {
+ if (!character) throw new Error('Invalid character object in table constructor');
+ if (!table || !isArray(table) || table.length < 2) throw new Error('Invalid table definition in table constructor');
+ this.character = character;
+ this.table = table;
+ this.tableType;
+ this.fieldGroup;
+ this.values = {};
+ this.sortKeys;
+ this.col = (_.isUndefined(col) || _.isNull(col) || (table && !_.isNull(table) && !table[1] && col && col==1)) ? '' : col;
+ }
+
+ /*
+ * A method to get the whole of a repeating table in
+ * two parts: an array of objects indexed by Roll20 object IDs,
+ * and an array of object IDs indexed by repeating table row number.
+ * Returns an object containing the table, and all parameters defining
+ * that table and where it came from.
+ */
+
+ addTable(attrDef,defaultVal,caseSensitive) {
+ if (_.isUndefined(attrDef) || !isArray(attrDef) || attrDef.length < 2) throw new Error('No table attribute supplied to addTable() for '+this.table[0]+', attrDef '+attrDef);
+ let rowName, index = 0, name = attrDef[0];
+ if (this.table && !_.isNull(this.table)) {
+ rowName = this.table[0]+this.col+'_$0_'+attrDef[0]+this.col;
+ } else {
+ rowName = name;
+ }
+
+ if (_.isUndefined(defaultVal)) {
+ defaultVal=attrDef[2];
+ }
+
+ if (!this.hasOwnProperty(name)) {
+ this[name] = new CharTable( attrDef );
+ }
+ this[name].defaultVal[attrDef[1]] = defaultVal;
+ let match=rowName.match(/^(repeating_.*)_\$(\d+)_.*$/);
+
+ if(match){
+ let createOrderKeys=[],
+ attrMatcher=new RegExp(`^${rowName.replace(/_\$\d+_/,'_([-\\da-zA-Z]+)_')}$`,(caseSensitive?'i':'')),
+ orderMatcher=new RegExp(`^${this.table[0]}${this.col}_([-\\da-zA-Z]+)_`,(caseSensitive?'i':'')),
+ attrs=_.chain(findObjs({type:'attribute', characterid:this.character.id}))
+ .map((a)=>{
+ let orderKey = (a.get('name').match(orderMatcher)||['',''])[1];
+ if (orderKey && !createOrderKeys.includes(orderKey)) {
+ createOrderKeys.push(orderKey);
+ };
+ return {attr:a,match:a.get('name').match(attrMatcher)};
+ })
+ .filter((o)=>o.match)
+ .reduce((m,o)=>{ m[o.match[1]]=o.attr; return m;},{})
+ .value(),
+ sortOrderKeys = _.chain( ((findObjs({
+ type:'attribute',
+ characterid:this.character.id,
+ name: `_reporder_${match[1]}`
+ })[0]||{get:_.noop}).get('current') || '' ).split(/\s*,\s*/))
+ .intersection(createOrderKeys)
+ .union(createOrderKeys)
+ .value();
+
+ if (_.isUndefined(this.sortKeys)) {
+ this.sortKeys = sortOrderKeys;
+ } else {
+ this.sortKeys = (sortOrderKeys.length > this.sortKeys.length) ? _.union(sortOrderKeys,this.sortKeys) : _.union(this.sortKeys,sortOrderKeys);
+ }
+ this[name].attrs=attrs;
+ if (_.isUndefined(this.values[attrDef[0]])) {
+ this.values[attrDef[0]] = Object.create({current:'',max:''});
+ }
+ this.values[attrDef[0]][attrDef[1]] = attrDef[2] || '';
+ } else {
+ this[name].attrs=[];
+ if (_.isUndefined(this.sortKeys)) {
+ this.sortKeys = [];
+ }
+ }
+ return this;
+ }
+
+ /*
+ * Find all the necessary tables to manage a repeating
+ * section of a character sheet. Dynamically driven by
+ * the table field definitions in the 'fields' object.
+ */
+
+ addAllTables( fieldGroup, caseSensitive ) {
+
+ var rows = {};
+
+ this.fieldGroup = fieldGroup;
+ this.values = {};
+ _.each( fields, (elem,key) => {
+ if (key.startsWith(fieldGroup)
+ && ['current','max'].includes(String(elem[1]).toLowerCase())) {
+ rows[key]=elem;
+ };
+ });
+ _.each(rows, (elem,key) => {
+ this.addTable( elem, elem[2], caseSensitive );
+ });
+ return this;
+ }
+
+ /**
+ * A function to take a table obtained using getTableField() and a row number, and
+ * safely return the value of the table row, or undefined. Uses the table object
+ * parameters such as the character object it came from and the field property.
+ * If the row entry is undefined use a default value if set in the getTableField() call,
+ * which can be overridden with an optional parameter. Can just return the row
+ * object or can return a different property of the object using the second optional parameter.
+ */
+
+ tableLookup( attrDef, index, defVal, retObj ) {
+
+ if (!attrDef || !isArray(attrDef) || attrDef.length < 2) throw new Error('No table attribute supplied to tableLookup() for '+this.table[0]+', attrDef '+attrDef);
+ var val, name = attrDef[0];
+ if (_.isUndefined(retObj)) {
+ retObj=false;
+ } else if (retObj === true) {
+ defVal=false;
+ }
+ if (_.isUndefined(defVal)) {
+ defVal=true;
+ }
+ if (this[name] && !_.isUndefined(index)) {
+ let property = (retObj === true) ? null : ((retObj === false) ? attrDef : retObj);
+ defVal = (defVal===false) ? (undefined) : ((defVal===true) ? this[name].defaultVal[attrDef[1]] : defVal);
+// if (!_.isUndefined(defVal)) defVal = String(defVal);
+ if (index>=0) {
+ let attrs = this[name].attrs,
+ sortOrderKeys = this.sortKeys;
+ if (index
{
+ if (_.isUndefined(elem.attrs)) return;
+ currentVal = (!rowVals || _.isUndefined(rowVals[key])) ? elem.defaultVal['current'] : rowVals[key]['current'];
+ maxVal = (!rowVals || _.isUndefined(rowVals[key])) ? elem.defaultVal['max'] : rowVals[key]['max'];
+ this.tableSet( [key,'current'], index, currentVal );
+ this.tableSet( [key,'max'], index, maxVal );
+ });
+ } else {
+ if (index > this.sortKeys.length) {
+ this.addTableRow( index-1, undefined );
+ }
+ let rowObjID = generateRowID();
+ let namePt1 = this.table[0]+this.col+'_'+rowObjID+'_';
+ let gotVals = !!rowVals && _.pairs(rowVals).length > 0;
+ _.each( list, (elem,key) => {
+ if (_.isUndefined(elem.attrs)) return;
+ rowObj = createObj( "attribute", {characterid: this.character.id, name: (namePt1+key+this.col)} );
+ if (!gotVals) {
+ newVal = _.isUndefined(this.values[key]) ? elem.defaultVal : this.values[key] ;
+ } else {
+ newVal = rowVals[key];
+ }
+ rowObj.set(newVal);
+ this[key].attrs[rowObjID] = rowObj;
+ this.sortKeys[index] = rowObjID;
+ });
+ }
+ return this;
+ }
+
+ /*
+ * Delete / remove a table row completely
+ */
+
+ delTableRow( index ) {
+
+ let rowObj, newVal, currentVal, maxVal, list = this;
+
+ let fieldGroup = this.fieldGroup;
+ if (!fieldGroup) throw new Error('undefined addTableRow fieldGroup');
+ if (index) index = parseInt(index);
+ if (_.isUndefined(index) || isNaN(index)) return this;
+
+ if ((index < 0) || ((index >= this.sortKeys.length) || _.isUndefined(this.tableLookup( fields[fieldGroup+'name'], index, false )))) return this;
+
+ _.each( list, (elem,key) => {
+ if (_.isUndefined(elem.attrs)) return;
+ elem.attrs[this.sortKeys[index]].remove();
+ _.omit(elem.attrs,this.sortKeys[index]);
+ });
+ this.sortKeys.splice(index,1);
+ return this;
+ };
+
+ /*
+ * A function to find the index of a matching entry in a table
+ */
+
+ tableFind( attrDef, val ) {
+
+ let findRE = _.isRegExp(val);
+ let findArray = _.isArray(val);
+ let findVal = !findRE && !findArray && (_.isString(val) || _.isNumber(val) || _.isBoolean(val));
+ if (!(findRE || findArray || findVal)) {
+ LibFunctions.sendError('Invalid search term "'+val+'" of type '+typeof val+' for tableFind while searching '+this.table[0]);
+ return undefined;
+ };
+ if (findVal) val = String(val).dbName() || '-';
+ if (findArray) val = val.map(v => v.dbName() || '-');
+ let property = attrDef[1];
+ let attrVal = LibFunctions.attrLookup( this.character, attrDef );
+ if ((this.table[1] < 0) && (findVal ? (val === String(attrVal.dbName() || '-')) :
+ (findRE ? (attrVal.search(val) >= 0) :
+ (findArray ? (val.includes(attrVal.dbName() || '-')) :
+ false )))) {
+ return -1;
+ }
+// log('tableFind: attrDef[0] = '+attrDef[0]+', _.size(this[attrDef[0]].attrs) = '+_.size(this[attrDef[0]].attrs)+', findRE = '+findRE+', findArray = '+findArray+', property = '+property);
+ let tableIndex = this.sortKeys.indexOf(
+ _.findKey(this[attrDef[0]].attrs, function( elem ) {
+ return (!elem ? false :
+ (findVal ? (val === (String(elem.get(property)).dbName() || '-')) :
+ (findRE ? String((elem.get(property)).search(val) >= 0) :
+ (findArray ? (val.includes(String(elem.get(property)).dbName() || '-')) :
+ false ))));
+ })
+ );
+ if (tableIndex < 0 && ((findVal && val === '-') || (findArray && val.includes('-')))) {
+ tableIndex = this.sortKeys.findIndex(k => (_.isUndefined(this[attrDef[0]].attrs[k]) || (!this[attrDef[0]].attrs[k].get(property))));
+ }
+ return (tableIndex >= 0) ? tableIndex : undefined;
+ };
+
+ /*
+ * A function to set all rows of just one field of a table to
+ * a provided value, or its default if value not provided
+ */
+
+ tableDefault( attrDef, val ) {
+ if (!attrDef || !isArray(attrDef) || attrDef.length < 2) throw new Error('No table attribute supplied to tableDefault() for '+this.table[0]+', attrDef '+attrDef);
+ if (_.isUndefined(val) || _.isNull(val)) val = this[attrDef[0]].defaultVal[attrDef[1]];
+ if (!this[attrDef[0]]) throw new Error('Invalid table attribute '+attrDef[0]+' supplied for '+this.table[0]);
+ _.each(this[attrDef[0]].attrs, obj => {
+ obj.set(attrDef[1],val);
+ });
+ return this;
+ }
+
+ /*
+ * Make a copy of the default values for the table
+ */
+
+ copyValues() {
+ let newValues = {};
+ _.each( this.values, (v,k) => newValues[k] = Object.create(v));
+ return newValues;
+ }
+
+ }
+
+ class CSdbIndex {
+ constructor() {
+ this.mu_spells_db = {};
+ this.pr_spells_db = {};
+ this.powers_db = {};
+ this.mi_db = {};
+ this.race_db = {};
+ this.class_db = {};
+ this.attacks_db = {};
+ }
+ }
+
+
+ class LibFunctions {
+
+ static init(){
+
+ /** ------------------------------- Table Management ---------------------------- **/
+
+ /*
+ * A function to get the whole of a repeating table in
+ * two parts: an array of objects indexed by Roll20 object IDs,
+ * and an array of object IDs indexed by repeating table row number.
+ * Returns an object containing the table, and all parameters defining
+ * that table and where it came from.
+ */
+
+ LibFunctions.getTableField = function(character,tableObj,tableDef,attrDef,col,defaultVal,caseSensitive) {
+ if (_.isUndefined(tableObj) || _.isUndefined(tableObj.table)) tableObj = new CharTableArray( character, tableDef, col );
+ tableObj.addTable( attrDef, defaultVal, caseSensitive );
+ return tableObj;
+ }
+
+ /*
+ * Find all the necessary tables to manage a repeating
+ * section of a character sheet. Dynamically driven by
+ * the table field definitions in the 'fields' object.
+ */
+
+ LibFunctions.getTable = function( character, fieldGroup, col, tableObj, caseSensitive ) {
+ if (!fieldGroup) return undefined;
+ let tableDef = fieldGroup.tableDef;
+ if (_.isUndefined(tableObj) || _.isUndefined(tableObj.table)) tableObj = new CharTableArray( character, tableDef, col );
+ tableObj.addAllTables( fieldGroup.prefix, caseSensitive );
+ return tableObj;
+ }
+
+ /*
+ * Get all tables in a particular numbered group of tables,
+ * based not on columns but on a numbered sequence of prefixes
+ */
+
+ LibFunctions.getLvlTable = function( character, fieldGroup, lvl, tableObj, caseSensitive ) {
+ if (_.isUndefined(lvl) || _.isNull(lvl)) lvl = '';
+ let tableDef = [fieldGroup.tableDef[0]+lvl,fieldGroup.tableDef[1]];
+ if (_.isUndefined(tableObj) || _.isUndefined(tableObj.table)) tableObj = new CharTableArray( character, tableDef, null );
+ tableObj.addAllTables( fieldGroup.prefix, caseSensitive );
+ return tableObj;
+ }
+
+ /*
+ * Function to initialise a values[] array to hold data for
+ * setting a table row to.
+ */
+
+ LibFunctions.initValues = function( fieldGroup, values ) {
+
+// let values = [new Set()];
+ if (_.isUndefined(values)) values = {};
+ let rows = _.filter( fields, (elem,f) => f.startsWith(fieldGroup))
+ .map(elem => {
+ if (_.isUndefined(values[elem[0]])) {
+ values[elem[0]] = {current:'',max:''};
+ }
+ values[elem[0]][elem[1]] = elem[2] || '';
+ });
+ return values;
+ }
+
+ /** ------------------------ Attribute Management ------------------------------ **/
+
+ /**
+ * A function to return the handle for the 'fields' object for the represented
+ * character sheet mapping, and an object of handles for other game-specific values.
+ **/
+
+ LibFunctions.getRPGMap = function() {
+ RPGMap.dbNames = dbNames;
+ RPGMap.fieldGroups = fieldGroups;
+ RPGMap.miTypeLists = miTypeLists;
+ RPGMap.clTypeLists = clTypeLists;
+ RPGMap.spTypeLists = spTypeLists;
+ RPGMap.classMap = classMap;
+ RPGMap.baseThac0table = baseThac0table;
+ RPGMap.spellsPerLevel = spellsPerLevel;
+ RPGMap.spellLevels = spellLevels;
+ RPGMap.specMU = specMU;
+ RPGMap.ordMU = ordMU;
+ RPGMap.wisdomSpells = wisdomSpells;
+ RPGMap.casterLevels = casterLevels;
+ RPGMap.primeClasses = primeClasses;
+ RPGMap.classLevels = classLevels;
+ RPGMap.rangedWeapMods = rangedWeapMods;
+ RPGMap.saveLevels = saveLevels;
+ RPGMap.baseSaves = baseSaves;
+ RPGMap.classSaveMods = classSaveMods;
+ RPGMap.raceSaveMods = raceSaveMods;
+ RPGMap.defaultNonProfPenalty = defaultNonProfPenalty;
+ RPGMap.classNonProfPenalty = classNonProfPenalty;
+ RPGMap.raceToHitMods = raceToHitMods;
+ RPGMap.classAllowedWeaps = classAllowedWeaps;
+ RPGMap.classAllowedArmour = classAllowedArmour;
+ RPGMap.weapMultiAttks = weapMultiAttks;
+ RPGMap.punchWrestle = punchWrestle;
+ RPGMap.saveFormat = saveFormat;
+ RPGMap.rogueSkills = rogueSkills;
+ RPGMap.thiefSkillFactors = thiefSkillFactors;
+ RPGMap.reSpellSpecs = reSpellSpecs;
+ RPGMap.reWeapSpecs = reWeapSpecs;
+ RPGMap.reACSpecs = reACSpecs;
+ RPGMap.reClassSpecs = reClassSpecs;
+ RPGMap.reAttr = reAttr;
+ RPGMap.showMoreObj = showMoreObj;
+ return [fields,RPGMap];
+ }
+
+ /**
+ * A function to lookup the value of any attribute, including repeating rows, without errors
+ * thus avoiding the issues with getAttrByName()
+ *
+ * Thanks to The Aaron for this, which I have modded to split and
+ * allow tables to be loaded once rather than multiple times.
+ */
+
+ LibFunctions.attrLookup = function(character,attrDef,tableDef,r,c='',caseSensitive=false, defValParam=true) {
+ let name, match,
+ property = attrDef[1];
+
+ if (!character || !character.id || (tableDef && isNaN(r))) return undefined;
+
+ if (tableDef && (tableDef[1] || r >= 0)) {
+ c = (tableDef[1] || c != 1) ? c : '';
+ name = tableDef[0] + c + '_$' + r + '_' + attrDef[0] + c;
+ } else {
+ name = attrDef[0];
+ }
+ let defVal = (defValParam === false ? undefined : (defValParam === true ? attrDef[2] : defValParam));
+ if (!_.isUndefined(defVal)) defVal = String(defVal);
+ match=name.match(/^(repeating_.*)_\$(\d+)_.*$/);
+ if(match){
+ let index=match[2];
+ let tableObj = new CharTableArray( character, tableDef, c );
+ tableObj.addTable(attrDef,null,caseSensitive);
+ return tableObj.tableLookup(attrDef,index,defValParam,!attrDef[1]);
+ } else {
+ let attrObj = findObjs({ type:'attribute', characterid:character.id, name:name}, {caseInsensitive: !caseSensitive});
+ if (!attrObj || attrObj.length == 0) {
+ return (_.isUndefined(property) || _.isNull(property)) ? undefined : defVal;
+ } else if (_.isUndefined(property) || _.isNull(property)) {
+ return getObj('attribute',attrObj[0].id);
+ } else {
+ let value = getObj('attribute',attrObj[0].id).get(property);
+ return (_.isUndefined(value) ? defVal : String(value));
+ }
+ }
+ }
+
+ /**
+ * Check that an attribute exists, set it if it does, or
+ * create it if it doesn't using !setAttr
+ **/
+
+ LibFunctions.setAttr = function( character, attrDef, attrValue, tableDef, r, c, caseSensitive ) {
+
+ var name, attrObj, match;
+
+ if (_.isUndefined(attrDef)) {log('setAttr attrDef undefined:'+attrDef);return undefined;}
+ try {
+ name = attrDef[0];
+ } catch {
+ return undefined;
+ }
+
+ if (tableDef && (tableDef[1] || r >= 0)) {
+ c = (c && (tableDef[1] || c != 1)) ? c : '';
+ name = tableDef[0] + c + '_$' + r + '_' + attrDef[0] + c;
+ } else {
+ name = attrDef[0];
+ }
+ match=name.match(/^(repeating_.*)_\$(\d+)_.*$/);
+ if(match){
+ let tableObj = new CharTableArray( character, tableDef, c );
+ tableObj.addTable(attrDef,null,caseSensitive);
+ if (tableObj) {
+ attrObj = tableObj.tableLookup(attrDef,r,false,true);
+ }
+ } else {
+ attrObj = LibFunctions.attrLookup( character, [name, null], null, null, null, caseSensitive );
+ if (!attrObj) {
+ attrObj = createObj( 'attribute', {characterid:character.id, name:attrDef[0], current:'', max:''} );
+ }
+ };
+ if (attrObj) {
+ if (_.isUndefined(attrValue)) attrValue = _.isUndefined(attrDef[2]) ? '' : attrDef[2];
+ if (attrDef[3]) {
+ attrObj.setWithWorker(attrDef[1],String(attrValue));
+ } else {
+ attrObj.set(attrDef[1],String(attrValue));
+ }
+ }
+ return attrObj;
+ }
+
+ /** --------------------------- Ability Management Functions ------------------------------ **/
+
+
+ /**
+ * Find an ability macro with the specified name in any
+ * macro database with the specified root name, returning
+ * the database name, and the matching "ct-" object.
+ * If can't find a matching ability macro or "ct-" object
+ * then return undefined objects
+ * RED v2.044: Updated to use a database index of object IDs
+ * to speed up lookups.
+ **/
+
+ LibFunctions.abilityLookup = function( rootDB, ability, charCS, silent=false, def=true, isGM=false, trueAbility='' ) {
+
+ var charID, obj, ct, db, spells, items, objIndex, abilityName, action,
+ trueAbilityName = (trueAbility || '').dbName(),
+ source = 'charDB',
+ notFound = false,
+ abilityObj = [],
+ ctObj = [],
+ rDB = rootDB.toLowerCase().replace(/-/g,'_');
+
+ var getTypes = function( body ) {
+ let objType = [],
+ specs = body.match(/}}\s*?specs\s*?=(.*?){{/im);
+ specs = specs ? [...('['+specs[0]+']').matchAll(reSpecClass)] : [];
+ for (let i=0; i < specs.length; i++) {
+ objType.push(specs[i][1]);
+ }
+ return _.uniq(objType.join('|').toLowerCase().split('|')).join('|');
+ };
+
+ if (_.isUndefined(DBindex[rDB])) {
+ for (db of _.keys(DBindex)) {
+ if (rDB.startsWith(db)) {
+ rDB = db;
+ break;
+ }
+ }
+ }
+ if (!ability || ability.length==0 || ability === '-') {
+ return (!def ? new AbilityObj( rootDB, undefined, undefined, undefined) : new AbilityObj( rDB, [undefined,blankItem], [undefined,0], 'apiDB'));
+ }
+
+ do {
+ abilityName = (ability || '').dbName();
+ if (!_.isUndefined(DBindex[rDB]) && !_.isUndefined(DBindex[rDB][abilityName])) {
+ objIndex = DBindex[rDB][abilityName];
+ if (objIndex[0].length) {
+ obj = getObj('ability',objIndex[0]);
+ }
+ }
+ if (charCS && (!objIndex || (objIndex[0].length && !obj))) {
+ obj = findObjs({ type:'ability', characterid:charCS.id, name:(ability.replace(/\s/g,'-')) });
+ if (obj && obj.length) {
+ source = 'sheet';
+ obj = obj[0];
+ objIndex = [];
+ objIndex.push(obj.id);
+ ct = findObjs({ type:'attribute', characterid:charCS.id, name:'ct-'+ability });
+ }
+ }
+ notFound = notFound || (!objIndex || (objIndex[0].length && !obj));
+ if (notFound) ability = trueAbility;
+ } while (notFound && abilityName !== trueAbilityName && ability && ability.length);
+
+ if (!objIndex || (objIndex[0].length && !obj)) {
+// if (!silent) log('Not found ability '+abilityName+' in any '+rootDB+' database');
+ return new AbilityObj( rootDB, undefined, undefined, undefined);
+ } else if (!objIndex[0].length || !obj) {
+ source = 'apiDB';
+ db = rootDB;
+ obj = dbNames[objIndex[2]].db[objIndex[3]];
+ obj.body = LibFunctions.parseStr(obj.body,dbReplacers);
+ abilityObj = [undefined,obj];
+ ctObj = [undefined,obj.ct];
+ } else {
+ charID = obj.get('characterid');
+ db = getObj('character',charID).get('name');
+ spells = db.startsWith(fields.MU_SpellsDB) || db.startsWith(fields.PR_SpellsDB) || db.startsWith(fields.Powers_DB);
+ items = db.startsWith(fields.MagicItemDB);
+ abilityObj[0] = obj;
+ action = obj.get('action');
+ ct = !ct ? getObj('attribute',objIndex[1]) : ct[0];
+ abilityObj[1] = {name:obj.get('name'),
+ type:getTypes(action),
+ ct:(!ct ? 0 : ct.get('current')),
+ charge:(!ct || spells ? 'uncharged' : ct.get('max')),
+ cost:(!ct || items ? '0' : ct.get('max')),
+ body:action};
+ ctObj = [ct,abilityObj[1].ct];
+ };
+// if (!notFound && !isGM) abilityObj[1].body = abilityObj[1].body.replace(/{{\s*?Looks Like\s*=.*?}}/img,'');
+ return new AbilityObj( db, abilityObj, ctObj, source );
+ }
+
+ /*
+ * Create or update an ability on a character sheet
+ */
+
+ LibFunctions.setAbility = function( charCS, abilityName, abilityMacro, actionBar=false ) {
+
+ if (!charCS) {log('setAbility error: invalid character sheet');return;}
+ abilityName = !abilityName ? '-' : abilityName.hyphened();
+ var abilityObj = findObjs({type: 'ability',
+ characterid: charCS.id,
+ name: abilityName},
+ {caseInsensitive:true});
+ if (!abilityObj || abilityObj.length == 0 || !abilityObj[0] || abilityObj[0].get('name') !== abilityName) {
+ abilityObj = createObj( 'ability', {characterid: charCS.id,
+ name: abilityName,
+ action: abilityMacro,
+ istokenaction: actionBar});
+ } else {
+ abilityObj = abilityObj[0];
+ abilityObj.set( 'action', abilityMacro );
+ abilityObj.set( 'istokenaction', actionBar );
+ }
+ return abilityObj;
+ }
+
+ /*
+ * Handle displaying an Ability Macro
+ */
+
+ LibFunctions.doDisplayAbility = function( args, selected, senderId, as, img ) {
+ if (!args) return;
+ if (!args[0] && selected && selected.length) {
+ args[0] = selected[0]._id;
+ }
+ if (args.length < 3) {
+ LibFunctions.sendError('Incorrect RPGMaster command syntax',msg_orig[senderId]);
+ return;
+ }
+ if ((charCS = LibFunctions.getCharacter(args[0]))) args.unshift('standard');
+ var cmd = (args[0] || 'standard').toLowerCase(),
+ tokenID = args[1],
+ db = args[2],
+ ability = args[3],
+ diceRoll1 = args[4] || '',
+ diceRoll2 = args[5] || '',
+ targetID = args[6] || '',
+ charCS = LibFunctions.getCharacter(tokenID),
+ targetToken = getObj('graphic',targetID),
+ targetCS = (targetToken ? getObj('character',targetToken.get('represents')) : undefined),
+ isView = cmd.includes('view'),
+ abObj, abilityMacro;
+
+ var diceRoll = function( rollTxt ) {
+ if (!rollTxt) return randomInteger(20);
+ var retVal = rollTxt.match(/\d+d\d+/i);
+ retVal = (!retVal) ? parseInt((rollTxt.match(/\((\d+)\)/) || rollTxt.match(/(\d+)/) || [0,randomInteger(20)])[1]) : '[['+retVal+']]';
+ return retVal;
+ };
+
+ if (!charCS) {
+ LibFunctions.sendError('The token identified does not represent a character sheet',msg_orig[senderId]);
+ return;
+ }
+
+ if (db.toLowerCase().includes('-db')) {
+ abObj = LibFunctions.getAbility( db, ability, charCS );
+ if (!abObj.obj) {
+ LibFunctions.sendError(('The provided ability does not exist in any '+db+' database'),msg_orig[senderId]);
+ return;
+ }
+ abilityMacro = abObj.obj[1].body;
+ } else {
+ var abilityCS = findObjs({type:'character',_id:db});
+ if (!abilityCS || !abilityCS.length) abilityCS = findObjs({type:'character',name:db}, {caseInsensitive: true});
+ if (abilityCS && abilityCS[0]) {
+ abObj = findObjs({type:'ability',characterid:abilityCS[0].id,name:ability}, {caseInsensitive: true});
+ }
+ if (!abObj || !abObj.length) {
+ LibFunctions.sendError(('Not found ability '+ability+' for character '+db),msg_orig[senderId]);
+ return;
+ }
+ abilityMacro = abObj[0].get('action');
+ }
+ diceRoll1 = diceRoll(diceRoll1);
+ diceRoll2 = diceRoll(diceRoll2);
+
+ abilityMacro = abilityMacro.replace(/\}\}\}/g,'} }}')
+ .replace(/%%diceRoll1%%/img,diceRoll1)
+ .replace(/%%diceRoll2%%/img,diceRoll2)
+ .replace(/@{selected\|token_id}/img,tokenID)
+ .replace(/@{selected/img,'@{'+charCS.get('name'));
+ if (targetToken && targetCS) {
+ let targetHP = LibFunctions.getTokenValue( targetToken, fields.token_HP, fields.HP, null, fields.Thac0_base, false );
+ let targetMaxHP = LibFunctions.getTokenValue( targetToken, fields.token_MaxHP, fields.MaxHP, null, fields.Thac0_base, false );
+ let targetAC = LibFunctions.getTokenValue( targetToken, fields.token_AC, fields.AC, fields.MonsterAC, fields.Thac0_base, false );
+ let tokenName = targetToken.get('name');
+ let targetName = targetCS.get('name');
+ let heart = abilityMacro.match(/\{\{\s*Token[_\s]Heart\s*=.*?\}\}/im);
+ if (heart) {
+ abilityMacro = abilityMacro.replace(heart[0],'{{Token_Heart='+Math.ceil(8*Math.max(0,targetHP.val)/targetMaxHP.val)+'}}');
+ }
+ abilityMacro = abilityMacro.replace(/@\{\s*target\s*\|?[^\{\}]*?\|\s*token_id\s*\}/img,targetID)
+ .replace(/(?:\[\[)?\s*(?:0\s*\+)?\s*@\{target\|?[^\{\}]*?\|hp\|max\}\s*(?:\&\{noerror\})?\s*(?:\]\])?/img,targetMaxHP.val+' ')
+ .replace(/(?:\[\[)?\s*(?:0\s*\+)?\s*@\{target\|?[^\{\}]*?\|hp\}\s*(?:\&\{noerror\})?\s*(?:\]\])?/img,targetHP.val+' ')
+ .replace(new RegExp('(?:\\[\\[)?\\s*(?:0\\s*\\+)?\\s*@\\{target\\|?[^\\{\\}]*?\\|'+targetMaxHP.name+'\\|max\\}\\s*(?:\\&\\{noerror\\})?\\s*(?:\\]\\])?','img'),targetMaxHP.val+' ')
+ .replace(new RegExp('(?:\\[\\[)?\\s*(?:0\\s*\\+)?\\s*@\\{target\\|?[^\\{\\}]*?\\|'+targetHP.name+'\\}\s*(?:\\&\\{noerror\\})?\\s*(?:\\]\\])?','img'),targetHP.val+' ')
+ .replace(/(?:\[\[)?\s*(?:0\s*\+)?\s*@\{target\|?[^\{\}]*?\|ac\}\s*(?:\&\{noerror\})?\s*(?:\]\])?/img,targetAC+' ')
+ .replace(/@\{target\|?[^\{\}]*?\|token_name\}(?:\s*\&\{noerror\})?/img,tokenName+' ');
+ let targetFields = [...abilityMacro.matchAll(/(?:\[\[)?(?:\s*0\s*\+)?\s*(@\{target\|.*?\})\s*(?:\&\{noerror\})?\s*(?:\]\])?/img)];
+ _.each(targetFields, f => abilityMacro = abilityMacro.replace(f[0],f[1]));
+ abilityMacro = abilityMacro.replace(/@\{target\|?[^\{\}]*?\|/img,'@{'+targetName+'|');
+ }
+ cmd = cmd.replace(/\-?view/,'');
+ if (isView && !state.MagicMaster.viewActions) abilityMacro = abilityMacro.replace(/(? LibFunctions.parseStr((LibFunctions.attrLookup( charCS, [fields.ItemVar[0]+name+'+'+row+'-'+w,'current'] ) || '').split('/')[v] || '');
+
+ if (abObj.obj) {
+ do {
+ extra = abObj.obj[1].body.match(/%{([^\|]+?)\|([^}]+?)}/);
+ if (extra) {
+ if (!extraList.includes(extra[2].dbName())) {
+ extraList.push(extra[2].dbName());
+ extraDef = LibFunctions.abilityLookup( extra[1], extra[2], charCS, silent );
+ } else {
+ extraDef.obj = undefined;
+ }
+ if (extraDef.obj) {
+ abObj.obj[1].body = abObj.obj[1].body.replace(/%{([^\|]+?)\|([^}]+?)}/,extraDef.obj[1].body.replace('$$','$$$$'));
+ } else {
+ abObj.obj[1].body = abObj.obj[1].body.replace(/%{([^\|]+?)\|([^}]+?)}/,'');
+ }
+ }
+ } while (extra && extraDef.obj);
+
+ trueName = (trueName || '').trim();
+ if (!isGM && trueName && trueName.length && (name.dbName() === trueName.dbName())) {
+ abObj.obj[1].body = abObj.obj[1].body.replace(/{{\s*?Looks\s?Like\s*=/img,'{{Appearance=');
+ }
+ if (charCS) {
+ if (trueName && trueName.length && name.dbName() !== trueName.dbName()) {
+ let cmd = '{{GM Info=[Reveal Now](!magic --button GM-ResetSingleMI|'+charCS.id+'|'+(name)
+ + ' --message gm|'+charCS.id+'|Revealing '+(trueName.dispName())+'|The item '+(trueName.dispName())+' which was hidden as '+(name.dispName())+' has been revealed)';
+ if (/{{\s*GM\s?Info\s*=/im.test(abObj.obj[1].body)) {
+ abObj.obj[1].body = abObj.obj[1].body.replace(/{{\s*GM\s?Info\s*=([^\[])/im,(cmd + ' $1'));
+ } else {
+ abObj.obj[1].body += cmd + '}}';
+ }
+ }
+ while (reVars.test(abObj.obj[1].body)) abObj.obj[1].body = abObj.obj[1].body.replace(reVars,varRes);
+ abObj.obj[0] = LibFunctions.setAbility( charCS, name, abObj.obj[1].body );
+ LibFunctions.setAttr( charCS, [fields.CastingTimePrefix[0]+name,'current'], abObj.obj[1].ct );
+ LibFunctions.setAttr( charCS, [fields.CastingTimePrefix[0]+name,'max'], abObj.obj[1].charge );
+ abObj.dB = charCS.get('name');
+ }
+ }
+ return abObj;
+ }
+
+ /** -------------------------------------------- send messages to chat ----------------------------------------- **/
+
+ LibFunctions.parseTemplate = function( txt ) {
+// return LibFunctions.parseOutput( '', '', '', txt, null, null, null, false );
+ };
+
+ LibFunctions.redisplayOutput = function(senderId) {
+ if (senderId && senderId.length && !_.isUndefined(lastMsg[senderId])) {
+ let args = [...lastMsg[senderId]];
+ if (args.length > 3) {
+ return LibFunctions.parseOutput( args[0], args[1], args[2], args[3], senderId );
+ }
+ }
+ }
+
+ /*
+ * Parse the standard Roll Template structure for RPGMaster
+ * templates and return the converted text for display in the
+ * chat window.
+ */
+
+ LibFunctions.parseOutput = function( as, preamble, template, txt, senderId ) {
+
+ var isGM = false;
+ var originalTxt = txt;
+ if (senderId && senderId.length) {
+ for (const playerId of senderId.split(',')) {
+ lastMsg[playerId] = arguments;
+ isGM = isGM || playerIsGM(playerId);
+ }
+ }
+
+ clearWaitTimer(senderId);
+
+ txt = txt.replace(/}}\s*?k/img,'} }k')
+ .replace(/{{=/img,'{{ =')
+ .replace(/</img,'<')
+ .replace(/>/img,'>')
+ .replace(/{{\s*}}/img,'');
+
+ var colours, colourSet;
+
+ switch (template.toLowerCase()) {
+ case 'rpgmattack':
+ colourSet = 'attack';
+ break;
+ case 'rpgmweapon':
+ case 'rpgmammo':
+ colourSet = 'weapon';
+ break;
+ case 'rpgmpotion':
+ colourSet = 'potion';
+ break;
+ case 'rpgmspell':
+ case 'rpgmitemspell':
+ case 'rpgmwandspell':
+ case 'rpgmscroll':
+ colourSet = 'spell';
+ break;
+ case 'rpgmmenu':
+ colourSet = 'menu';
+ break;
+ case 'rpgmmessage':
+ colourSet = 'message';
+ break;
+ case 'rpgmwarning':
+ colourSet = 'warning';
+ break;
+ case 'rpgmarmour':
+ case 'rpgmitem':
+ case 'rpgmring':
+ case 'rpgmwand':
+ case 'rpgmclass':
+ case 'rpgmdefault':
+ default:
+ colourSet = 'def';
+ break;
+ }
+ if (_.isUndefined(state.MagicMaster) || _.isUndefined(state.attackMaster)) {
+ colours = Object.create(pallet.plain[colourSet]);
+ } else if (!senderId || _.isUndefined(state.MagicMaster.playerConfig) || _.isUndefined(state.MagicMaster.playerConfig[senderId])) {
+ colours = Object.create((state.attackMaster.fancy || state.MagicMaster.fancy) ? pallet.fancy[colourSet] : pallet.plain[colourSet]);
+ } else {
+ let config = state.MagicMaster.playerConfig[senderId];
+ colours = Object.create(config.menuImages ? pallet.fancy[colourSet] : (config.menuDark ? pallet.dark[colourSet] : pallet.plain[colourSet]));
+ }
+ if (template) {
+ const txtObj = _.object([...txt.replace(/[\r\n]/g,'').matchAll(/\{\{(.+?)=(.*?)\}\}/g)].map(v => v.slice(1)).map(v => [v[0].dbName(),v[1]]));
+ _.each( txtObj, (t,k) => {
+ if (!_.isUndefined(colours[k])) {
+ colours[k] = t;
+ txt = txt.replace(new RegExp(`{{\\s*${k}\\s*=.*?}}`,'img'),'');
+ }
+ });
+ };
+ const outerFrame = '';
+ const endOuterFrame = '
';
+ const headerFrame = '';
+ const endHeaderFrame = ' |
';
+ const header1 = '';
+ const endHeader1 = '';
+ const header2 = '';
+ const endHeader2 = '';
+ const subtitle1 = '
';
+ const endSubtitle1 = '';
+ const subtitle2 = '
';
+ const endSubtitle2 = '';
+ const settings = '';
+
+ const bodyFrame = '';
+ const fullBodyFrame = '';
+ const lastBodyFrame = '';
+ const endBodyFrame = ' ';
+ const row1col = ['',
+ ' '];
+ const rowResult = ['',
+ ' '];
+ const endRowResult = ' '
+ const endRow1col = ' ';
+ const rowHeader = ' ';
+ const endRowHeader = ' | ';
+ const rowBodyC = ' ';
+ const endRowBodyC = ' | ';
+ const rowBody = ' ';
+ const endRowBody = ' | ';
+ const row1 = ' ';
+ const endRow1 = ' | ';
+ const row1C = ' ';
+ const endRow1C = ' | ';
+ const row2col = [' ',
+ ' '];
+ const endRow2col = ' ';
+ const rowL = ' ';
+ const endRowL = ' | ';
+ const rowR = ' ';
+ const endRowR = ' | ';
+ const rowC = ' ';
+ const endRowC = ' | ';
+ const row2 = ' ';
+ const endRow2 = ' | ';
+ const rowC2 = ' ';
+ const endRowC2 = ' | ';
+ const titleDmgSM = ' '+colours.dmgslabel+' | ';
+ const rowDmgSM = ' ';
+ const endRowDmgSM = ' | ';
+ const titleAC = ' AC Hit | ';
+ const rowAC = ' ';
+ const endRowAC = ' | ';
+ const rowType = ' ';
+ const endRowType = ' | ';
+ const titleDmgL = ' '+colours.dmgllabel+' | ';
+ const rowDmgL = ' ';
+ const endRowDmgL = ' | ';
+ const sImg = ' '
+ const pImg = ' '
+ const bImg = ' '
+ const rowTargetAC = ' Target | ';
+ const endRowTargetAC = ' | AC | ';
+ const rowTargetSAC = ' ';
+ const endRowTargetSAC = ' | ';
+ const rowTargetPAC = ' ';
+ const endRowTargetPAC = ' | ';
+ const rowTargetBAC = ' ';
+ const endRowTargetBAC = ' | ';
+ const titleTargetHP = ' Target HP | '
+ const rowTargetHP = ' ';
+ const endTableStyle = ' | ';
+
+ const addDescs = function( txtObj, j, showMore='', rowCols=row1col, rowFrame=row1 ) {
+ let content = '';
+ if (!_.isUndefined(txtObj.desc)) content += (rowCols[(j++)%2]+rowFrame+ txtObj.desc +showMore +endRow1+endRow1col);
+ for (let i=1; i<=9; ++i) {
+ if (!_.isUndefined(txtObj['desc'+i])) content += (rowCols[(j++)%2]+rowFrame+ txtObj['desc'+i] +endRow1+endRow1col);
+ };
+ return content;
+ };
+
+ const maxDiceRoll = function( diceRoll ) {
+ var rollData = diceRoll.match(/(\d+)d(\d+)/i)||fields.ToHitRoll.match(/(\d+)d(\d+)/i)||['1d20',1,20];
+ return {min:(parseInt(rollData[1])||1), max:((parseInt(rollData[1]) * parseInt(rollData[2]))||20)};
+ };
+
+ const RPGMattack = function( txt ) {
+
+ const arReplace = function( txt, ac ) {
+ let arAdj = txt.match(/([-+]?\d+)\[([\s\w\d]+?)=([-+\d\|]+?)\]/i);
+ if (!arAdj || !arAdj.length) return txt;
+ txt = arAdj[3].split('|')[arAdj[1]];
+ txt = '+-'.includes(txt[0]) ? txt : '+'+txt;
+ return '[['+(ac && ac.length ? ac : arAdj[1])+txt+' ['+txt+' ['+arAdj[2]+'] ] ]]';
+ }
+
+ const varReplace = function( str, field ) {
+ var value = '';
+ if (field) {
+ field = field.replace(/[-\s]/g,'_').toLowerCase();
+ value = (/^[-+]?[\d.]+/.test(field)|| _.isUndefined(txtObj[field])) ? parseFloat(field) : parseFloat(txtObj[field].match(/[-+]?[\d.]+/));
+ }
+ return value;
+ }
+
+ const attkDefaults = {title:'', name:'', subtitle:'', ac_hit:'', target_ac:'', attk_type:'', target_sac:'', target_pac:'', target_bac:'', dmg_s:'', dmg_l:'', target_hp:'', target_maxhp:''};
+ const txtObj = _.object([...txt.replace(/[\r\n]/g,'').replace(/\}\}\}/g,'} }}').matchAll(/\{\{(.+?)=(.*?)\}\}/g)].map(v => v.slice(1)).map(v => [v[0].replace(/[-\s]/g,'_').toLowerCase(),v[1]]));
+ const dice_roll = parseInt((txtObj.ac_hit.match(/(\d+)\[Dice roll\]/i) || ['',''])[1]);
+ const toHitRoll = (txt.match(/specs=\[.*?(\d+d\d+),.*\]/im)||['',fields.ToHitRoll])[1];
+ const minMaxRoll = maxDiceRoll(toHitRoll);
+ const isMax = state.attackMaster.naturalRolls && !isNaN(dice_roll) && dice_roll >= minMaxRoll.max;
+ const isMin = state.attackMaster.naturalRolls && !isNaN(dice_roll) && dice_roll <= minMaxRoll.min;
+ const hasDescs = /{{\s*desc\d?\s*=/im.test(txt);
+ var crit = false;
+ var fumble = false;
+ _.defaults(txtObj,attkDefaults);
+ txtObj.target_sac = arReplace( txtObj.target_sac, txtObj.target_ac );
+ txtObj.target_pac = arReplace( txtObj.target_pac, txtObj.target_ac );
+ txtObj.target_bac = arReplace( txtObj.target_bac, txtObj.target_ac );
+ txtObj.attk_type = txtObj.attk_type.toLowerCase();
+ var content = outerFrame;
+
+ if (txtObj.title.length || txtObj.name.length) {
+ content += headerFrame
+ +header1+ txtObj.title+' '+txtObj.name +endHeader1
+ +(txtObj.subtitle ? (subtitle1+ txtObj.subtitle +endSubtitle1) : '')
+ +settings
+ +endHeaderFrame;
+ }
+ if (txtObj.ac_hit != '') {
+ content += ((txtObj.crit_roll || txtObj.fumble_roll || txtObj.ar_adjust || txtObj.target_ac != '' || txtObj.result || hasDescs) ? bodyFrame : lastBodyFrame)
+ +row1col[0]
+ +titleDmgSM
+ +rowAC+ txtObj.ac_hit +endRowAC
+ +titleDmgL
+ +endRow1col
+ +row1col[1]
+ +rowDmgSM+ txtObj.dmg_s +endRowDmgSM
+ +rowDmgL+ txtObj.dmg_l +endRowDmgL
+ +endRow1col
+ +row1col[0]
+ +titleAC
+ +endRow1col
+ +row1col[1]
+ +rowType+ [sImg,pImg,bImg].filter((e,i) => txtObj.attk_type.includes(['s','p','b'][i])).join('') +endRowType
+ +endRow1col
+ +endBodyFrame;
+ }
+ if (txtObj.ar_adjust) {
+ content += ((txtObj.target_ac != '' || txtObj.result || hasDescs) ? bodyFrame : lastBodyFrame)
+ +row1col[0]
+ + rowC + txtObj.ar_adjust + endRowC
+ +endRow1col
+ +endBodyFrame;
+ }
+ if ((txtObj.crit_roll || txtObj.fumble_roll) && !isNaN(dice_roll)) {
+ const crit_roll = parseInt(txtObj.crit_roll);
+ const fumble_roll = parseInt(txtObj.fumble_roll);
+ crit = (crit_roll && (crit_roll <= dice_roll));
+ fumble = (fumble_roll && (fumble_roll >= dice_roll));
+ if (crit || fumble) {
+ content += ((txtObj.target_ac != '' || txtObj.result || hasDescs) ? bodyFrame : lastBodyFrame)
+ +(!crit ? '' : (rowResult[0]+rowC+ (txtObj.crit || 'Critical Hit!') +endRowC+endRowResult))
+ +(!fumble ? '' : (rowResult[1]+rowC+ (txtObj.fumble || 'Fumbled!') +endRowC+endRowResult))
+ +endBodyFrame;
+ }
+ }
+ if (txtObj.target_ac != '') {
+ const target_hp = parseInt(txtObj.target_hp.match(/[-+]?\d+/));
+ const target_maxhp = parseInt(txtObj.target_maxhp.match(/[-+]?\d+/));
+ const heart_url = !(isNaN(target_hp) || isNaN(target_maxhp)) ? heart[Math.min(Math.ceil(8*Math.max(target_hp,0)/target_maxhp),8)] : '';
+ content += ((txtObj.result) ? bodyFrame : lastBodyFrame)
+ +row1col[0]
+ +rowTargetAC+ txtObj.target_ac +endRowTargetAC
+ +titleTargetHP
+ +endRow1col
+ +row1col[1]
+ +rowTargetSAC+ txtObj.target_sac +endRowTargetSAC
+ +rowTargetPAC+ txtObj.target_pac +endRowTargetPAC
+ +rowTargetBAC+ txtObj.target_bac +endRowTargetBAC
+ +rowTargetHP + 'background-image: url('+heart_url+');">' +endRowTargetHP
+ +endRow1col
+ +endBodyFrame;
+ }
+ if (txtObj.result || ((isMax || isMin) && !(crit || fumble))) {
+ let result = isMax || crit;
+ if (txtObj.result) {
+ const test = txtObj.result.match(/([\w\s_.+-]+?|[-+]?[\d.]+?)((?:<=|>=|<|>|=|<>|!=))(.+)/);
+ if (test) {
+ const field1 = test[1].replace(/[-\s]/g,'_').toLowerCase();
+ const field2 = test[3].replace(/[-\s]/g,'_').toLowerCase();
+ const value1 = (/^[-+]?[\d.]+/.test(test[1])|| _.isUndefined(txtObj[field1])) ? parseFloat(test[1]) : parseFloat(txtObj[field1].match(/[-+]?[\d.]+/));
+ const value2 = (/^[-+]?[\d.]+/.test(test[3])|| _.isUndefined(txtObj[field2])) ? parseFloat(test[3]) : parseFloat(txtObj[field2].match(/[-+]?[\d.]+/));
+ switch (test[2]) {
+ case '=': result = value1 == value2; break;
+ case '<': result = value1 < value2; break;
+ case '>': result = value1 > value2; break;
+ case '<=': result = value1 <= value2; break;
+ case '>=': result = value1 >= value2; break;
+ case '<>': result = value1 != value2; break;
+ case '!=': result = value1 != value2; break;
+ default: result = false;
+ }
+ if (state.attackMaster.weapRules.naturals) result = (result || isMax) && !isMin;
+ if (state.attackMaster.weapRules.criticals) result = (result || crit) && !fumble;
+ if (txtObj.successcmd && txtObj.successcmd.length && result) {
+ while (/%%[_\d\w\+-]+?%%/.test(txtObj.successcmd)) txtObj.successcmd = txtObj.successcmd.replace( /%%([_\d\w\+-]+?)%%/, varReplace );
+ _.each(txtObj.successcmd.split(' '),cmd => LibFunctions.sendAPI(LibFunctions.parseStr(cmd)));
+ } else if (txtObj.failcmd && txtObj.failcmd.length && !result) {
+ while (/%%[_\d\w\+-]+?%%/.test(txtObj.failcmd)) txtObj.failcmd = txtObj.failcmd.replace( /%%([_\d\w\+-]+?)%%/, varReplace );
+ _.each(txtObj.failcmd.split(' '),cmd => LibFunctions.sendAPI(LibFunctions.parseStr(cmd)));
+ };
+ }
+ };
+ content += (hasDescs ? bodyFrame : lastBodyFrame)
+ +rowResult[result ? 0 : 1]+rowC+''+ ((isMax || crit) ? 'Natural '+dice_roll : ((isMin || fumble) ? 'Natural '+dice_roll : (result ? 'Success' : 'Failure'))) +''+endRowC+endRowResult
+ +endBodyFrame;
+ }
+ if (hasDescs) {
+ content += lastBodyFrame
+ +addDescs(txtObj,1)
+ +endBodyFrame;
+ }
+ content += endOuterFrame;
+ return content;
+ };
+
+ const RPGMspell = function( txt, preamble ) {
+ const spellDefaults = {prefix:'', title:'', name:'', splevel:'', school:'', range:'', components:'', duration:'', time:'', aoe:'', save:'', effects:''};
+ let k=1;
+ const txtObj = _.object([...txt.replace(/[\r\n]/g,'').matchAll(/\{\{(.+?)=(.*?)\}\}/g)].map(v => v.slice(1)).map(v => [v[0].replace(/[-\s]/g,'_').toLowerCase(),v[1]]));
+ const isLooksLike = !isGM && !!txtObj.looks_like;
+ _.defaults(txtObj,spellDefaults);
+ const showMore = /{{hide\d=/img.test(originalTxt);
+ const showLess = /{{desc\d=/img.test(originalTxt);
+ const txtRowID = generateRowID();
+ if (showMore || showLess) showMoreObj[txtRowID] = (preamble+'&{template:'+template+'}'+originalTxt.replace(/{{hide(\d)=/img,'{{reveal$1=').replace(/{{desc(\d)=/img,'{{hide$1=').replace(/{{reveal(\d)=/img,'{{desc$1=') );
+ const showMoreButton = (showMore || showLess) ? (' *show '+(showMore ? 'more' : 'less')+'...*') : '';
+ const hasDescs = !isLooksLike && /{{\s*desc\d?\s*=/im.test(txt);
+ var content = outerFrame
+ +headerFrame
+ +header2+ (!isLooksLike ? txtObj.prefix : '')+' '+txtObj.title+' '+(!isLooksLike ? txtObj.name : '')+endHeader2
+ +(!isLooksLike ? (subtitle2+ txtObj.splevel +' * '+ txtObj.school +endSubtitle2) : '')
+ +settings
+ +endHeaderFrame
+ +lastBodyFrame
+ +(!isLooksLike ? (
+ row2col[++k%2]+rowL+'Range '+ txtObj.range +endRowL
+ +rowR+'Components '+ txtObj.components +endRowR+endRow2col
+ +row2col[++k%2]+rowL+'Duration '+ txtObj.duration +endRowL
+ +rowR+'Casting Time '+ txtObj.time +endRowR+endRow2col
+ +row2col[++k%2]+rowL+'Area of Effect '+ txtObj.aoe +endRowL
+ +rowR+'Saving Throw '+ txtObj.save +endRowR+endRow2col
+ +(txtObj.healing ? (row2col[++k%2]+rowC2+'Healing: '+ txtObj.healing +endRowC2+endRow2col) : '')
+ +(txtObj.damage ? (row2col[++k%2]+rowC2+'Damage: '+ txtObj.damage +endRowC2+endRow2col) : '')
+ +(txtObj.reference ? (row2col[++k%2]+rowC2+'Reference: '+ txtObj.reference +endRowC2+endRow2col) : '')
+ +(txtObj.materials ? (row2col[++k%2]+rowC2+'Materials: '+ txtObj.materials +endRowC2+endRow2col) : '')
+ +(txtObj.use ? (row2col[++k%2]+row2+'Use: '+ txtObj.use +endRow2+endRow2col) : '')
+ +(isGM && txtObj.gm_info ? (row2col[++k%2]+row2+'GM Info: '+ txtObj.gm_info +endRow2+endRow2col) : '')
+ ) : '')
+ +(txtObj.looks_like || txtObj.appearance ? (row2col[++k%2]+row2+(!isLooksLike ? 'Looks Like: ' : '')+ (txtObj.looks_like ? txtObj.looks_like : txtObj.appearance) +endRow2+endRow2col) : '')
+ +(!isLooksLike ? (
+ row2col[++k%2]+row2+'Effects: '+ txtObj.effects +showMoreButton +endRow2+endRow2col
+ +addDescs(txtObj,++k,'',row2col,row2)
+ ) : '');
+ +endBodyFrame
+ +endOuterFrame;
+ return content;
+ }
+
+ const RPGMmessage = function( txt ) {
+
+ const txtObj = _.object([...txt.replace(/[\r\n]/g,'').matchAll(/\{\{(.+?)=(.*?)\}\}/g)].map(v => v.slice(1)).map(v => [v[0].replace(/[-\s]/g,'_').toLowerCase(),v[1]]));
+ let content = outerFrame;
+ if (txtObj.name || txtObj.title) {
+ content += headerFrame
+ +header1+ (txtObj.title || '')+(txtObj.name || '') +endHeader1
+ +settings
+ +endHeaderFrame
+ +lastBodyFrame;
+ } else {
+ content += fullBodyFrame;
+ }
+ content += addDescs(txtObj,1)
+ +endBodyFrame
+ +endOuterFrame;
+ return content;
+ }
+
+ const RPGMdefault = function( txt, preamble, isShowMore=true ) {
+
+ var value1, value2;
+
+ const resultTest = function( t ) {
+ let result = false;
+ const test = t.match(/([\w\s_.+-]+?|[-+]?[\d.]+?)((?:<=|>=|<|>|=|<>|!=))(.+)/);
+ if (test) {
+ value1 = (/^[-+]?[\d.]+/.test(test[1])|| _.isUndefined(txtObj[test[1]])) ? parseFloat(test[1]) : parseFloat(txtObj[test[1]].match(/[-+]?[\d.]+/));
+ value2 = (/^[-+]?[\d.]+/.test(test[3])|| _.isUndefined(txtObj[test[3]])) ? parseFloat(test[3]) : parseFloat(txtObj[test[3]].match(/[-+]?[\d.]+/));
+ switch (test[2]) {
+ case '=': result = value1 == value2; break;
+ case '<': result = value1 < value2; break;
+ case '>': result = value1 > value2; break;
+ case '<=': result = value1 <= value2; break;
+ case '>=': result = value1 >= value2; break;
+ case '<>': result = value1 != value2; break;
+ case '!=': result = value1 != value2; break;
+ default: result = false;
+ }
+ }
+ return result;
+ };
+
+ const rollVal = (m,v) => LibFunctions.evalAttr(v);
+
+ const defDefaults = {prefix:'', title:'', name:'', success:'', failure:''};
+ const txtObj = _.object([...txt.replace(/[\r\n]/g,'').matchAll(/\{\{(.+?)=(.*?)\}\}/g)].map(v => v.slice(1)));
+ const isLooksLike = !isGM && /{{\s*Looks\s?Like\s*=.*?}}/im.test(txt);
+ _.defaults(txtObj,defDefaults);
+ const showMore = /{{hide\d=/img.test(originalTxt);
+ const showLess = /{{desc\d=/img.test(originalTxt);
+ const txtRowID = generateRowID();
+ if (isShowMore && (showMore || showLess)) showMoreObj[txtRowID] = (preamble+' &{template:'+template+'}'+originalTxt.replace(/{{hide(\d)=/img,'{{reveal$1=').replace(/{{desc(\d)=/img,'{{hide$1=').replace(/{{reveal(\d)=/img,'{{desc$1=') );
+ const showMoreButton = (isShowMore && (showMore || showLess)) ? (' *show '+(showMore ? 'more' : 'less')+'...*') : '';
+ let content = outerFrame
+ +headerFrame
+ +header1+ (!isLooksLike ? txtObj.prefix : '')+' '+txtObj.title+' '+(!isLooksLike ? txtObj.name : '')+endHeader1
+ +(txtObj.subtitle && !isLooksLike ? (subtitle1+ txtObj.subtitle +endSubtitle1) : '')
+ +settings
+ +endHeaderFrame;
+ content += lastBodyFrame;
+ let j=1, crit=false, fumble=false, result=false;
+
+ _.each(txtObj,(t,k) => {
+ switch (k.dbName()) {
+ case 'result': result = resultTest(t); break;
+ case 'critroll': crit = state.attackMaster.weapRules.criticals && resultTest(t); break;
+ case 'fumbleroll': fumble = state.attackMaster.weapRules.criticals && resultTest(t); break;
+ default: break;
+ }
+ });
+
+ result = !fumble && (crit || result);
+// log('RPGMdefault: result = '+result+', crit = '+crit+', fumble = '+fumble);
+ _.each(txtObj,(t,k) => {
+ t = t.replace(/\/img,tableStyle);
+ t = t.replace(/\<\/table\>/img,endTableStyle);
+ txtObj[k] = t;
+ if (!t || !t.length) return;
+ let key = k.toLowerCase().replace(/\s/g,'');
+ if (key === 'lookslike') {
+ if (!isGM) {
+ content += (row2col[(j++)%2]+row2+ t +endRow2+endRow2col);
+ } else {
+// content += (row1col[(j++)%2]+rowHeader+ k +endRowHeader+rowBodyC+ t +endRowBodyC+endRow1col);
+ content += (row2col[(j++)%2]+row2+ '**'+k+'**: '+t +endRow2+endRow2col);
+ }
+ } else if (isLooksLike) {
+ return;
+ } else if (key === 'result' || key === 'crit_roll' || key === 'fumble_roll') {
+ switch (key) {
+ case 'result':
+ if (result) {txtObj.success = (txtObj.Success || txtObj.success).replace(/value1/ig,value1).replace(/value2/ig,value2).replace(/\[\[\d*?\[(.+?)\]\s?\]\]/g,rollVal);}
+ else {txtObj.failure = (txtObj.Failure || txtObj.failure).replace(/value1/ig,value1).replace(/value2/ig,value2).replace(/\[\[\d*?\[(.+?)\]\s?\]\]/g,rollVal);}
+ let resultTxt = (result ? !!txtObj.success.length : !!txtObj.failure.length) ? ' ' : '';
+ content += rowResult[result ? 0 : 1]+row1C+''+ (result ? 'Success' : 'Failure') +''
+ + resultTxt+(result ? txtObj.success : txtObj.failure)+(resultTxt.length?'':'')+endRow1C+endRowResult;
+ if (txtObj.successcmd && txtObj.successcmd.length && result && !crit) {
+ LibFunctions.sendAPI(LibFunctions.parseStr(txtObj.successcmd));
+ } else if (txtObj.failcmd && txtObj.failcmd.length && !result && !fumble) {
+ LibFunctions.sendAPI(LibFunctions.parseStr(txtObj.failcmd));
+ };
+ break;
+ case 'crit_roll':
+ content += (!crit ? '' : (rowResult[0]+row1C+ (txtObj.crit || 'Critical Success!') +endRow1C+endRowResult));
+ if (txtObj.critcmd && txtObj.critcmd.length && crit) LibFunctions.sendAPI(LibFunctions.parseStr(txtObj.critcmd));
+ break;
+ case 'fumble_roll':
+ content += (!fumble ? '' : (rowResult[1]+row1C+ (txtObj.fumble || 'Critical Failure!') +endRow1C+endRowResult));
+ if (txtObj.fumblecmd && txtObj.fumblecmd.length && fumble) LibFunctions.sendAPI(LibFunctions.parseStr(txtObj.fumblecmd));
+ break;
+ }
+ } else if (key === 'use') {
+ content += (row2col[(j++)%2]+row2+ '**'+k+'**: '+t +endRow2+endRow2col);
+ } else if (key.startsWith('hide')) {
+ return;
+ } else if (key.startsWith('section')) {
+ content += row1col[(j++)%2]+row1C+ t +endRow1C+endRow1col;
+ } else if (key === 'gminfo') {
+// if (isGM) content += (row1col[(j++)%2]+rowHeader+ k +endRowHeader+rowBodyC+ t +endRowBodyC+endRow1col);
+ if (isGM) content += (row2col[(j++)%2]+row2+ '**'+k+'**: '+t +endRow2+endRow2col);
+ } else if (!['prefix','name','title','subtitle','successcmd','failcmd','success','failure','crit','fumble','critcmd','fumblecmd','gmdesc'].includes(key) && !key.startsWith('desc')) {
+ content += row1col[(j++)%2]+rowHeader+ k +endRowHeader+rowBodyC+ t +endRowBodyC+endRow1col;
+ }
+ });
+ content += (isLooksLike ? '' : addDescs(txtObj,j,showMoreButton))
+ + endBodyFrame + endOuterFrame;
+ return content;
+ }
+
+ let content;
+ switch (template.toLowerCase()) {
+ case 'rpgmattack':
+ content = RPGMattack( txt );
+ break;
+ case 'rpgmspell':
+ case 'rpgmpotion':
+ case 'rpgmitemspell':
+ case 'rpgmwandspell':
+ case 'rpgmscroll':
+ content = RPGMspell( txt, preamble );
+ break;
+ case 'rpgmmessage':
+ content = RPGMmessage( txt );
+ break;
+ case 'rpgmwarning':
+ case 'rpgmmenu':
+ content = RPGMdefault( txt, preamble, false );
+ break;
+ case 'rpgmweapon':
+ case 'rpgmammo':
+ case 'rpgmarmour':
+ case 'rpgmitem':
+ case 'rpgmring':
+ case 'rpgmwand':
+ case 'rpgmclass':
+ case 'rpgmdefault':
+ content = RPGMdefault( txt, preamble, true );
+ break;
+ default:
+ content = (template ? '&{template:'+template+'}' : '' ) + txt;
+ break;
+ }
+ while (/
/.test(content)) {content = content.replace(/
/mg,' ')};
+ content = (content[0] === '!' ? '' : preamble) + content;
+ setTimeout(() => sendChat(as?as:defaultAs,content,null,{noarchive:!archive, use3d:use3Ddice}), 0);
+ return content;
+ }
+
+ /*
+ * Determine who to send a Response to: use who controls
+ * the character - if no one or if none of the controlling
+ * players are on-line send the response to the GM
+ */
+
+ LibFunctions.sendToWho = function(charCS,senderId,makePublic=false,embedded=false) {
+
+ var to, controlledBy, players, viewerID, isPlayer=LibFunctions.checkPlayersLive( charCS );
+ controlledBy = (!charCS ? '' : charCS.get('controlledby'));
+ if (controlledBy.includes('all')) {
+ to = '';
+ } else if (playerIsGM(senderId) || !charCS || !isPlayer) {
+ to = embedded ? '/w gm ' : '/w gm ';
+ } else if (makePublic) {
+ to = '';
+ } else {
+ to = (embedded ? ('/w "'+charCS.get('name')+'" ') : ('/w "' + charCS.get('name') + '" '));
+ }
+ return to;
+ }
+
+ /*
+ * A more reliable form of function to determine who
+ * to send a Response to: use who controls
+ * the character - if no one or if none of the controlling
+ * players are on-line send the response to the GM
+ */
+
+ LibFunctions.sendMsgToWho = function(charCS,senderId,msg,div='',makePublic=false,embedded=false) {
+
+ var to, controlledBy, players, viewerID, isPlayer=false;
+// match = !embedded ? /(?<=^|}}\s*)^(?!\!|\/)/mg : /(? 0) {
+ controlledBy = controlledBy.split(',');
+ viewerID = (state.roundMaster && state.roundMaster.viewer && state.roundMaster.viewer.is_set) ? (state.roundMaster.viewer.pid || null) : null;
+ players = controlledBy.filter(id => id != viewerID);
+ if (players.length) {
+ isPlayer = _.some( controlledBy, function(playerID) {
+ players = findObjs({_type: 'player', _id: playerID, _online: true});
+ return (players && players.length > 0);
+ });
+ };
+ };
+ if (controlledBy.includes('all')) {
+ to = '';
+ } else if (playerIsGM(senderId) || !charCS || controlledBy.length == 0 || !isPlayer) {
+ to = embedded ? '/w gm ' : '/w gm ';
+ } else if (makePublic) {
+ to = '';
+ } else {
+ to = (embedded ? ('/w "'+charCS.get('name')+'" ') : ('/w "' + charCS.get('name') + '" '));
+ }
+ if (!embedded) msg = msg.replace(/^&{template:/img,(to+div+'$&'))
+ .replace(/^(?!\!|\/)/,('$&'+to+div))
+ .replace(/^\!.*^(?!\!|\/)/mg,('$&'+to+div))
+ .replace(/^\/(?:w|em|ooc|talktomyself|fx|desc|as|emas)\s.*?^(?!\!|\/)/img,('$&'+to+div));
+
+ return embedded ? to : msg;
+ }
+
+ /**
+ * Insert a whisper into a body with a template.
+ * If no template, inserts the whisper at the start of
+ * the first line not starting with an API call.
+ **/
+
+ LibFunctions.insertWhisper = function(to, msg='') {
+ let splitMsg = msg.match(/([^]*?)^.*?((?:&|\\amp|\\amp;){template:.*)/msi);
+ if (!splitMsg || !splitMsg.length > 2) return to+' '+msg;
+ return splitMsg[1]+'\n'+to+' '+splitMsg[2];
+ }
+
+ /**
+ * Send public message with 3d dice rolls (if enabled)
+ */
+
+ LibFunctions.sendPublic = function(msg,charCS,senderId) {
+ if (!msg)
+ {return undefined;}
+ var who;
+
+ if (charCS) {
+ who = 'character|'+charCS.id;
+ } else {
+ who = '';
+ }
+ clearWaitTimer();
+ setTimeout(() => sendChat(who,msg,null,{use3d:use3Ddice}), 0);
+ };
+
+ /**
+ * Send API command to chat
+ */
+ LibFunctions.sendAPI = function(msg, senderId, from='', noSplit=false) {
+ var as;
+ if (!msg) {
+ log('sendMagicAPI: no msg');
+ return undefined;
+ }
+ if (!senderId || senderId.length == 0) {
+ as = '';
+ } else {
+ as = 'player|' + senderId;
+ }
+ let msgArray = noSplit ? [msg] : msg.split(/(?:
|\n)/);
+ _.each(msgArray, m => sendChat(as,m, null,{noarchive:!archive, use3d:use3Ddice}));
+ };
+
+ /**
+ * Send locally parsed feedback to the GM only!
+ */
+ LibFunctions.sendFeedback = function(msg,as,img) {
+ if (!msg)
+ {return;}
+ var gm = findTheGM(),
+ div = ''
+ + ' '
+ + ' ';
+ clearWaitTimer(gm);
+ setTimeout(() => sendChat(('player|'+gm),LibFunctions.sendMsgToWho(null,null,msg,div),null,{noarchive:!archive,use3d:false}), 100); //,use3d:false
+ };
+
+ /**
+ * Sends a response to everyone who controls the character
+ * RED: v0.003 Check the player(s) controlling the character are valid for this campaign
+ * if they are not, send to the GM instead - Transmogrifier can introduce invalid IDs
+ * Also check if the controlling player(s) are online. If they are not
+ * assume the GM is doing some testing and send the message to them.
+ */
+
+ LibFunctions.sendResponse = function(charCS,msg,senderId,as,img) {
+ if (!msg)
+ {return;}
+ if (!charCS || (senderId && playerIsGM(senderId))) {
+ LibFunctions.sendFeedback( msg, as, img );
+ } else {
+ var div = ''
+ + ' '
+ + ' ';
+ clearWaitTimer(senderId);
+ setTimeout(() => sendChat((senderId ? 'player|'+senderId : charCS.get('name')),LibFunctions.sendMsgToWho(charCS,senderId,msg,div),null,{noarchive:!archive, use3d:use3Ddice}), 100);
+ }
+ };
+
+ /*
+ * Send a message to the player (rather than the character)
+ */
+
+ LibFunctions.sendResponseError = function(pid,msg,as,img) {
+ msg = '&{template:'+fields.warningTemplate+'}{{title=Warning!}}{{desc='+msg+'}}';
+ LibFunctions.sendResponsePlayer(pid,msg,as,img);
+ return;
+ }
+
+ /*
+ * Send an error message to the identified player.
+ * If that player is not online, send to the GM
+ */
+
+ LibFunctions.sendResponsePlayer = function(pid,msg,as,img) {
+ if (!pid || !msg)
+ {return null;}
+ var player = getObj('player',pid),
+ to;
+ if (player && player.get('_online')) {
+ to = '/w "' + player.get('_displayname') + '" ';
+ } else {
+ to = '/w gm ';
+ }
+ var content = to
+ + ''
+ + ' '
+ + ' '+msg;
+ clearWaitTimer(pid);
+ setTimeout(() => sendChat((as?as:defaultAs),content,null,{noarchive:false, use3d:use3Ddice}), 100);
+ };
+
+ /*
+ * Send to all players other than those that control the specified character
+ * and/or other than the specified player
+ */
+
+ LibFunctions.sendToOthers = function(pid,msg,as,img,charCS) {
+ if (!msg || (!pid && !charCS))
+ {return null;}
+ let controllers = charCS ? charCS.get('controlledby').split(',') : [];
+ let players = filterObjs(obj => {
+ if (obj.get('_type') != 'player' || obj.id == pid) return false;
+ if (controllers.includes(obj.id)) return false;
+ return obj.get('_online');
+ });
+ _.each(players, p => LibFunctions.sendResponsePlayer(p,msg,as,img));
+ };
+
+ /**
+ * Send a simple error
+ */
+
+ LibFunctions.sendError = function(msg, cmd) {
+ var postErrorMsg = function( msg, cmd ) {
+ var content = '/w GM '
+ + ''
+ + ' '
+ + ' '
+ + errorMsgDiv + 'Error: ' + msg
+ + (cmd ? (' while processing command
' + cmd.content + ' ') : '')
+ + '';
+
+ sendChat(((cmd && cmd.who) ? cmd.who : defaultAs),content,null,{noarchive:false, use3d:false});
+ log('RPGMaster error: '+msg+ (cmd ? (' while processing command '+cmd.content) : ''));
+ };
+ setTimeout(postErrorMsg,500,msg,cmd);
+ };
+
+ /**
+ * Send an error caught by try/catch
+ */
+
+ LibFunctions.sendCatchError = function(apiName,msg,e,cmdStr='') {
+ var postCatchMsg = function(apiName,msg,e,cmdStr) {
+ if (!msg || !msg.content) {msg= {};msg.content = ''};
+ if (!cmdStr) cmdStr = msg.content;
+ log(apiName + ' error: ' + e.name + ', ' + e.message + ' when processing command ' + cmdStr);
+ let who=(getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname');
+ sendChat(apiName,`/w gm `+
+ ``+
+ ` There was an error while trying to run ${who}'s command: `+
+ ` ${cmdStr}
`+
+ ` Please send me this information so I can make sure this doesn't happen again (triple click for easy select in most browsers.): `+
+ ` `+
+ JSON.stringify({msg:msg, version:version, stack: e.stack, API_Meta})+
+ ` `+
+ ` `
+ )
+ };
+ setTimeout(postCatchMsg,500,apiName,msg,e,cmdStr);
+ };
+
+ /**
+ * Pare a message with ^^...^^ parameters in it and send to chat
+ * This allows character and token names for selected characters to be sent
+ * Must be called with a validated tokenID
+ */
+
+ LibFunctions.sendParsedMsg = function( tid, msg, senderId, msgFrom, t2id ) {
+ var cid, tname, charCS, cname, curToken,
+ parsedMsg = msg;
+
+ curToken = getObj( 'graphic', tid );
+ tname = (curToken ? curToken.get('name') : '');
+ cid = (curToken ? curToken.get('represents') : '');
+ charCS = getObj('character',cid);
+ cname = (charCS ? charCS.get('name') : '');
+
+ parsedMsg = parsedMsg.replace( /\^\^cid\^\^/gi , cid );
+ parsedMsg = parsedMsg.replace( /\^\^tid\^\^/gi , tid );
+ parsedMsg = parsedMsg.replace( /\^\^cname\^\^/gi , cname );
+ parsedMsg = parsedMsg.replace( /\^\^tname\^\^/gi , tname );
+
+ if (t2id) {
+ curToken = getObj( 'graphic', t2id );
+ tname = curToken.get('name');
+ cid = curToken.get('represents');
+ charCS = getObj('character',cid);
+ cname = charCS.get('name');
+
+ parsedMsg = parsedMsg.replace( /\^\^c2id\^\^/gi , cid );
+ parsedMsg = parsedMsg.replace( /\^\^t2id\^\^/gi , t2id );
+ parsedMsg = parsedMsg.replace( /\^\^c2name\^\^/gi , cname );
+ parsedMsg = parsedMsg.replace( /\^\^t2name\^\^/gi , tname );
+ }
+ LibFunctions.sendResponse( charCS, parsedMsg, senderId, msgFrom, null );
+ };
+
+ /*
+ * Check to see if a command string includes a gm roll query. If so,
+ * convert it to a normal roll query and send it to the GM to answer.
+ * Return true if a gm query has been found.
+ */
+
+ LibFunctions.sendGMquery = function( api, command, senderId ) {
+ var rollQuery;
+ if (command.toLowerCase().includes('gm{')) {
+ while ((rollQuery = command.match(/gm{.+?}/i))) {
+ if (!rollQuery || !rollQuery.length) break;
+ rollQuery = rollQuery[0].replace(/gm{/i,'?{').replace(/\//g,'|');
+ rollQuery = LibFunctions.parseStr(rollQuery);
+ command = command.replace(/gm{.+?}/i,rollQuery);
+ };
+ LibFunctions.sendFeedback( '&{template:'+fields.warningTemplate+'}{{title=DM Selection}}{{desc=As DM, you need to make [selections](!'+api+' '+senderId+' --'+command+') for '+getObj('player',senderId).get('_displayname')+'. Press the button and the selections and their reasons will be presented to you in Roll Querys in the centre of the screen.}}');
+ LibFunctions.sendResponsePlayer( senderId, '&{template:'+fields.messageTemplate+'}{{title=DM Selection}}{{desc=Please wait while the DM makes a choice or dice roll.}}' );
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ /*
+ * Send a formatted "please wait" message to the specified player.
+ */
+
+ LibFunctions.sendWait = function(senderId,timer=500,source='') {
+ if (timer === 0) {
+ clearWaitTimer(senderId);
+ return;
+ } else if (waitList[senderId]) {
+ clearWaitTimer(senderId);
+ }
+ if (playerIsGM(senderId)) {
+ waitList[senderId] = setTimeout(() => {sendChat(defaultAs,('/w GM ' + waitMsgDiv + 'Gathering data - please wait'),null,{noarchive:!archive});
+ clearWaitTimer(senderId);
+ }, timer);
+ } else {
+ var player = getObj('player',senderId),
+ to = '/w "' + (!player ? 'GM' : player.get('_displayname')) + '" ';
+ waitList[senderId] = setTimeout(() => {sendChat('player|'+senderId,(to + waitMsgDiv + 'Gathering data - please wait'),null,{noarchive:!archive, use3d:false});
+ clearWaitTimer(senderId);
+ }, timer);
+ }
+ };
+
+
+ /* ------------------------------- Character Sheet Database Management -------------------------- */
+
+ /*
+ * Check the version of a Character Sheet database against
+ * the current version in the API. Return true if needs updating
+ */
+
+ LibFunctions.checkDBver = function( dbFullName, dbObj, silent ) {
+
+ dbFullName = dbFullName.replace(/_/g,'-');
+
+ var dbCS = findObjs({ type:'character', name:dbFullName },{caseInsensitive:true}),
+ dbVersion = 0.0,
+ msg, versionObj;
+
+ if (!dbCS || !dbCS.length) return true;
+
+ dbCS = dbCS[0];
+ dbVersion = parseFloat(LibFunctions.attrLookup( dbCS, fields.dbVersion ) || dbVersion);
+
+ if (dbVersion < (parseFloat(dbObj.version) || 0)) {log('checkDBver: dB '+dbFullName+' API version='+(parseFloat(dbObj.version) || 0)+', CS version='+dbVersion); return true;}
+
+ msg = dbFullName+' v'+dbVersion+' not updated as is already latest version';
+ if (!silent) LibFunctions.sendFeedback(msg,fields.feedbackName);
+ return false;
+ }
+
+ /*
+ * A function to read the abilities of a database character sheet
+ * and write them to a handout, so they can be cut&pasted to an API
+ * for saving as a new version.
+ */
+
+ LibFunctions.saveDBtoHandout = function( dbName, version, typeFilter='' ) {
+
+ var dbCS = findObjs({ type: 'character', name: dbName })[0] || undefined,
+ objDef,
+ objHeader = '',
+ foundItems = [],
+ dbHandout,csDBlist,
+ reDBdata = {speed:reSpellSpecs.speed,cost:reSpellSpecs.cost,recharge:reSpellSpecs.recharge};
+
+ var encodeStr = (str,encoders=dbEncoders) => encoders.reduce((m, rep) => m.replace(rep[0], rep[1]), str);
+
+ if (!dbCS) {
+ LibFunctions.sendError(('Database '+dbName+' not found'),null);
+ return undefined;
+ }
+ if (!version || !version.length) {
+ version = (parseFloat(LibFunctions.attrLookup( dbCS, fields.dbVersion ) || '1.0') + 0.01).toFixed(2).toString();
+ } else if (version === '=') {
+ version = parseFloat(LibFunctions.attrLookup( dbCS, fields.dbVersion ) || '1.0');
+ }
+ dbHandout = findObjs({ type: 'handout', name: dbName+'-object v'+version });
+
+ if (!dbHandout || !dbHandout.length) {
+ dbHandout = createObj('handout',{name:(dbName+'-object v'+version)});
+ } else {
+ dbHandout = dbHandout[0];
+ }
+
+ objHeader = 'avatar:\''+dbCS.get('avatar')+'\', '
+ + 'version:'+version+', ';
+ objDef = 'db:[';
+ csDBlist = findObjs({ type: 'ability', characterid: dbCS.id });
+
+ _.each( _.sortBy(csDBlist,item => item.get('name')), function( item ) {
+ let itemName = item.get('name');
+ if (foundItems.includes(itemName)) return;
+ foundItems.push(itemName);
+
+ let objData = LibFunctions.resolveData(itemName,dbName,reNotAttackData,null,reDBdata).parsed,
+ objBody = encodeStr(item.get('action')),
+ objCT = objData.speed || 0,
+ objChg = objData.type || 'uncharged',
+ objCost = objData.cost || 0,
+ objType = '',
+ specs = objBody.match(/}}\s*?specs\s*?=(.*?){{/im);
+
+ specs = specs ? [...('['+specs[0]+']').matchAll(reSpecClass)] : [];
+ for (let i=0; i < specs.length; i++) {
+ objType += (objType && objType.length) ? ('|' + specs[i][1]) : specs[i][1];
+ }
+ objType = _.uniq(objType.toLowerCase().split('|')).join('|');
+ if (typeFilter && typeFilter.length && !objType.includes(typeFilter)) return;
+
+ objBody = objBody.replace(/template:2Edefault/i,'template:\'+fields.CSdefaultTemplate+\'')
+ .replace(/template:2Espell/i,'template:\'+fields.CSspellTemplate+\'')
+ .replace(/template:2Eattack/i,'template:\'+fields.CSweaponTemplate+\'')
+ .replace(/template:RPGMdefault/i,'template:\'+fields.defaultTemplate+\'')
+ .replace(/template:RPGMspell/i,'template:\'+fields.spellTemplate+\'')
+ .replace(/template:RPGMweapon/i,'template:\'+fields.weaponTemplate+\'')
+ .replace(/template:RPGMpotion/i,'template:\'+fields.potionTemplate+\'')
+ .replace(/template:RPGMattack/i,'template:\'+fields.targetTemplate+\'')
+ .replace(/template:RPGMammo/i,'template:\'+fields.ammoTemplate+\'')
+ .replace(/template:RPGMarmour/i,'template:\'+fields.armourTemplate+\'')
+ .replace(/template:RPGMitem/i,'template:\'+fields.itemTemplate+\'')
+ .replace(/template:RPGMitemSpell/i,'template:\'+fields.itemSpellTemplate+\'')
+ .replace(/template:RPGMring/i,'template:\'+fields.ringTemplate+\'')
+ .replace(/template:RPGMscroll/i,'template:\'+fields.scrollTemplate+\'')
+ .replace(/template:RPGMwand/i,'template:\'+fields.wandTemplate+\'')
+ .replace(/template:RPGMwandSpell/i,'template:\'+fields.wandSpellTemplate+\'')
+ .replace(/template:RPGMmessage/i,'template:\'+fields.messageTemplate+\'')
+ .replace(/template:RPGMwarning/i,'template:\'+fields.warningTemplate+\'')
+ .replace(/template:RPGMclass/i,'template:\'+fields.classTemplate+\'');
+
+ objDef += '{name:\''+itemName+'\','
+ + 'type:\''+objType+'\','
+ + 'ct:\''+objCT+'\','
+ + 'charge:\''+objChg+'\','
+ + 'cost:\''+objCost+'\','
+ + 'body:\''+objBody+'\'}, ';
+ });
+ objDef += ']}, ';
+ dbHandout.set('notes',objHeader+objDef);
+ LibFunctions.sendFeedback('Extracted '+dbName+' v'+version,fields.feedbackName);
+ LibFunctions.setAttr( dbCS, fields.dbVersion, version );
+ return dbHandout;
+ }
+
+ /*
+ * Check the version of a Character Sheet database and, if
+ * it is earlier than the static data held in this API, update
+ * it to the latest version.
+ */
+
+ LibFunctions.buildCSdb = function( dbFullName, dbObj, typeList, silent ) {
+
+ dbFullName = dbFullName.replace(/_/g,'-');
+
+ const spells = dbObj.type.includes('spell') || dbObj.type.includes('power'),
+ charClass = dbObj.type.includes('class'),
+ rootDB = dbObj.root.toLowerCase();
+
+ var dbVersion = 0.0,
+ dbCS = findObjs({ type:'character', name:dbFullName },{caseInsensitive:true}),
+ errFlag = false,
+ lists = {},
+ foundItems = [],
+ csDBlist, specs, objType, objBody,
+ msg, versionObj, curDB;
+
+ if (LibFunctions.checkDBver( dbFullName, dbObj, silent )) {
+
+ if (dbCS && dbCS.length) {
+ let abilities = findObjs({ _type:'ability', _characterid:dbCS[0].id });
+ _.each( abilities, a => a.remove() );
+ dbCS = dbCS[0];
+ } else {
+ dbCS = createObj( 'character', {name:dbFullName} );
+ }
+
+ let sorted = _.sortBy(dbObj.db,'name');
+
+ _.each(sorted, item => {
+ if (!foundItems.includes(item.name)) {
+ foundItems.push(item.name);
+ item.body = LibFunctions.parseStr(item.body,dbReplacers);
+ if (!LibFunctions.setAbility( dbCS, item.name, item.body )) {
+ errFlag = true;
+ } else {
+ LibFunctions.setAttr( dbCS, [fields.CastingTimePrefix[0]+item.name, 'current'], item.ct );
+ LibFunctions.setAttr( dbCS, [fields.CastingTimePrefix[0]+item.name, 'max'], (spells ? item.cost : item.charge) );
+ LibFunctions.addMIspells( dbCS, item );
+ item.type.dbName().split('|').filter(t => !!t).map(t => {
+ let listType = typeList[t] ? typeList[t].type.toLowerCase() : (typeList.miscellaneous ? typeList.miscellaneous.type.toLowerCase() : undefined);
+ if (listType) {
+ if (!lists[listType]) lists[listType] = [];
+ if (!lists[listType].includes(item.name)) {
+ lists[listType].push(item.name);
+ }
+ } else if (_.isUndefined(listType)) {
+ LibFunctions.sendError(('Unable to identify item type '+t+' when updating '+item.name+' in database '+dbFullName));
+ };
+ });
+ };
+ };
+ });
+ if (errFlag) {
+ LibFunctions.sendError( ('Unable to completely update database '+dbFullName) );
+ } else {
+ _.each(typeList, dbList => dbList.field[0].length ? LibFunctions.setAttr( dbCS, [dbList.field[0],'current'], (lists[dbList.type.toLowerCase()] || ['']).join('|')) : '');
+ LibFunctions.setAttr( dbCS, fields.dbVersion, (dbObj.version || 1.0));
+ dbCS.set('avatar',(dbObj.avatar || ''));
+ dbCS.set('bio',(dbObj.bio || ''));
+ dbCS.set('controlledby',(dbObj.controlledby || 'All'));
+ dbCS.set('gmnotes',(dbObj.gmnotes || ''));
+ let msg = 'Updated database '+dbFullName+' to version '+String(dbObj.version);
+ if (!silent) LibFunctions.sendFeedback( msg, fields.feedbackName ); else log(msg);
+ }
+ }
+ return (errFlag);
+ }
+
+ /**
+ * Create an internal index of items in the databases
+ * to make searches much faster. Index entries indexed by
+ * database root name & short name (name in lower case with
+ * '-', '_' and ' ' ignored). index[0] = abilityID,
+ * index[1] = ct-attributeID
+ * v3.051 Check that other database-handling APIs have finished
+ * updating their databases and performed a handshake
+ **/
+
+ LibFunctions.updateDBindex = function() {
+ var rootDB, magicDB, validDB,
+ db, shortName, attrName, objList,
+ rootList = ['mu_spells_db','pr_spells_db','powers_db','mi_db','race_db','class_db','attacks_db','styles_db','locks_traps_db'],
+ index = {};
+
+ _.each( dbNames, (dbFields, db) => {
+ if (state.MagicMaster.spellRules.denyCustom && db.toLowerCase().includes('custom')) return;
+ _.each( dbFields.db, (item, i) => {
+ rootDB = db.toLowerCase().match( /[a-z_]+?_db/i );
+ if (!item || !item.name) log('updateDBindex: item='+item.name+', i='+i+', unable to create shortName');
+ shortName = item.name.dbName();
+ if (_.isUndefined(index[rootDB])) index[rootDB] = {};
+ if (_.isUndefined(index[rootDB][shortName])) index[rootDB][shortName] = ['',String(item.ct),db,i];
+ });
+ });
+
+ objList = filterObjs( function(obj) {
+ if (obj.get('type') != 'ability') return false;
+ if (!(magicDB = getObj('character',obj.get('characterid')))) {
+ return false;
+ }
+ db = magicDB.get('name').toLowerCase().replace(/-/g,'_');
+ if (/\s*v\d*\.\d*/.test(db)) {
+ return false;
+ }
+ let validDB = false;
+ for (const rDB of rootList) {
+ if (db.startsWith(rDB)) {
+ validDB = true;
+ rootDB = rDB;
+ break;
+ }
+ }
+ if (!validDB) {return false;}
+ let shortName = obj.get('name').dbName();
+
+ if (_.isUndefined(index[rootDB])) {index[rootDB] = {};}
+ if (_.isUndefined(index[rootDB][shortName]) || !index[rootDB][shortName][0].length || !stdDB.includes(db)) {
+ index[rootDB][shortName] = [obj.id,''];
+ }
+ return true;
+ });
+ objList = filterObjs( function(obj) {
+ if (obj.get('type') != 'attribute') {return false;}
+ attrName = obj.get('name');
+ if (!attrName || !attrName.toLowerCase().startsWith('ct-')) {return false;}
+ if (!(magicDB = getObj('character',obj.get('characterid')))) {
+ return false;
+ }
+ db = magicDB.get('name').toLowerCase().replace(/-/g,'_');
+ if (/\s*v\d*\.\d*/.test(db)) {return false;}
+ let validDB = false;
+ for (const rDB of rootList) {
+ if (db.startsWith(rDB)) {
+ validDB = true;
+ rootDB = rDB;
+ }
+ }
+ if (!validDB)
+ {return false;}
+ let shortName = attrName.dbName().substring(2);
+
+ if (!!!index[rootDB][shortName]) {
+ return false;
+ }
+ if (!stdDB.includes(db) || (!!!index[rootDB][shortName][1]) || (index[rootDB][shortName][1].length === 0)) {
+ index[rootDB][shortName][1] = obj.id;
+ };
+ return true;
+ });
+ magicList = {}; // Blank the internal index of items, as it might have changed and needs rebuilding
+// LibFunctions.sendFeedback( waitMsgDiv+'RPGMaster is now ready.' );
+
+ return index;
+ }
+
+ /*
+ * Check a character sheet database and update/create the
+ * required attributes from the definitions. This should
+ * be run after updating or adding item or spell definitions.
+ */
+
+ LibFunctions.checkCSdb = function( dbFullName ) {
+
+ var db = dbFullName.toLowerCase(),
+ lists = {},
+ spellsDB,
+ dbCSlist,
+ dbTypeList;
+
+ var checkObj = function( obj ) {
+ var objCS, objCSname, objName, objBody, type, objCT, objChg, objCost, specs, spellsDB, classDB;
+
+ if (!obj || obj.get('type') !== 'ability') return false;
+ objCS = getObj('character',obj.get('characterid'));
+ if (!objCS) {log('checkObj: not found database object');return false;}
+ objCSname = objCS.get('name').toLowerCase();
+ if (db && db.length && (db !== '-db' && !objCSname.startsWith(db))) return false;
+ if (!objCSname.includes('-db') || (/\s*v\d*\.\d*/.test(objCSname))) return false;
+ objBody = obj.get('action');
+ spellsDB = objCSname.includes('spells') || objCSname.includes('powers');
+ classDB = objCSname.includes('class') || objCSname.includes('race');
+ specs = objBody.match(reSpecs);
+ objName = obj.get('name');
+ if (specs) {
+ dbTypeList = (spellsDB ? spTypeLists : (classDB ? clTypeLists : miTypeLists));
+ specs = specs ? [...('['+specs[0]+']').matchAll(reSpecClass)] : [];
+ for (const i of specs) {
+ type = i[1];
+ if (type && type.length) {
+ let typeList = type.dbName().split('|');
+ for (const t of typeList) {
+ let itemType = dbTypeList[t] ? dbTypeList[t].type : (dbTypeList.miscellaneous ? dbTypeList.miscellaneous.type : undefined);
+ if (itemType) {
+ if (!lists[objCS.id]) lists[objCS.id] = {};
+ if (!lists[objCS.id][itemType]) lists[objCS.id][itemType] = [];
+ if (!lists[objCS.id][itemType].includes(objName)) {
+ lists[objCS.id][itemType].push(objName);
+ };
+ };
+ };
+ };
+ };
+ };
+ objCT = (objBody.match(reDataSpeed) || ['',0])[1];
+ objChg = (objBody.match(reDataCharge) || ['','uncharged'])[1];
+ objCost = (objBody.match(reDataCost) || ['',0])[1];
+ LibFunctions.setAttr( objCS, [fields.CastingTimePrefix[0]+objName, 'current'], objCT );
+ LibFunctions.setAttr( objCS, [fields.CastingTimePrefix[0]+objName, 'max'], (spellsDB ? objCost : objChg) );
+ LibFunctions.addMIspells( objCS, {name:objName,body:objBody} );
+ return true;
+ };
+
+ dbCSlist = filterObjs( obj => checkObj(obj) );
+ if (!dbCSlist || !dbCSlist.length) {
+ LibFunctions.sendFeedback('No databases found with a name that includes '+db,fields.feedbackName);
+ } else {
+ _.each(lists,(types,dbID) => {
+ let dbCS = getObj('character',dbID);
+ _.each(dbTypeList, dbList => {
+ if (types[dbList.type]) {
+ LibFunctions.setAttr( dbCS, [dbList.field[0],'current'], (types[dbList.type].sort().join('|') || '' ));
+ }
+ });
+ });
+ LibFunctions.sendFeedback(((!db || !db.length || db === '-db') ? 'All databases have' : ('Database '+dbFullName+' has')) + ' been updated',fields.feedbackName);
+ }
+ return;
+ }
+
+ /**
+ * Get a new DB index of all Ability Objects stored in
+ * database character sheets
+ **/
+
+ LibFunctions.getDBindex = function(forceUpdate = false) {
+ if (_.isUndefined(DBindex) || forceUpdate) {
+ DBindex = LibFunctions.updateDBindex();
+ }
+ return DBindex;
+ }
+
+ /**
+ * Update or create the help handouts
+ **/
+
+ LibFunctions.updateHandouts = function(handouts,silent,senderId) {
+
+ let classHelp = findObjs({ type:'handout', name:'Class Database Help' });
+ if (classHelp && classHelp[0]) classHelp[0].remove();
+ _.each(handouts,(obj,k) => {
+ let dbCS = findObjs({ type:'handout', name:obj.name },{caseInsensitive:true});
+ if (!dbCS || !dbCS[0]) {
+ log(obj.name+' not found. Creating version '+obj.version);
+ if (!silent) LibFunctions.sendFeedback(obj.name+' not found. Creating version '+obj.version);
+ dbCS = createObj('handout',{name:obj.name,inplayerjournals:(_.isUndefined(senderId) ? '' : senderId)});
+ dbCS.set('notes',obj.bio);
+ dbCS.set('avatar',obj.avatar);
+ } else {
+ dbCS = dbCS[0];
+ dbCS.get('notes',function(note) {
+ let reVersion = new RegExp(obj.name+'\\s*?v(\\d+?.\\d*?)', 'im');
+ let version = note.match(reVersion);
+ version = (version && version.length) ? (parseFloat(version[1]) || 0) : 0;
+ if (version >= obj.version) {
+ if (!silent) LibFunctions.sendFeedback('Not updating handout '+obj.name+' as is already version '+obj.version);
+ return;
+ }
+ dbCS.set('notes',obj.bio);
+ dbCS.set('avatar',obj.avatar);
+ if (!silent) LibFunctions.sendFeedback(obj.name+' handout updated to version '+obj.version);
+ log(obj.name+' handout updated to version '+obj.version);
+ });
+ }
+ });
+ return;
+ }
+
+ /**
+ * Get the handout IDs for all handouts
+ **/
+
+ LibFunctions.getHandoutIDs = function() {
+
+ var handoutObjs = findObjs({ type: 'handout' }),
+ handoutIDs = {};
+ _.each( handoutObjs, h => {
+ let name = h.get('name').replace(/[-_&\s]/g,'');
+ handoutIDs[name] = h.id;
+ });
+ return handoutIDs;
+ };
+
+ /* -------------------------------- Utility Functions ---------------------------- */
+
+ /**
+ * Calculate/roll an attribute value that has a range
+ * Always tries to create a 3 dice bell curve for the value
+ **/
+
+ LibFunctions.calcAttr = function( attr='3:18' ) {
+ let attrRange = attr.split(':'),
+ low = parseInt(attrRange[0]),
+ high = parseInt(attrRange[1]);
+ if (high && !isNaN(low) && !isNaN(high)) {
+ let range = high - (low - 1);
+ if (range === 2) {
+ return low - 1 + randomInteger(2);
+ } else if (range === 3) {
+ return low - 2 + randomInteger(2) + randomInteger(2);
+ } else if (range === 5) {
+ return low - 2 + randomInteger(3) + randomInteger(3);
+ } else if ((range-2)%3 === 0) {
+ return low - 3 + randomInteger(Math.ceil(range/3)+1) + randomInteger(Math.floor(range/3)+1) + randomInteger(Math.floor(range/3)+1);
+ } else if ((range-1)%3 === 0) {
+ return low - 3 + randomInteger(Math.ceil(range/3)) + randomInteger(Math.ceil(range/3)) + randomInteger(Math.ceil(range/3));
+ } else if ((range)%3 === 0) {
+ return low - 3 + randomInteger((range/3)+1) + randomInteger((range/3)+1) + randomInteger(range/3);
+ }
+ }
+ return attr;
+ }
+
+ /**
+ * A function to calculate an internal dice roll
+ */
+
+ LibFunctions.rollDice = function( count, dice, reroll ) {
+ count = parseInt(count || 1);
+ dice = parseInt(dice || 8);
+ reroll = parseInt(reroll || 0);
+ let total = 0,
+ roll;
+ for (let d=0; d id !== '' && id !== GMid);
+ if (!playerIds || !playerIds.length) return GMid;
+ if (playerIds[0] !== 'all') {
+ let pid = playerIds.find( p => (getObj('player',p) && !!getObj('player',p).get('_online')));
+ if (pid) return pid;
+ }
+ playerObjs = filterObjs(p => (p.get('_type') === 'player' && !!p.get('_online') && p.id !== GMid));
+ }
+ }
+ return (!playerObjs || !playerObjs.length) ? GMid : playerObjs[0].id;
+ };
+
+ /**
+ * Find a Character object given a name only,
+ * returning the first match or undefined
+ */
+
+ LibFunctions.findCharacter = function( name ) {
+ var charObj = findObjs({ _type: 'character' , name: name },{caseInsensitive: true});
+ return ((charObj && charObj.length) ? charObj[0] : undefined);
+ }
+
+ /**
+ * Function to find the ID of a live player
+ * that controls the specified character
+ */
+
+ LibFunctions.checkPlayersLive = function( charCS ) {
+ let playerID, controlledBy = (!charCS ? '' : charCS.get('controlledby'));
+ if (controlledBy.length > 0) {
+ controlledBy = controlledBy.split(',');
+ let viewerID = (state.roundMaster && state.roundMaster.viewer && state.roundMaster.viewer.is_set) ? (state.roundMaster.viewer.pid || null) : null;
+ let players = controlledBy.filter(id => id != viewerID);
+ if (players.length) {
+ playerID = _.find( controlledBy, function(playerID) {
+ players = findObjs({_type: 'player', _id: playerID, _online: true});
+ return (players && players.length > 0);
+ });
+ };
+ };
+ return playerID;
+ };
+
+ /**
+ * A function to return the specified player ID, or
+ * the first live player who controls the character,
+ * or the first live player who controls the token
+ * representing a character, or senderId, or the GM.
+ */
+
+ LibFunctions.fixSenderId = function( args, selected, senderId ) {
+
+ let playerID = args[0] || (selected && selected.length ? selected[0]._id : senderId),
+ playerObj = getObj('player',playerID);
+ if (!playerObj) playerID = LibFunctions.checkPlayersLive( getObj('character',args[0]) );
+ if (!playerID) playerID = LibFunctions.checkPlayersLive( LibFunctions.getCharacter(args[0]) );
+
+ return playerID || senderId;
+ };
+
+ /*
+ * Parse a data string for attribute settings
+ */
+
+ LibFunctions.parseData = function( attributes, reSpecs, def=true, charCS, item='', row='' ) {
+
+ var parsedData = {},
+ val,
+ varRes = ( m, w, v = 'current' ) => LibFunctions.parseStr((LibFunctions.attrLookup( charCS, [fields.ItemVar[0]+item+'+'+row+'-'+w,'current'] ) || '').split('/')[v] || '');
+
+ if (charCS) while (reVars.test(attributes)) attributes = attributes.replace(reVars,varRes);
+ _.each( reSpecs, spec => {
+ if (_.isUndefined(spec) || _.isUndefined(spec.re)) return;
+ val = attributes.match(spec.re);
+ if (!!val && val.length>1 && val[1].length) {
+ parsedData[spec.field] = (val.length == 3 && val[2]) ? [val[1],val[2]] : val[1];
+ } else if (!def) {
+ parsedData[spec.field] = undefined;
+ } else {
+ parsedData[spec.field] = spec.def;
+ }
+ });
+ return parsedData;
+ }
+
+ /*
+ * Follow an inheritance chain of Class or Race database objects and
+ * consolidate their parsed data and attribute specifications
+ */
+
+ LibFunctions.resolveData = function( name, dBase, reThisData, charCS, reParseTable, row='', quals=[], defBase=true, doneList=[], topItem, debugging=false ) {
+
+ try {
+
+ if (_.isEmpty(reParseTable)) reParseTable = undefined;
+
+ var thisObj, thisSpecs, baseObj, thisData, thisAttr, parsedData,
+ rDB = dBase.toLowerCase().replace(/-/g,'_'),
+ isSpell = rDB.includes('spells_db'),
+ isMI = rDB.startsWith('mi_db'),
+ isRC = !isSpell && !isMI,
+ parseTable = reClassSpecs,
+ baseData = [['']],
+ baseParsed = LibFunctions.parseData( '', (reParseTable || (!isRC ? reSpellSpecs : reClassSpecs)), defBase ),
+ baseAttr = LibFunctions.parseData( '', reAttr, defBase ),
+ debugging = debugging || false, // name.dbName().includes('berserking'),
+ varRes = ( m, w, v = 0 ) => LibFunctions.parseStr((LibFunctions.attrLookup( charCS, [fields.ItemVar[0]+(topItem || name)+'+'+row+'-'+w,'current'] ) || '').split('/')[v] || '');
+
+ if (!name || !name.trim().length || doneList.includes(name.dbName())) throw new Error('resolveData: no name or already processed '+name);
+ thisObj = LibFunctions.abilityLookup( dBase, name, charCS, true );
+ if (!thisObj.obj) throw new Error('resolveData: no definition of '+name+' in '+dBase);
+ doneList.push(name.dbName());
+ thisSpecs = thisObj.specs();
+ if (!thisSpecs || !thisSpecs[0]) throw new Error('resolveData: no Specs in definition of '+name);
+ if (debugging) log('resolveData: weapon '+name+' thisSpecs = '+thisSpecs[0]+', item body = '+thisObj.obj[1].body);
+ baseObj = LibFunctions.resolveData( ((isMI ? thisSpecs[0][5] : thisSpecs[0][4]) || ''), dBase, reThisData, charCS, reParseTable, row, quals, defBase, doneList, (topItem || name), debugging );
+ baseParsed = baseObj.parsed; baseAttr = baseObj.attrs; baseData = baseObj.raw;
+ thisData = thisObj.data(reThisData);
+ if (!thisData || !thisData[0]) thisData = [['']];
+ _.each( quals, (q,k) => thisData[0][0] = thisData[0][0].replace(new RegExp('\\?\\?'+k,'g'),q));
+ if (debugging) log('resolveData: weapon '+name+' quals = '+quals+', after ??# replacement, thisData.length = '+thisData.length+', thisData = '+thisData);
+ thisData[0][0] = thisData[0][0].replace(/\?\?\d/g,'0');
+ if (isMI || isSpell) {
+ if (debugging && !miTypeLists[thisSpecs[0][2].dbName().split('|')[0]]) log('resolveData: unable to find '+thisSpecs[0][2].dbName().split('|')[0]);
+ switch ((miTypeLists[thisSpecs[0][2].dbName().split('|')[0]] || {type:''}).type) {
+ case 'weapon':
+ case 'ammo':
+ parseTable = reWeapSpecs;
+ break;
+ case 'armour':
+ case 'armor':
+ parseTable = reACSpecs;
+ break;
+ default:
+ parseTable = reSpellSpecs;
+ break;
+ };
+ };
+ while (reVars.test(thisData[0][0])) thisData[0][0] = thisData[0][0].replace(reVars,varRes);
+ parsedData = LibFunctions.parseData( thisData[0][0], (reParseTable || parseTable), false, charCS, name, row );
+ thisAttr = LibFunctions.parseData( (parsedData.cattr || '')+',', reAttr, false, charCS, name, row );
+ if (baseParsed) {
+ if (!parsedData.cattr) {
+ parsedData.cattr = baseParsed.cattr;
+ thisAttr = baseAttr;
+ } else if (baseAttr) {
+ thisAttr = _.mapObject(Object.assign(baseAttr,_.pick(thisAttr,a => !!a)), attr => attr !== '-' ? attr : '');
+ }
+ if (debugging) log('resolveData: baseParsed = '+_.pairs(baseParsed).flat()+', parsedData = '+_.pairs(parsedData).flat());
+ parsedData = _.mapObject(Object.assign(baseParsed,_.pick(parsedData,a => !!a)), attr => attr !== '-' ? attr : '');
+ let dataCount = reIsAttackData.test(thisData[0][0]) ? thisData.length : 1;
+ for (let i=0; i < dataCount; i++) {
+ while (reVars.test(thisData[i][0])) thisData[i][0] = thisData[i][0].replace(reVars,varRes);
+ if (!!baseData.length && i < thisSpecs.length) {
+ thisData[i][0] = '['+_.pairs(Object.assign(
+ _.object(baseData[0][0].replace(/^.*?=\[/,'').replace(/[\[\]]/g,'').split(',').map(v => {v = v.trim().split(':');v[0] = v[0].toLowerCase();return v})),
+ _.object(thisData[i][0].replace(/^.*?=\[/,'').replace(/[\[\]]/g,'').split(',').map(v => {v = v.trim().split(':');v[0] = v[0].toLowerCase();return v}))
+ )
+ ).map(v => v.join(':')).filter(v => v !== ':').join()+']';
+ if (baseData.length > 1) {baseData.shift();} // else {baseData = [['']]};
+ };
+ };
+ }
+ if (debugging)log('resolveData: merged data for '+name+' thisData = '+thisData);
+ if (parsedData.bag || (parsedData.numpowers && parsedData.numpowers[0]!=='=')) thisData = thisData.concat(baseData);
+ if (debugging)log('resolveData: result is '+thisData.length+' long = '+thisData);
+ return {parsed:parsedData, attrs:thisAttr, raw:((thisData[0].length === 1 && thisData[0][0].trim() === '[]') ? '' : thisData)};
+
+ } catch (err) {
+ if (err.message.startsWith('resolveData')) {
+ if (debugging) log(err.message);
+ return {parsed:baseParsed, attrs:baseAttr, raw:baseData};
+ } else {
+ LibFunctions.sendCatchError( 'RPGM Library',null,err,'RPGM Library resolveData()');
+ }
+ }
+ };
+
+ /*
+ * Function to replace special characters in a string
+ */
+
+ LibFunctions.parseStr = function(str='',replaced=replacers){
+ return replaced.reduce((m, rep) => m.replace(rep[0], rep[1]), str);
+ }
+
+ /**
+ * Get valid character from a tokenID
+ */
+
+ LibFunctions.getCharacter = function( tokenID, silent=true ) {
+
+ var curToken,
+ charID,
+ charCS;
+
+ if (!tokenID) {
+ if (!silent) LibFunctions.sendError('Invalid token_id in arguments');
+ return undefined;
+ };
+
+ charCS = getObj( 'character', tokenID );
+ if (charCS) return charCS;
+
+ curToken = getObj( 'graphic', tokenID );
+
+ if (!curToken) {
+ if (!silent) LibFunctions.sendError('Invalid token_id in arguments');
+ return undefined;
+ };
+
+ charID = curToken.get('represents');
+
+ if (!charID) {
+ if (!silent) LibFunctions.sendError(('The token "'+curToken.get('name')+'" does not represent a character sheet'));
+ return undefined;
+ };
+
+ charCS = getObj('character',charID);
+
+ if (!charCS) {
+ if (!silent) LibFunctions.sendError(('The token "'+curToken.get('name')+'" does not represent a character sheet'));
+ return undefined;
+ };
+ return charCS;
+ };
+
+ /*
+ * Get Thac0 from the right place for this token. This should be from
+ * Bar2 current value on the token (to support multi-token monsters affected
+ * individually by +/- magic impacts on thac0) but checks if another bar allocated
+ * or, if none are, get from character sheet (monster or character)
+ */
+
+ LibFunctions.getTokenValue = function( curToken, tokenBar, field, altField, thac0_base ) {
+
+ var charCS = LibFunctions.getCharacter(curToken.id),
+ attr = field[0].toLowerCase(),
+ altAttr = altField ? altField[0].toLowerCase() : 'EMPTY',
+ property = field[1],
+ token_property = (property.toLowerCase() == 'current' ? 'value' : 'max'),
+ linkedToken = false,
+ barName, attrVal, attrObj, attrName, tokenField,
+ fieldIndex = _.isUndefined(state.RPGMaster.tokenFields) ? -1 : state.RPGMaster.tokenFields.indexOf( field[0] );
+
+ if (!charCS) {return undefined;}
+
+ if (_.some( ['bar2_link','bar1_link','bar3_link'], linkName=>{
+ let linkID = curToken.get(linkName);
+ tokenField = linkName;
+ barName == '';
+ if (linkID && linkID.length) {
+ linkedToken = true;
+ attrObj = getObj('attribute',linkID);
+ if (attrObj) {
+ attrName = attrObj.get('name').toLowerCase();
+ barName = tokenField.substring(0,4);
+ return (attrName == attr) || (attrName == altAttr);
+ }
+ }
+ return false;
+ })) {
+ attrVal = curToken.get(barName+'_'+token_property);
+ attrVal = !isNaN(attrVal) ? parseFloat(attrVal) : undefined;
+ }
+ if (isNaN(attrVal) && !linkedToken && fieldIndex >= 0) {
+ attrVal = parseFloat(curToken.get('bar'+(fieldIndex+1)+'_'+token_property));
+ attrName = barName = 'bar'+(fieldIndex+1);
+ }
+ if (isNaN(attrVal) && attr.includes('thac0')) {
+ if (!thac0_base) thac0_base = ['thac0-base','current','20'];
+ attrVal = parseFloat(LibFunctions.attrLookup( charCS, thac0_base ));
+ attrName = thac0_base[0];
+ barName = undefined;
+ }
+ if (isNaN(attrVal)) {
+ attrVal = parseFloat(LibFunctions.attrLookup( charCS, field ));
+ attrName = field[0];
+ barName = undefined;
+ }
+ if (isNaN(attrVal) && altField) {
+ attrVal = parseFloat(LibFunctions.attrLookup( charCS, altField ));
+ attrName = altField[0];
+ }
+ return {val:attrVal, name:(isNaN(attrVal) ? undefined : attrName), barName:(barName || attrName)};
+ }
+
+ /*
+ * Create an array of class objects for the classes
+ * of the specified character.
+ */
+
+ LibFunctions.classObjects = function( charCS, senderId, parseTable ) {
+
+ try {
+ var charLevels = ((_.filter( fields, (elem,l) => {return l.toLowerCase().includes('_level')}).filter( elem => 0 < (LibFunctions.attrLookup( charCS, elem ) || 0))) || fields.Fighter_level);
+ var charClass, baseClass, charLevel, dB = fields.ClassDB;
+
+ var classDef = _.filter( classLevels, a => {
+ return _.some( charLevels, b => {
+ return (a[1].includes(b[0]))
+ })
+ })
+ .map( elem => {
+ charClass = LibFunctions.attrLookup(charCS,elem[0]) || '';
+ charLevel = LibFunctions.attrLookup( charCS, elem[1] ) || 0;
+ if (elem[0][0] == fields.Wizard_class[0]) {
+ baseClass = 'wizard';
+ } else if (elem[0][0] == fields.Priest_class[0]) {
+ baseClass = 'priest';
+ } else if (elem[0][0] == fields.Rogue_class[0]) {
+ baseClass = 'rogue';
+ } else if (elem[0][0] == fields.Psion_class[0]) {
+ baseClass = 'psion';
+ } else if (elem[1][0] == fields.Monster_hitDice[0]) {
+ let monsterHD = parseInt(LibFunctions.attrLookup( charCS, fields.Monster_hitDice )) || 0,
+ monsterHPplus = parseInt(LibFunctions.attrLookup( charCS, fields.Monster_hpExtra )) || 0,
+ monsterIntField = LibFunctions.attrLookup( charCS, fields.Monster_int ) || '',
+ monsterIntNum = parseInt((monsterIntField.match(/\d+/)||["1"])[0]) || 0,
+ monsterInt = monsterIntField.toLowerCase().includes('non') ? 0 : monsterIntNum;
+ charLevel = Math.ceil((monsterHD + Math.ceil(monsterHPplus/4)) / (monsterInt != 0 ? 1 : 2)); // Calculation based on p65 of DMG
+ baseClass = 'creature';
+ if (!charClass || !charClass.length) {
+ charClass = LibFunctions.attrLookup(charCS,fields.Race);
+ dB = fields.RaceDB;
+ };
+ if (!charClass || !charClass.length) {
+ charClass = 'creature';
+ dB = fields.ClassDB;
+ };
+
+ } else {
+ baseClass = 'warrior';
+ }
+ let classObj = LibFunctions.abilityLookup( dB, charClass, charCS, true );
+ if (!charClass.length || !classObj.obj) {
+ charClass = baseClass;
+ classObj = LibFunctions.abilityLookup( dB, baseClass, charCS, true );
+ }
+ return {name:charClass.dbName(), dB:classObj.dB, base:baseClass.dbName(), dBbase:fields.ClassDB, level:charLevel, obj:classObj.obj};
+ });
+ if (_.isUndefined(classDef) || !classDef.length) classDef = [{name:'creature', dB:fields.RaceDB, base:'warrior', dBbase:fields.ClassDB, level:0, obj:LibFunctions.abilityLookup( fields.ClassDB, 'creature', charCS ).obj}];
+ classDef = classDef.map(c => {let d = LibFunctions.resolveData((c.name || charClass), (c.dB || dB), reData, null, parseTable); c.classData = d.parsed; c.attrData = d.attrs; c.rawData = d.raw; return c});
+
+ } catch (e) {
+ LibFunctions.sendCatchError( 'RPGM Library',(senderId ? msg_orig[senderId] : null),e,'RPGM Library classObjects()');
+ }
+ return classDef;
+ };
+
+ /*
+ * Determine if a particular item type or superType is an
+ * allowed type for a specific class.
+ */
+
+ LibFunctions.classAllowedItem = function( charCS, wname, wt, wst, allowedItemsByClass ) {
+
+ wt = wt ? wt.dbName() : '-';
+ wst = wst ? wst.dbName() : '-';
+ wname = wname ? wname.dbName() : '-';
+ allowedItemsByClass = allowedItemsByClass.dbName();
+
+ var typeDefaults = {weaps:'any',ac:'any',sps:'any',spm:'',spb:'',align:'any',race:'any'},
+ itemType = !_.isUndefined(typeDefaults[allowedItemsByClass]) ? allowedItemsByClass : 'weaps',
+ forceFalse = false,
+ reItemSpecs = { weapons: reClassSpecs.weapons,
+ armour: reClassSpecs.armour,
+ majorsphere:reClassSpecs.majorsphere,
+ minorsphere:reClassSpecs.minorsphere,
+ bannedsphere:reClassSpecs.bannedsphere,
+ alignment: reClassSpecs.alignment,
+ race: reClassSpecs.race,
+ };
+
+ var classAllowed = LibFunctions.classObjects( charCS ).some( elem => {
+ if (wt.includes('innate') || wst.includes('innate')) return true;
+
+ if (!elem.obj) return false;
+ let allowedItems = (elem.classData[itemType] || typeDefaults[itemType]).toLowerCase().replace(reIgnore,'').split('|');
+ return allowedItems.reduce((p,c) => {
+ let item = '!+'.includes(c[0]) ? c.slice(1) : c,
+ found = item.includes('any') || (wt.includes(item) || wst.includes(item) || (wt=='-' && wst=='-' && wname.includes(item)));
+ forceFalse = (forceFalse || (c[0] === '!' && found)) && !(c[0] === '+' && found);
+ return (p || found) && !forceFalse;
+ }, false);
+ }),
+ raceAllowed = true;
+
+ forceFalse = false;
+ let allowedItems = LibFunctions.resolveData( (LibFunctions.attrLookup( charCS, fields.Race ) || 'human'), fields.RaceDB, reClassRaceData, charCS, reItemSpecs, '', [], true, [] ).parsed[itemType];
+ if (!allowedItems || !allowedItems.length) {
+ allowedItems = typeDefaults[itemType];
+ }
+ allowedItems = allowedItems.dbName().split('|');
+ raceAllowed = allowedItems.reduce((p,c) => {
+ let item = '!+'.includes(c[0]) ? c.slice(1) : c,
+ found = item.includes('any') || (wt.includes(item) || wst.includes(item) || (wt=='-' && wst=='-' && wname.includes(item)));
+ forceFalse = (forceFalse || (c[0] === '!' && found)) && !(c[0] === '+' && found);
+ return (p || found) && !forceFalse;
+ }, false);
+ return (classAllowed && raceAllowed);
+ };
+
+ /*
+ * For magic items that have stored spells or powers, extract
+ * these from the MI definition and create or update the
+ * related character sheet database attribute.
+ */
+
+ LibFunctions.addMIspells = function( dbCS, dbItem ) {
+
+ var itemData = LibFunctions.resolveData( dbItem.name, fields.MagicItemDB, reNumSpellsData ).raw,
+ itemSpells = itemData ? [...('['+itemData+']').matchAll(/\[.+?\]/g)] : [],
+ spellSet = {MU:[[],[]],PR:[[],[]],PW:[[],[]],AB:[[],[]]};
+
+ _.each(itemSpells, spell => {
+ let parsedData = LibFunctions.parseData( spell[0], reSpellSpecs );
+ if (parsedData && parsedData.spell && ['MU','PR','PW','AB'].includes(parsedData.spell.toUpperCase())) {
+ let spellType = parsedData.spell.toUpperCase();
+ spellSet[spellType][0].push(parsedData.name);
+ spellSet[spellType][1].push((spellType == 'PW') ? (parsedData.perDay+'.'+parsedData.perDay) : (parsedData.level+'.0'));
+ }
+ });
+ if (spellSet.PW[0].length) {
+ LibFunctions.setAttr( dbCS, [fields.ItemPowersList[0]+dbItem.name,fields.ItemPowersList[1]], spellSet.PW[0].join() );
+ if (spellSet.PW[1].length) {
+ LibFunctions.setAttr( dbCS, [fields.ItemPowerValues[0]+dbItem.name,fields.ItemPowerValues[1]], spellSet.PW[1].join() );
+ }
+ }
+ if (spellSet.PR[0].length) {
+ LibFunctions.setAttr( dbCS, [fields.ItemPRspellsList[0]+dbItem.name,fields.ItemPRspellsList[1]], spellSet.PR[0].join() );
+ if (spellSet.PR[1].length) {
+ LibFunctions.setAttr( dbCS, [fields.ItemPRspellValues[0]+dbItem.name,fields.ItemPRspellValues[1]], spellSet.PR[1].join() );
+ }
+ }
+ if (spellSet.MU[0].length) {
+ LibFunctions.setAttr( dbCS, [fields.ItemMUspellsList[0]+dbItem.name,fields.ItemMUspellsList[1]], spellSet.MU[0].join() );
+ if (spellSet.MU[1].length) {
+ LibFunctions.setAttr( dbCS, [fields.ItemMUspellValues[0]+dbItem.name,fields.ItemMUspellValues[1]], spellSet.MU[1].join() );
+ }
+ }
+ return spellSet;
+ }
+
+ /**
+ * String together the value of the specified item type from
+ * all databases with the specified root name, separated
+ * by |. This is used to get a complete list of available
+ * magic spell or item macros across all databases of a
+ * specific type.
+ **/
+
+ LibFunctions.getMagicList = function( rootDB, mapObj, objType, senderId, defList='', other=false, otherMsg='Specify', alphabet=false ) {
+
+ objType = _.isArray(objType) ? objType.join('-').toLowerCase() : objType.toLowerCase();
+ if (!magicList[rootDB] || !magicList[rootDB][objType] || magicList[rootDB][objType].alpha != alphabet) {
+
+ var list = [],
+ alphaList = [],
+ rDB = rootDB.toLowerCase().replace(/-/g,'_'),
+ typeList;
+
+ var addItemToList = function( objIndex, objName, mapObj, objType ) {
+ var error = false;
+ try {
+ var typeList;
+ if (objIndex[0].length) {
+ let obj = getObj('ability',objIndex[0]);
+ if (!obj) return true;
+ let objDef = obj.get('action');
+ let specs = objDef.match(reSpecs);
+ specs = specs ? [...('['+specs[0]+']').matchAll(reSpecsAll)] : [];
+
+ outer_block: {
+ for (const s of specs) {
+ typeList = s[2].dbName().split('|');
+ if (typeList.includes('format') || typeList.includes('hide')) continue;
+ for (const t of typeList) {
+ if (t==='magic') continue;
+ if ((mapObj[t] && !!mapObj[t].type && objType.includes(mapObj[t].type))
+ || (!mapObj[t] && mapObj.miscellaneous && objType.includes(mapObj.miscellaneous.type))) {
+ let listName = obj.get('name').dispName();
+ let listQuery = !mapObj[t] ? '' : (!mapObj[t].query ? '' : '%%'+mapObj[t].query).replace(/%/g,'%%')
+ .replace(/\&/g,'&')
+ .replace(/\?/g,'?')
+ .replace(/{/g,'&#123;')
+ .replace(/}/g,'&#125;')
+ .replace(/\|/g,'&#124;')
+ .replace(/\,/g,'&#44;');
+ list.push(listName+(alphabet ? ',' : ',')+listName+listQuery);
+ break outer_block;
+ }
+ }
+ }
+ }
+ } else {
+ typeList = dbNames[objIndex[2]].db[objIndex[3]].type.dbName().split('|');
+ if (typeList.includes('format') || typeList.includes('hide')) return error;
+
+ for (const t of typeList) {
+ if (t === 'magic') continue;
+ if ((mapObj[t] && !!mapObj[t].type && objType.includes(mapObj[t].type))
+ || (!mapObj[t] && mapObj.miscellaneous && objType.includes(mapObj.miscellaneous.type))) {
+ let listName = dbNames[objIndex[2]].db[objIndex[3]].name.dispName();
+ let listQuery = !mapObj[t] ? '' : (!mapObj[t].query ? '' : '%%'+mapObj[t].query).replace(/%/g,'%%')
+ .replace(/\&/g,'&')
+ .replace(/\?/g,'?')
+ .replace(/{/g,'&#123;')
+ .replace(/}/g,'&#125;')
+ .replace(/\|/g,'&#124;')
+ .replace(/\,/g,'&#44;');
+ list.push(listName+(alphabet ? ',' : ',')+listName+listQuery);
+ break;
+ }
+ }
+ }
+ } catch (e) {
+ log('LibFunction getMagicList: JavaScript '+e.name+': '+e.message+' while processing object '+objName);
+ LibFunctions.sendCatchError('RPGMaster Library',(senderId ? msg_orig[senderId] : null),e);
+ error = true;
+
+ } finally {
+ return error;
+ }
+ };
+
+ if (_.isUndefined(DBindex[rDB])) {
+ for (const db of _.keys(DBindex)) {
+ if (rDB.startsWith(db)) {
+ rDB = db;
+ break;
+ }
+ }
+ }
+ _.each( DBindex[rDB], (objIndex,objName) => {
+ addItemToList( objIndex, objName, mapObj, objType );
+ });
+ if (!list.length || !list[0].length) {
+ list = defList.split('|');
+ }
+ list = _.uniq(list.filter( list => !!list ).sort(),true);
+
+ if (alphabet) {
+ for (let i=65; i<=90; i++) {
+ let subList = list.filter( n => n.toUpperCase().charCodeAt(0)==i )
+ .concat(list.filter( n => n.toUpperCase().startsWith('POTION OF ') && n.toUpperCase().charCodeAt(10)==i ),
+ list.filter( n => n.toUpperCase().startsWith('RING OF ') && n.toUpperCase().charCodeAt(8)==i ),
+ list.filter( n => n.toUpperCase().startsWith('SCROLL OF ') && n.toUpperCase().charCodeAt(10)==i ));
+ if (subList && subList.length) {
+ if (subList.length === 1) subList.push(subList[0]);
+ alphaList.push(String.fromCharCode(i)+',?{Choose from |'+(subList.join('|'))+'}');
+ }
+ };
+ list = alphaList;
+ }
+ if (other) {
+ list.push('Other,?{'+otherMsg+'}');
+ }
+
+ if (!magicList[rootDB]) magicList[rootDB] = {};
+ if (!magicList[rootDB][objType]) magicList[rootDB][objType] = {};
+ magicList[rootDB][objType].list = list.join('|');
+ magicList[rootDB][objType].alpha = alphabet;
+ }
+ return magicList[rootDB][objType].list;
+ };
+
+ /**
+ * Get the displayable type of an item, derived from the
+ * item's database "Specs" field, for display in search-able
+ * containers
+ **/
+
+ LibFunctions.getShownType = function( miObj, row, miAlt ) {
+ var specs = miObj.specs(),
+ mi, miType, miAlt, data;
+ if (specs) {
+ let miClasses = specs.reduce((a,b) => a.concat([b[2]]), []).join('|');
+ let lowerMI = miClasses.toLowerCase();
+
+ mi = miClasses.split('|').find(itemClass => _.isUndefined(miTypeLists[itemClass.dbName()]) || !(['weapon','ammo','armour','armor'].includes(miTypeLists[itemClass.dbName()].type)));
+ if (!mi) {
+ mi = miClasses.split('|').find(itemClass => ['weapon','ammo','armour','armor'].includes(miTypeLists[itemClass.dbName()].type));
+ }
+ miType = miTypeLists[mi.dbName()] ? miTypeLists[mi.dbName()].type : 'miscellaneous';
+ if (!miAlt) miAlt = LibFunctions.resolveData(miObj.obj[1].name,fields.MagicItemDB,reNotAttackData,null,{itemType:reSpellSpecs.itemType},row,null,false).parsed.itemType;
+ specs = specs.find(itemSpecs => itemSpecs[2].toLowerCase().includes(mi.toLowerCase()));
+ switch (miType) {
+ case 'weapon':
+ case 'ammo':
+ mi = miAlt || ((specs || ['','','','','item'])[4]);
+ break;
+ case 'armour':
+ case 'armor':
+ mi = miAlt || ((specs || ['','mi'])[1]);
+ break;
+ case 'miscellaneous':
+ mi = miAlt || mi;
+ break;
+ default:
+ if (mi.toLowerCase() === 'magic') {
+ mi = miAlt || ((specs || ['','','','','item'])[4]);
+ }
+ break;
+ }
+ }
+ return mi.replace(/[-_]/g,' ').replace(/\|/g,'/');
+ };
+
+ /**
+ * Find an item identified as a Power, but which might actually
+ * be in a different database, as powers can be anything magical
+ **/
+
+ LibFunctions.findPower = function( charCS, power, silent=false, def=true ) {
+
+ if (!power || !power.length) return LibFunctions.abilityLookup( fields.PowersDB, '', charCS, true, false );
+
+ const dbList = [['PW-',fields.PowersDB],['MU-',fields.MU_SpellsDB],['PR-',fields.PR_SpellsDB],['MI-',fields.MagicItemDB]];
+
+ var powerType = power.substring(0,3),
+ powerLib;
+
+ if (_.some(dbList,dB=>dB[0]===powerType.toUpperCase())) power = power.slice(powerType.length);
+
+ if (!_.some(dbList, dB => {
+ if (powerType.toUpperCase() === dB[0]) {
+ powerLib = LibFunctions.abilityLookup( dB[1], power, null, true, def );
+ return true;
+ } else {
+ return false;
+ }
+ })) {
+ _.some(dbList, dB => {
+ powerLib = LibFunctions.abilityLookup( dB[1], power, null, true, false );
+ return !_.isUndefined(powerLib.obj);
+ });
+ };
+ if (!powerLib.obj) {
+ powerLib = LibFunctions.abilityLookup( fields.PowersDB, power, charCS, silent, def );
+ }
+ powerLib.name = power;
+ return powerLib;
+ }
+
+ /**
+ * Find and return total level of a character
+ **/
+
+ LibFunctions.characterLevel = function( charCS ) {
+// var level = parseInt((LibFunctions.attrLookup( charCS, fields.Total_level ) || 0),10);
+// if (!level) {
+ var level = Math.max(((parseInt((LibFunctions.attrLookup( charCS, fields.Monster_hitDice ) || 0),10)
+ + ((parseInt((LibFunctions.attrLookup( charCS, fields.Monster_hpExtra ) || 0),10) >= 3) ? 1 : 0)) || 0),
+ ((parseInt((LibFunctions.attrLookup( charCS, fields.Fighter_level ) || 0),10)
+ + parseInt((LibFunctions.attrLookup( charCS, fields.Wizard_level ) || 0),10)
+ + parseInt((LibFunctions.attrLookup( charCS, fields.Priest_level ) || 0),10)
+ + parseInt((LibFunctions.attrLookup( charCS, fields.Rogue_level ) || 0),10)
+ + parseInt((LibFunctions.attrLookup( charCS, fields.Psion_level ) || 0),10)) || 0));
+// }
+ return level;
+ }
+
+ /*
+ * Find and return the level for spell casting.
+ * MU: Wizard_level
+ * PR: Priest_level
+ * POWER or MI: all levels added
+ */
+
+ LibFunctions.caster = function( charCS, casterType ) {
+
+ var level=0, castingLevel=0, charClass, castingClass;
+
+ casterType = casterType.toUpperCase();
+
+ if (casterType == 'MI' || casterType == 'POWER' || casterType == 'PW') {
+ level = LibFunctions.characterLevel( charCS );
+ return {lv:level,cl:'',clv:level,ccl:''};
+ }
+
+ for (const casterData of casterLevels) {
+ charClass = (LibFunctions.attrLookup( charCS, casterData[0] ) || '');
+ castingClass = charClass.dbName();
+ level = LibFunctions.attrLookup(charCS,casterData[1]) || 0;
+ if (level > 0 && (_.isUndefined(spellsPerLevel[castingClass]) || _.isUndefined(spellsPerLevel[castingClass][casterType]))) {
+ if (casterType == 'MU' && casterData[0][0] == fields.Wizard_class[0]) {
+ castingClass = 'wizard';
+ } else if (casterType == 'PR' && casterData[0][0] == fields.Priest_class[0]) {
+ castingClass ='priest';
+ } else {
+ level = 0;
+ }
+ }
+ if (level > 0) break;
+ }
+ if (level>0 && castingClass) {
+ castingLevel = Math.min(Math.max((1+parseInt(level) - spellsPerLevel[castingClass][casterType][0][1]),0),spellsPerLevel[castingClass][casterType][0][2]);
+ if (castingLevel <= 0) castingLevel = -1;
+ };
+ return {lv:level,cl:charClass,clv:castingLevel,ccl:castingClass};
+ };
+
+ /* ---------------------------- Game Rule-Specific Functions -------------------------------- */
+
+ /*
+ * Return the base Thac0 of a character based on class & level
+ */
+
+ LibFunctions.handleGetBaseThac0 = function( charCS, type ) {
+
+ if (!type) {
+ return Math.min( LibFunctions.attrLookup( charCS, fields.MonsterThac0 ) || 20,
+ baseThac0table[0][LibFunctions.attrLookup( charCS, fields.Fighter_level ) || 0],
+ baseThac0table[1][LibFunctions.attrLookup( charCS, fields.Wizard_level ) || 0],
+ baseThac0table[2][LibFunctions.attrLookup( charCS, fields.Priest_level ) || 0],
+ baseThac0table[3][LibFunctions.attrLookup( charCS, fields.Rogue_level ) || 0],
+ baseThac0table[4][LibFunctions.attrLookup( charCS, fields.Psion_level ) || 0]
+ );
+ } else if (!isNaN(type)) {
+ return parseInt(type);
+ } else {
+ type = type.split('=');
+ let fromType = type[0].split('|'),
+ toType = (type[1].toUpperCase() || 'F')[0],
+ classNum = toType === 'O' ? 4
+ :(toType === 'W' ? 1
+ :(toType === 'P' ? 2
+ :(toType === 'R' ? 3
+ : 0 ))),
+ thac0 = 20,
+ field;
+
+ _.each( fromType, t => {
+ switch (t[0].toUpperCase()) {
+ default: field = fields.Fighter_level;break;
+ case 'W': field = fields.Wizard_level; break;
+ case 'P': field = fields.Priest_level; break;
+ case 'R': field = fields.Rogue_level; break;
+ case 'O': field = fields.Psion_level; break;
+ }
+ thac0 = Math.min( thac0, baseThac0table[classNum][LibFunctions.attrLookup( charCS, field ) || 0]);
+ });
+ return thac0;
+ }
+ }
+
+ /*
+ * Parse the Class Databases to update internal rule tables with
+ * any changes held for specific Class definitions
+ */
+
+ LibFunctions.parseClassDB = function(forceUpdate=false) {
+
+ var doParse = function( rootDB, saveMods ) {
+ let isClass = rootDB === fields.ClassDB,
+ indexDB = rootDB.toLowerCase().replace(/-/g,'_');
+ if (!DBindex[indexDB]) return;
+ for (const ClassName in DBindex[indexDB]) {
+ let def = LibFunctions.abilityLookup(rootDB, ClassName),
+ type = !def.obj ? '' : def.obj[1].type,
+ classSpecs = def.specs(reSpecs) || [['','','','','']],
+ isCreature = type.toLowerCase().includes('creature') || (classSpecs && classSpecs[0] && classSpecs[0][4] && String(classSpecs[0][4]).toLowerCase().includes('creature')),
+ dataObj = LibFunctions.resolveData( ClassName, rootDB, /}}\s*?(?:Class|Race)Data\s*?=(.*?){{/im, null, null, '', [], false );
+
+ if (isClass) {
+ let classType;
+ if (classSpecs && !_.isNull(classSpecs)) {
+ if (classSpecs.some( s => {
+ if (s && s.length >= 5) {
+ classType = (s[1]||'').dbName();
+ return (((s[4]||'').dbName() == 'wizard' ) && !ordMU.includes(classType) && (dataObj.parsed.specmu == 1));
+ }
+ return false;
+ })) {
+ if (!specMU.includes(classType)) specMU.push(classType);
+ } else {
+ if (!ordMU.includes(classType)) ordMU.push(classType);
+ };
+ };
+ }
+
+ if (dataObj.raw) {
+ for (let r=0; r {
+ pen = pen.toLowerCase().split('=');
+ pen[0] = pen[0].dbName();
+ pen[1] = parseInt(pen[1]) || 0;
+ return pen;
+ });
+ }
+ let rowArray = rowData.toLowerCase().replace(/\[/g,'').replace(/\]/g,'').split(','),
+ svlArray = rowArray.filter(elem => elem.startsWith('svl'));
+
+ if (svlArray && svlArray.length) {
+ svlArray.sort((a,b)=>{parseInt((a.match(/svl(\d+):/)||[0,0])[1])-parseInt((b.match(/svl(\d+):/)||[0,0])[1]);});
+ saveLevels[name] = [];
+ baseSaves[name] = [];
+ let oldLevel = 0,
+ baseIndex = 0;
+ svlArray.forEach(svl => {
+ let sv = svl.match(/svl(\d+):([\d\|]+)/),
+ level = parseInt(sv[1] || 0),
+ saves = (sv[2] || '20|20|20|20|20').split('|');
+ saveLevels[name].length = level+1;
+ saveLevels[name].fill(baseIndex,oldLevel,level+1);
+ if (baseIndex == 0 && level != 0) {
+ baseSaves[name].push([16,18,17,20,19]);
+ baseIndex++;
+ }
+ saves.length = 5;
+ baseSaves[name].push(saves);
+ baseIndex++
+ oldLevel = level+1;
+ });
+ };
+ svlArray = rowArray.filter(elem => {return /^\s*sv[a-z0-9]{3}:/.test(elem);});
+ if (svlArray && svlArray.length) {
+ saveMods[name] = {att:'con',par:0.0,poi:0.0,dea:0.0,rod:0.0,sta:0.0,wan:0.0,pet:0.0,pol:0.0,bre:0.0,spe:0.0,str:0.0,con:0.0,dex:0.0,int:0.0,wis:0.0,chr:0.0};
+ svlArray.forEach(svm => {
+ let sv = svm.match(/sv([a-z0-9]{3}):([+-]?\d+\.?\d*|\w{3})(L\d+)?/i);
+ if (sv[1] == 'all') {
+ saveMods[name] = _.mapObject(saveMods[name], (v,k) => {return k != 'att' ? v + (parseFloat(sv[2] || 0) || 0) : v;});
+ } else if (['sav','atr','chk'].includes(sv[1])) {
+ let saves = sv[1] === 'sav' ? saveFormat.Saves : (sv[1] === 'atr' ? saveFormat.Attributes : saveFormat.Checks);
+ _.each(saves, s => saveMods[name][s.tag] = saveMods[name][s.tag] + (parseFloat(sv[2] || 0) || 0));
+ } else {
+ let plv = parseInt(sv[3]) || 1;
+ saveMods[name][sv[1]] = (sv[1] != 'att') ? (String(parseFloat(sv[2] || 0) || 0)+(sv[3] || '')) : (sv[2] || 'con').dbName();
+ }
+ });
+ };
+ svlArray = rowArray.filter(elem => {return /^\s*sv[a-z0-9]{3}\+:/.test(elem);});
+ if (svlArray && svlArray.length) {
+ classSaveMods[name] = {att:'con',par:0.0,poi:0.0,dea:0.0,rod:0.0,sta:0.0,wan:0.0,pet:0.0,pol:0.0,bre:0.0,spe:0.0,str:0.0,con:0.0,dex:0.0,int:0.0,wis:0.0,chr:0.0};
+ svlArray.forEach(svm => {
+ let sv = svm.match(/sv([a-z0-9]{3})\+:([+-]?\d+\.?\d*|\w{3})/);
+ if (sv[1] == 'all') {
+ classSaveMods[name] = _.mapObject(classSaveMods[name], (v,k) => {return k != 'att' ? v + (parseFloat(sv[2] || 0) || 0) : v;});
+ } else if (['sav','atr','chk'].includes(sv[1])) {
+ let saves = sv[1] === 'sav' ? saveFormat.Saves : (sv[1] === 'atr' ? saveFormat.Attributes : saveFormat.Checks);
+ _.each(saves, s => classSaveMods[name][s.tag] = classSaveMods[name][s.tag] + (parseFloat(sv[2] || 0) || 0));
+ } else {
+ classSaveMods[name][sv[1]] = (sv[1] != 'att') ? (parseFloat(sv[2] || 0) || 0) : (sv[2] || 'con').dbName();
+ }
+ });
+ };
+ };
+ };
+ if (isCreature) {
+ if (!clTypeLists[classSpecs[0][2].toLowerCase()]) clTypeLists[classSpecs[0][2].toLowerCase()] = {type:'creature',field:fields.RaceCreatureList,query:''};
+ if (dataObj.parsed.query && dataObj.parsed.query.length) {
+ let query = LibFunctions.parseStr(dataObj.parsed.query).split('|');
+ let question = query.shift();
+ clTypeLists[classSpecs[0][2].toLowerCase()].query = '?{'+question + '|'
+ + query.map( q => {
+ let sq = q.split('%');
+ return sq[0]+','+sq.join('%%');
+ }).join('|')
+ + '}';
+ };
+ };
+ };
+ return;
+ };
+ if (classesParsed && !forceUpdate) return;
+ doParse( fields.ClassDB, classSaveMods );
+ doParse( fields.RaceDB, raceSaveMods );
+ classesParsed = true;
+ LibFunctions.sendFeedback( waitMsgDiv+'RPGMaster is now ready.' );
+
+ return;
+ };
+
+ /*
+ * Scan Race, Class, Level and MI data to set the saving throws table
+ * for a particular Token
+ */
+
+ LibFunctions.handleCheckSaves = function( args, senderId, selected, silent=false ) {
+
+ const blankMods = {par:0,poi:0,dea:0,rod:0,sta:0,wan:0,pet:0,pol:0,bre:0,spe:0,str:0,con:0,dex:0,int:0,wis:0,chr:0,rules:[]},
+ types = {par:'sav',poi:'sav',dea:'sav',rod:'sav',sta:'sav',wan:'sav',pet:'sav',pol:'sav',bre:'sav',spe:'sav',str:'atr',con:'atr',dex:'atr',int:'atr',wis:'atr',chr:'atr'},
+ reSave = /[,\[\s]sv([a-z0-9]{3}):([-\+\*\/\=\^vfc\d\.;\(\)]+)[,\s\]]/g,
+ reRules = /[,\[\s]rules:(.*?)[,\s\]]/i;
+
+ var tokenID,
+ charCS,
+ attkMenu,
+ msg = '',
+ massMod = false;
+
+ var checkThisSave = function(attkMenu,curToken,senderId,silent,selected) { // length
+
+ return new Promise(resolve => {
+ try {
+ tokenID = attkMenu ? curToken.id : curToken._id;
+ charCS = LibFunctions.getCharacter( tokenID, true );
+
+ var scanForSaves = function( item, trueItem, classArray, specsArray, dataArray, itemMods, setFlags ) {
+ let modsClass = classArray[0];
+ _.each( dataArray, data => {
+ if (!data) return;
+ if (!itemMods[modsClass] || _.size(itemMods[modsClass]) < _.size(blankMods)) itemMods[modsClass] = _.clone(blankMods);
+ let svRules = (data[0].match(reRules) || ['',''])[1].toLowerCase().replace(/[_\s]/g,'').split('|').map(r => r.replace(/\-/g,(match,i,s)=>(i>0?'':match))),
+ inHand = !svRules.includes('+inhand') || !_.isUndefined(LibFunctions.getTableField( charCS, {}, fields.InHand_table, fields.InHand_trueName ).tableFind( fields.InHand_trueName, trueItem )),
+ worn = !svRules.includes('+worn') || LibFunctions.classAllowedItem( charCS, trueItem, specsArray[0][1].dbName(), specsArray[0][4].dbName(), 'ac' ),
+ conflict = '',
+ testRules = [],
+ adds = !(_.some(itemMods,(mi,c) => {conflict=c;return (svRules.includes( '-'+c ) || _.some(classArray,mic => {return mi.rules.includes('-'+mic);}))}));
+ if (_.isUndefined(addedText[modsClass]) && (!inHand || !worn || !adds)) addedText[modsClass] = '';
+ if (!inHand && !silent) addedText[modsClass] += '{{'+item+'=Is not currently in hand}}';
+ if (!worn && !silent) addedText[modsClass] += '{{'+item+'=Is not of a usable type}}';
+ if (!adds && !silent) addedText[modsClass] += '{{'+item+'=Does not combine with items of class '+conflict+'}}';
+ if (!inHand || !worn || !adds) return;
+ let saveMods = [...data[0].matchAll(reSave)];
+ _.each( saveMods, m => {
+ let n = LibFunctions.evalAttr('-+='.includes(m[2][0]) ? m[2].substring(1) : m[2]);
+ m[2] = ('-+='.includes(m[2][0]) ? m[2][0] : '') + String(n);
+ let saveSpec;
+ if (_.isUndefined(addedText[modsClass])) addedText[modsClass] = '';
+ if (!silent) addedText[modsClass] += '{{'+item+'=';
+ let every = m[1] === 'all',
+ attr = m[1] === 'atr',
+ save = m[1] === 'sav';
+ if (save || every || attr) {
+ massMod = true;
+ let msg = 'All '+(every ? '' : (attr ? 'attribute ' : 'save '))+'mods: '+m[2];
+ if (!silent) addedText[modsClass] += msg;
+ let tempObj = itemMods[modsClass];
+ itemMods[modsClass] = _.mapObject(tempObj, function(v,k) {
+ if (k != 'att' && k != 'rules' && !_.isUndefined(blankMods[k])) {
+ if (every || types[k] == m[1]) {
+ if ('+-'.includes(m[2][0]) && !setFlags[k]) {
+ return (v+(parseInt(m[2]) || 0));
+ } else if (m[2][0] == '=') {
+ let newVal = parseInt(m[2].substring(1)) || 0;
+ if (setFlags[k]) {
+ if (!silent && v < newVal) addedText[modsClass] = '{{'+item+'='+msg;
+ return Math.max(v,newVal);
+ } else {
+ setFlags[k] = true;
+ if (!silent) addedText[modsClass] = '{{'+item+'='+msg;
+ return newVal;
+ }
+ } else if (!setFlags[k]) {
+ if (!silent && v < (parseInt(m[2]) || 0)) addedText[modsClass] = '{{'+item+'='+msg;
+ return Math.max(v,(parseInt(m[2]) || 0));
+ } else {
+ return v;
+ }
+ } else {
+ return v;
+ }
+ } else {
+ return v;
+ }
+ log('handleCheckSaves: checked everything, should not get here');
+ return v;
+ });
+ } else {
+ if (_.isUndefined(mods[m[1]])) mods[m[1]] = 0;
+ if (_.isUndefined(itemMods[modsClass][m[1]])) itemMods[modsClass][m[1]] = 0;
+ if (_.isUndefined(setFlags[m[1]])) setFlags[m[1]] = false;
+ let v = itemMods[modsClass][m[1]] || 0;
+ if (m[1] != 'att') {
+ if (!silent) addedText[modsClass] += (xlateSave[m[1]] || trueItem)+': '+m[2]+', ';
+ if ('+-'.includes(m[2][0]) && !setFlags[m[1]]) {
+ itemMods[modsClass][m[1]] += (parseInt(m[2]) || 0);
+ } else if (m[2][0] === '=') {
+ let newVal = parseInt(m[2].substring(1)) || 0;
+ if (setFlags[m[1]]) {
+ itemMods[modsClass][m[1]] = Math.max(v,newVal);
+ } else {
+ setFlags[m[1]] = true;
+ itemMods[modsClass][m[1]] = newVal;
+ }
+ } else if (!setFlags[m[1]]) {
+ itemMods[modsClass][m[1]] = Math.max(v,(parseInt(m[2]) || 0));
+ }
+ } else {
+ itemMods[modsClass].att = v;
+ }
+ };
+ if (!silent) addedText[modsClass] += '}}';
+ });
+ itemMods[modsClass].rules = itemMods[modsClass].rules ? itemMods[modsClass].rules.concat(svRules) : svRules;
+ });
+ return [itemMods,setFlags];
+ };
+
+
+ if (!charCS) {
+ return;
+ }
+ var tokenName = getObj('graphic',tokenID).get('name'),
+ classes = LibFunctions.classObjects( charCS ),
+ race = (LibFunctions.attrLookup( charCS, fields.Race ) || 'human').dbName(),
+ ItemNames = LibFunctions.getTableField( charCS, {}, fields.Items_table, fields.Items_name ),
+ saves = [],
+ classSaves, classMods,
+ SaveMods = LibFunctions.getTable( charCS, fieldGroups.SAVES ),
+ mods = _.isUndefined(raceSaveMods[race]) ? (_.find(raceSaveMods, (m,k) => race.includes(k)) || raceSaveMods.human) : raceSaveMods[race],
+ raceBonus = _.isUndefined(classSaveMods[race]) ? (_.find(classSaveMods, (m,k) => race.includes(k)) || _.create(blankMods)) : classSaveMods[race],
+ setFlags = {att:false,par:false,poi:false,dea:false,rod:false,sta:false,wan:false,pet:false,pol:false,bre:false,spe:false,str:false,con:false,dex:false,int:false,wis:false,chr:false},
+ miMods = {},
+ modName,
+ attribute, attrVal, item,
+ addedText = {},
+ itemText = '',
+ content = silent ? '' : '&{template:'+fields.defaultTemplate+'}';
+
+ content += silent ? '' : '{{name='+tokenName+'\'s Saving Throws}}';
+
+ classes.forEach( c => {
+ if (!saveLevels[c.name]) {
+ classSaves = baseSaves[c.base][saveLevels[c.base][Math.min(c.level,saveLevels[c.base].length-1)]];
+ } else {
+ classSaves = baseSaves[c.name][saveLevels[c.name][Math.min(c.level,saveLevels[c.name].length-1)]];
+ }
+ if (!saves || !saves.length) {
+ saves = classSaves;
+ } else {
+ saves = saves.map((v,k)=> Math.min(v,classSaves[k]));
+ }
+ if (!silent) itemText += '{{'+c.obj[1].name+'=Level '+c.level+'='+classSaves+'}}';
+ });
+
+ switch (mods.att.toLowerCase()) {
+ case 'str':
+ attribute = fields.Strength;
+ break;
+ case 'dex':
+ attribute = fields.Dexterity;
+ break;
+ case 'con':
+ attribute = fields.Constitution;
+ break;
+ case 'int':
+ attribute = fields.Intelligence;
+ break;
+ case 'wis':
+ attribute = fields.Wisdom;
+ break;
+ case 'chr':
+ attribute = fields.Charisma;
+ break;
+ default:
+ attribute = undefined;
+ };
+ if (attribute) {
+ attrVal = parseInt(LibFunctions.attrLookup( charCS, attribute )) || -1;
+ } else {
+ attrVal = -1;
+ }
+ if (!silent && _.some(mods,(m,k)=>!!m && k!='att')) itemText += '{{'+LibFunctions.attrLookup( charCS, fields.Race )+'=';
+ mods = _.mapObject(mods,(v,k) => {
+ if (k == 'att') {
+ return v;
+ } else {
+ if (!silent && v != 0) itemText += xlateSave[k]+':'+(Math.floor(attrVal != -1 ? (attrVal/v) : v)+raceBonus[k])+', ';
+ return Math.floor(v != 0 ? (attrVal != -1 ? (attrVal/v) : v) : 0)+raceBonus[k];
+ }
+ });
+ if (!silent && _.some(mods,(m,k)=>!!m && k!='att')) itemText += '}}';
+
+ let dexBonus = 0-(parseInt(LibFunctions.attrLookup( charCS, fields.Dex_acBonus )) || 0);
+ if (dexBonus) {
+ mods.dex += dexBonus;
+ itemText += '{{Dexterity of '+LibFunctions.attrLookup( charCS, fields.Dexterity )+'='+(dexBonus > 0 ? 'Bonus' : 'Penalty')+' of '+dexBonus+'}}';
+ }
+
+ classes.forEach( c => {
+ classMods = classSaveMods[c.name] || classSaveMods[c.base] || classSaveMods.undefined;
+ classMods = _.mapObject(classMods,v=>{
+ let plv = (v || '').match(/([-\+]?\d+)L(\d+)/i);
+ if (plv && plv[2] != 0) v = plv[1] * Math.ceil(c.level/plv[2]);
+ return parseInt(v);
+ });
+ if (!mods && !mods.length) {
+ mods = classMods;
+ } else {
+ mods = _.mapObject(mods,(v,k)=>{return k != 'att' ? v+classMods[k] : v});
+ }
+ if (classMods.att) classMods.att = classMods.par;
+ if (!silent && _.some(classMods)) {
+ itemText += '{{'+c.name+' Mods=';
+ let vals = _.chain(classMods).values().uniq().value();
+ if (vals.length == 1) {
+ itemText += 'All mods:'+vals[0];
+ } else {
+ _.mapObject(classMods,(v,k)=> ((k!='att' && v) ? (itemText += xlateSave[k]+':'+v+' ') : ''));
+ }
+ itemText += '}}';
+ }
+ });
+
+ for (let itemRow = ItemNames.table[1]; !_.isUndefined(item = ItemNames.tableLookup( fields.Items_name, itemRow, false )); itemRow++) {
+ if (item && item.length && item != '-') {
+ let trueItem = ItemNames.tableLookup( fields.Items_trueName, itemRow );
+ let itemObj = LibFunctions.abilityLookup( fields.MagicItemDB, trueItem, charCS );
+ if (itemObj.obj) {
+ let specsArray = itemObj.specs(/}}\s*specs=\s*?(.*?)\s*?{{/im),
+ miClass = specsArray ? (specsArray[0][2].dbName() || 'magicitem') : 'magicitem';
+
+ if (miClass.includes('ring') && miClass.includes('protection')) {
+ let leftRing = LibFunctions.attrLookup( charCS, fields.Equip_leftTrueRing ) || '-',
+ rightRing = LibFunctions.attrLookup( charCS, fields.Equip_rightTrueRing ) || '-';
+ if (![leftRing,rightRing].includes(trueItem)) {
+ if (!silent) itemText += '{{'+item+'=Is not currently worn}}';
+ continue;
+ }
+ }
+ [miMods,setFlags] = scanForSaves( item, trueItem, miClass.dbName().split('|'), specsArray, itemObj.data(/}}\s*\w*?data\s*=.*?sv[a-z0-9]{3}:.*?{{/img), miMods, setFlags );
+ };
+ };
+ };
+
+ for (let modRow = SaveMods.table[1]; !_.isUndefined(modName = SaveMods.tableLookup( fields.SaveMod_name, modRow, false )); modRow++) {
+ if (modName === '-') continue;
+ let curRound = parseInt(SaveMods.tableLookup(fields.SaveMod_curRound,modRow)) || 0,
+ toRound = parseInt(SaveMods.tableLookup(fields.SaveMod_round,modRow)) || 0,
+ diff = state.initMaster.round - curRound;
+ if (diff < 0 && !isNaN(toRound) && toRound !== 0) toRound += diff;
+ curRound += diff;
+ let saveCount = SaveMods.tableLookup(fields.SaveMod_saveCount,modRow);
+ if ((saveCount !== '' && saveCount <= 0) || (!isNaN(toRound) && toRound > 0 && toRound < state.initMaster.round)) {
+ SaveMods.addTableRow(modRow);
+ continue;
+ } else if (diff !== 0 && !isNaN(toRound) && toRound > 0) {
+ SaveMods.tableSet(fields.SaveMod_curRound,modRow,curRound);
+ SaveMods.tableSet(fields.SaveMod_round,modRow,toRound);
+ };
+ let spellName = SaveMods.tableLookup(fields.SaveMod_spellName,modRow);
+ [miMods,setFlags] = scanForSaves( spellName, modName, spellName.toLowerCase().split('|'), [['','','','','']], [['['+SaveMods.tableLookup(fields.SaveMod_saveSpec,modRow)+']']], miMods, setFlags );
+ };
+
+ _.each(miMods, function(s,c) {
+ mods = _.mapObject(mods, function(v,k) {
+ return (_.isUndefined(s[k]) ? v : (setFlags[k] ? s[k] : (v + s[k])));
+ });
+ });
+
+ _.each( saveFormat.Saves, (s,k) => {
+ LibFunctions.setAttr( charCS, s.mon, saves[s.index] );
+ LibFunctions.setAttr( charCS, s.save, saves[s.index] );
+ LibFunctions.setAttr( charCS, s.mod, mods[s.tag] );
+ });
+
+ for (let modRow = SaveMods.table[1]; !_.isUndefined(modName = SaveMods.tableLookup( fields.SaveMod_name, modRow, false )); modRow++) {
+ if (modName === '-') continue;
+ let tag = SaveMods.tableLookup( fields.SaveMod_tag, modRow ),
+ basis = SaveMods.tableLookup( fields.SaveMod_basis, modRow, false ),
+ i = SaveMods.tableLookup( fields.SaveMod_index, modRow );
+ if (_.isUndefined(mods[tag])) {
+ mods[tag] = 0;
+ setFlags[tag] = false;
+ }
+ if (!_.isUndefined(basis) && !setFlags[tag]) mods[tag] += mods[basis];
+ LibFunctions.setAttr( charCS, [SaveMods.tableLookup( fields.SaveMod_saveField, modRow ),'current'], saves[i] );
+ LibFunctions.setAttr( charCS, [SaveMods.tableLookup( fields.SaveMod_modField, modRow ),'current'], mods[tag] );
+ };
+
+ if (!silent) {
+ itemText += _.reduce(addedText, (t,i) => (t + i));
+ content +='{{Saves=';
+ let i = -1,
+ a = [];
+ _.each( saveFormat.Saves, (s,k) => {
+ if (s.index != i) {
+ content += a.join(', ');
+ a = [];
+ content += (i>0?'':'')+'**'+saves[(i=s.index)]+'** | ';
+ }
+ a.push(k+'('+(mods[s.tag]>=0?'+':'')+mods[s.tag]+')');
+ });
+ content += a.join(', ')+' | ';
+ for (let modRow = SaveMods.table[1]; !_.isUndefined(modName = SaveMods.tableLookup( fields.SaveMod_name, modRow, false )); modRow++) {
+ if (modName === '-') continue;
+ let tag = SaveMods.tableLookup( fields.SaveMod_tag, modRow ),
+ index = SaveMods.tableLookup( fields.SaveMod_index, modRow );
+ content += '**'+saves[index]+'** | '+modName+'('+(mods[tag]>=0?'+':'')+mods[tag]+') | ';
+ };
+
+ content += ' }}';
+ content += '{{Attribute Checks=';
+ _.each( saveFormat.Attributes, (a,k) => content += '**'+LibFunctions.attrLookup(charCS,a.save)+'** | '+k+'('+(mods[a.tag]>=0?'+':'')+mods[a.tag]+') | ');
+ content +=' }}'
+ + ((selected.length == 1) ? itemText : '');
+ };
+ } catch (e) {
+ sendCatchError('RPGM Library',null,e,'RPGM Library handleCheckSaves()');
+ content = '';
+ } finally {
+ setTimeout(() => {
+ resolve(content);
+ }, 1);
+ };
+ });
+ };
+
+ async function checkAllSaves( args, selected, senderId, silent ) {
+ try {
+ var who = LibFunctions.sendToWho(null,senderId);
+
+ if (attkMenu = (args && args[0])) {
+ selected = [];
+ selected.push(getObj('graphic',args[0]));
+ }
+ let nomenu = args && ((args[2] || '') === 'nomenu');
+
+ for (const token of selected) {
+ if (msg && msg.length) msg += '\n'+who;
+ msg += await checkThisSave( attkMenu, token, senderId, silent, selected );
+ };
+
+ if (!silent && !nomenu && (attkMenu || (args && args[1]))) {
+ if (!msg) msg = '&{template:'+fields.defaultTemplate+'}';
+ msg += '{{desc=[Return to Menu]('+(attkMenu ? ('!attk --button '+(args[1] || 'SAVES')+'|'+args[0]) : ('!cmd --button '+args[1]))+')}}';
+ }
+ if (!silent) {
+ LibFunctions.sendResponse( charCS, msg, senderId );
+ } else {
+ clearWaitTimer(senderId);
+ }
+ return;
+ } catch (e) {
+ sendCatchError( 'RPGM Library', msg_orig[senderId], e);
+ }
+ };
+
+ checkAllSaves( args, selected, senderId, silent );
+ return;
+ }
+
+ /*
+ * Reload all weapons in the InHand tables, to set correct
+ * data after a race, class or level change. Will not work
+ * for weapons entered manually into the weapon tables
+ */
+
+ LibFunctions.handleCheckWeapons = function( tokenID, charCS ) {
+
+ var InHand = LibFunctions.getTable( charCS, fieldGroups.INHAND ),
+ itemIndex = InHand.tableLookup( fields.InHand_index, 0 );
+ if (itemIndex.length && !isNaN(itemIndex)) {
+ LibFunctions.sendAPI('!attk --button PRIMARY|'+tokenID+'|'+itemIndex+'|0||silent');
+ }
+ itemIndex = InHand.tableLookup( fields.InHand_index, 1 );
+ if (itemIndex.length && !isNaN(itemIndex)) {
+ LibFunctions.sendAPI('!attk --button OFFHAND|'+tokenID+'|'+itemIndex+'|1||silent');
+ }
+ itemIndex = InHand.tableLookup( fields.InHand_index, 2 );
+ if (itemIndex.length && !isNaN(itemIndex)) {
+ LibFunctions.sendAPI('!attk --button BOTH|'+tokenID+'|'+itemIndex+'|2||silent');
+ }
+
+ for (let r=3; !_.isUndefined(itemIndex = InHand.tableLookup( fields.InHand_index, r, false )); r++) {
+ if (itemIndex.length && !isNaN(itemIndex)) {
+ LibFunctions.sendAPI('!attk --button HAND|'+tokenID+'|'+itemIndex+'|'+r+'||silent');
+ }
+ }
+ return;
+ }
+
+/* ------------------------------------------------------------ Configuration ------------------------------------------------ */
+
+ /**
+ * Get the configuration for the player who's ID is passed in
+ * or, if the config is passed back in, set it in the state variable
+ **/
+
+ LibFunctions.getSetPlayerConfig = function( playerID, configObj ) {
+
+ if (!state.MagicMaster.playerConfig[playerID]) {
+ state.MagicMaster.playerConfig[playerID]={};
+ }
+ if (!_.isUndefined(configObj)) {
+ state.MagicMaster.playerConfig[playerID] = configObj;
+ };
+ return state.MagicMaster.playerConfig[playerID];
+ };
+
+ /*
+ * Make a configuration menu to allow the DM to select:
+ * - strict mode: follow the rules precisely,
+ * - house rules mode: follow "old fogies" house rules
+ * - no restrictions: allow anything goes
+ */
+
+ LibFunctions.makeConfigMenu = function( args, msg='' ) {
+
+ var configButtons = function( flag, txtOn, cmdOn, txtOff, cmdOff ) {
+ const liveButton = (txt) =>''+txt+' | ',
+ selButton = (txt,cmd) => ''+txt+' | ';
+ var buttons = (flag ? (selButton(txtOn,cmdOn)+liveButton(txtOff)) : (liveButton(txtOn)+selButton(txtOff,cmdOff)));
+// + (flag ? ('['+txtOn+']('+cmdOn+')'+txtOff+'')
+// : (''+txtOn+' | ['+txtOff+']('+cmdOff+')'))
+ return buttons;
+ };
+
+ var content = '&{template:'+fields.menuTemplate+'}{{name=Configure RPGMaster}}{{subtitle=AttackMaster}}'
+ + (msg.length ? '{{ ='+msg+'}}' : '')
+ + '{{desc=Select which configuration you wish for this campaign using the toggle buttons below.}}'
+ + '{{desc1=';
+
+ content += ('undefined' !== typeof MagicMaster ? ('Menus | '+configButtons(state.MagicMaster.fancy, 'Plain menus', '!magic --config fancy-menus|false', 'Fancy menus', '!magic --config fancy-menus|true')+' ') : '');
+ if ('undefined' !== typeof attackMaster) {
+ content += 'Player Targeted Attks | '+configButtons(!state.attackMaster.weapRules.dmTarget, 'Not Allowed', '!attk --config dm-target|true', 'Allowed by All', '!attk --config dm-target|false')+' '
+ + 'Allowed weapons | '+configButtons(state.attackMaster.weapRules.allowAll, 'Restrict Usage', '!attk --config all-weaps|false', 'All Can Use Any', '!attk --config all-weaps|true')+' '
+ + (state.attackMaster.weapRules.allowAll ? '' : ('Restrict weapons | '+configButtons(!state.attackMaster.weapRules.classBan, 'Strict Denial', '!attk --config weap-class|true', 'Apply Penalty', '!attk --config weap-class|false')+' '))
+ + 'Weapon Speed | '+configButtons(!state.attackMaster.weapRules.initPlus, 'Plus affects speed', '!attk --config weap-plus|true', 'Magic Plus Ignored', '!attk --config weap-plus|false')+' '
+ + 'Critical Rolls | '+configButtons(!state.attackMaster.weapRules.criticals, 'Always hit/miss', '!attk --config criticals|true', 'Calculate hit/miss', '!attk --config criticals|false')+' '
+ + 'Natural Max Min Rolls | '+configButtons(!state.attackMaster.weapRules.naturals, 'Always hit/miss', '!attk --config naturals|true', 'Calculate hit/miss', '!attk --config naturals|false')+' '
+ + 'Allowed Armour | '+configButtons(state.attackMaster.weapRules.allowArmour, 'Strict Denial', '!attk --config all-armour|false', 'All Can Use Any', '!attk --config all-armour|true')+' '
+ + 'Non-Prof Penalty | '+configButtons(!state.attackMaster.weapRules.prof, 'Class Penalty', '!attk --config prof|true', 'Character Sheet', '!attk --config prof|false')+' '
+ + 'Ranged Mastery | '+configButtons(state.attackMaster.weapRules.masterRange, 'Not Allowed', '!attk --config master-range|false', 'Mastery Allowed', '!attk --config master-range|true')+' '
+ + 'Rogue Skills | '+configButtons(state.attackMaster.thieveCrit, 'No Critical', '!attk --config rogue-crit|false', 'Critical Success', '!attk --config rogue-crit|true')+' '
+ + ((state.attackMaster.thieveCrit > 0) ? ('Rogue Crit Value | '+configButtons(state.attackMaster.thieveCrit>1, 'Critical = 1%', '!attk --config rogue-crit-val|false', 'Critical = 5%', '!attk --config rogue-crit-val|true')+' ') : '');
+ }
+ if ('undefined' !== typeof MagicMaster) {
+ content += 'Specialist Wizards | '+configButtons(!state.MagicMaster.spellRules.specMU, 'Specified in Rules', '!magic --config specialist-rules|true', 'Allow Any Specialist', '!magic --config specialist-rules|false')+' '
+ + 'Spells per Level | '+configButtons(!state.MagicMaster.spellRules.strictNum, 'Strict by Rules', '!magic --config spell-num|true', 'Allow to Set Misc', '!magic --config spell-num|false')+' '
+ + 'Spell Schools | '+configButtons(state.MagicMaster.spellRules.allowAll, 'Strict by Rules', '!magic --config all-spells|false', 'All Can Use Any', '!magic --config all-spells|true')+' '
+ + 'Powers by Level | '+configButtons(state.MagicMaster.spellRules.allowAnyPower, 'Strict by Rules', '!magic --config all-powers|false', 'All Can Use Any', '!magic --config all-powers|true')+' '
+ + 'Custom Objects | '+configButtons(!state.MagicMaster.spellRules.denyCustom, 'External / GM Defined', '!magic --config custom-spells|true', 'All Items Allowed', '!magic --config custom-spells|false')+' '
+ + 'Auto-Hide Items | '+configButtons(state.MagicMaster.autoHide, 'GM Hide Manually', '!magic --config auto-hide|false', 'Auto-Hide if Possible', '!magic --config auto-hide|true')+' '
+ + 'Reveal Hidden Items | '+configButtons(state.MagicMaster.reveal, 'Reveal Manually', '!magic --config reveal|false', 'Reveal on Use', '!magic --config reveal|true')+' '
+ + 'Action Buttons | '+configButtons(state.MagicMaster.viewActions, 'Grey on View', '!magic --config view-action|false', 'Active on View', '!magic --config view-action|true')+' '
+ + 'Alphabetic Lists | '+configButtons(!state.MagicMaster.alphaLists, 'Alphabetic', '!magic --config alpha-lists|true', 'Not Alphabetic', '!magic --config alpha-lists|false')+' '
+ + 'Skill-Based Chance | '+configButtons(!state.MagicMaster.gmRolls, 'GM rolls', '!magic --config gm-rolls|true', 'Player rolls', '!magic --config gm-rolls|false')+' ';
+ }
+ content += ('undefined' !== typeof CommandMaster ? ('[Set Default Token Bars](!cmd --button AB_ASK_TOKENBARS|) | ') : '')
+ + ' }}';
+ LibFunctions.sendFeedback( content );
+ return;
+ };
+
+/* -------------------------------------------------- Code stubs for alternate versions -------------------------------------- */
+
+ LibFunctions.creatureAttkDefs = function() {};
+ LibFunctions.creatureWeapDefs = function() {};
+ LibFunctions.updateClassLevel = function() {};
+ LibFunctions.displayClassLevel = function() {};
+
+/* --------------------------------------------------- End of Library Functions ---------------------------------------------------- */
+
+
+/* ---------------------------------------------------- Finish Initialisation ---------------------------------------------- */
+
+ LibFunctions.sendFeedback( waitMsgDiv+'Please wait while RPGMaster initialises...' );
+ apis.magic = ('undefined' !== typeof MagicMaster);
+ apis.attk = ('undefined' !== typeof attackMaster);
+ apis.init = ('undefined' !== typeof initMaster);
+ DBindex = undefined;
+
+ if (_.isUndefined(state.RPGMaster)) state.RPGMaster = {};
+ if (_.isUndefined(state.RPGMaster.tokenFields)) {
+ state.RPGMaster.tokenFields = [fields.AC[0],fields.Thac0_base[0],fields.HP[0]];
+ };
+ if (_.isUndefined(state.MagicMaster)) state.MagicMaster = {};
+ if (_.isUndefined(state.MagicMaster.spellRules)) state.MagicMaster.spellRules = {};
+
+ setTimeout( del_Old_DBs, 5000 );
+
+ // RED: v1.036 create help handouts from stored data
+ setTimeout( () => LibFunctions.updateHandouts(handouts,true,findTheGM()),300);
+ setTimeout( () => displayReleaseNotesLink(), 5000 );
+ setTimeout( () => LibFunctions.sendAPI('!token-mod --api-as '+findTheGM()+' --config players-can-ids|on',findTheGM()), 10000);
+ }
+ }
+
+ const handleChatMessage = (msg) => {
+ try {
+ var preamble, targetid,
+ playerid = msg.playerid;
+
+ msg_orig[playerid] = msg;
+
+ if (msg.type === "api") {
+ return;
+ } else if (msg.content.trim().startsWith('!')) {
+ log('lib handleChatMessage: msg not api but starts with ! so re-send. Msg = '+msg.content);
+ return;
+ }
+ if (msg.rolltemplate && msg.rolltemplate.startsWith('RPGM')) {
+
+ targetid = findTheGM();
+ if (msg.target) {
+ if (msg.target != 'gm') {
+ let targetObjs = findObjs({_type:'player',_displayname:msg.who});
+ targetid = (!targetObjs || !targetObjs.length) ? targetid : targetObjs[0].id;
+ }
+ }
+ let newMsg = Object.create(msg);
+ newMsg = processInlinerolls(newMsg);
+ const template = newMsg.match(/^([^{]*)({{[^]*}}).*?$([^]*)/im);
+ switch (msg.type.toLowerCase()) {
+ case 'emote':
+ preamble = '/em';
+ break;
+ case 'desc':
+ preamble = '/desc';
+ break;
+ case 'whisper':
+ preamble = '/w "'+msg.target_name+'"';
+ break;
+ default:
+ preamble = '';
+ break;
+ }
+ if (/^\s*\/i.test(template[1])) preamble += ' '+template[1]; else if (template[1].trim().length) log('RPGM output parser: extra preamble = '+template[1]);
+ LibFunctions.parseOutput( msg.who, preamble, msg.rolltemplate, template[2], targetid );
+ }
+ return;
+ } catch (e) {
+ log('RPGMaster Library handleChatMessage: JavaScript '+e.name+': '+e.message+' while processing a chat message');
+ LibFunctions.sendCatchError('RPGMaster Library',msg_orig[playerid],e);
+ }
+ };
+
+ const tryInit = ()=>{
+ if(Campaign()) {
+ LibFunctions.init();
+ } else {
+ setTimeout(tryInit,10);
+ }
+ };
+ setTimeout(tryInit,0);
+
+ const checkInstall = () => {
+ log('-=> libRPGMaster v'+version+' <=- ['+(new Date(lastUpdate*1000))+']');
+
+ if( ! state.hasOwnProperty('libRPGMaster') || state.libRPGMaster.version !== schemaVersion) {
+ switch(state.libRPGMaster && state.libRPGMaster.version) {
+
+ case 0.1:
+ /* break; // intentional dropthrough */
+
+ case 'UpdateSchemaVersion':
+ state.libRPGMaster.version = schemaVersion;
+ break;
+
+ default:
+ state.libRPGMaster = {
+ version: schemaVersion
+ };
+ break;
+ }
+ }
+ };
+
+ const registerLib = () => {
+ on('chat:message',handleChatMessage);
+ };
+
+ on('ready', function () {
+ checkInstall();
+ registerLib();
+ });
+
+ return {
+ getRPGMap: (...a) => LibFunctions.getRPGMap(...a),
+ getTableField: (...a) => LibFunctions.getTableField(...a),
+ getTable: (...a) => LibFunctions.getTable(...a),
+ getLvlTable: (...a) => LibFunctions.getLvlTable(...a),
+ initValues: (...a) => LibFunctions.initValues(...a),
+ attrLookup: (...a) => LibFunctions.attrLookup(...a),
+ setAttr: (...a) => LibFunctions.setAttr(...a),
+ abilityLookup: (...a) => LibFunctions.abilityLookup(...a),
+ setAbility: (...a) => LibFunctions.setAbility(...a),
+ doDisplayAbility: (...a) => LibFunctions.doDisplayAbility(...a),
+ getAbility: (...a) => LibFunctions.getAbility(...a),
+ parseTemplate: (...a) => LibFunctions.parseTemplate(...a),
+ redisplayOutput: (...a) => LibFunctions.redisplayOutput(...a),
+ parseOutput: (...a) => LibFunctions.parseOutput(...a),
+ sendToWho: (...a) => LibFunctions.sendToWho(...a),
+ sendMsgToWho: (...a) => LibFunctions.sendMsgToWho(...a),
+ sendPublic: (...a) => LibFunctions.sendPublic(...a),
+ sendAPI: (...a) => LibFunctions.sendAPI(...a),
+ sendFeedback: (...a) => LibFunctions.sendFeedback(...a),
+ sendResponse: (...a) => LibFunctions.sendResponse(...a),
+ sendResponsePlayer: (...a) => LibFunctions.sendResponsePlayer(...a),
+ sendResponseError: (...a) => LibFunctions.sendResponseError(...a),
+ sendToOthers: (...a) => LibFunctions.sendToOthers(...a),
+ sendError: (...a) => LibFunctions.sendError(...a),
+ sendCatchError: (...a) => LibFunctions.sendCatchError(...a),
+ sendParsedMsg: (...a) => LibFunctions.sendParsedMsg(...a),
+ sendGMquery: (...a) => LibFunctions.sendGMquery(...a),
+ sendWait: (...a) => LibFunctions.sendWait(...a),
+ checkDBver: (...a) => LibFunctions.checkDBver(...a),
+ saveDBtoHandout: (...a) => LibFunctions.saveDBtoHandout(...a),
+ buildCSdb: (...a) => LibFunctions.buildCSdb(...a),
+ checkCSdb: (...a) => LibFunctions.checkCSdb(...a),
+ getDBindex: (...a) => LibFunctions.getDBindex(...a),
+ updateHandouts: (...a) => LibFunctions.updateHandouts(...a),
+ findThePlayer: (...a) => LibFunctions.findThePlayer(...a),
+ findCharacter: (...a) => LibFunctions.findCharacter(...a),
+ fixSenderId: (...a) => LibFunctions.fixSenderId(...a),
+ calcAttr: (...a) => LibFunctions.calcAttr(...a),
+ rollDice: (...a) => LibFunctions.rollDice(...a),
+ evalAttr: (...a) => LibFunctions.evalAttr(...a),
+ getCharacter: (...a) => LibFunctions.getCharacter(...a),
+ getTokenValue: (...a) => LibFunctions.getTokenValue(...a),
+ classObjects: (...a) => LibFunctions.classObjects(...a),
+ addMIspells: (...a) => LibFunctions.addMIspells(...a),
+ getMagicList: (...a) => LibFunctions.getMagicList(...a),
+ getShownType: (...a) => LibFunctions.getShownType(...a),
+ parseClassDB: (...a) => LibFunctions.parseClassDB(...a),
+ handleCheckSaves: (...a) => LibFunctions.handleCheckSaves(...a),
+ handleCheckWeapons: (...a) => LibFunctions.handleCheckWeapons(...a),
+ getHandoutIDs: (...a) => LibFunctions.getHandoutIDs(...a),
+ classAllowedItem: (...a) => LibFunctions.classAllowedItem(...a),
+ parseData: (...a) => LibFunctions.parseData(...a),
+ parseStr: (...a) => LibFunctions.parseStr(...a),
+ resolveData: (...a) => LibFunctions.resolveData(...a),
+ findPower: (...a) => LibFunctions.findPower(...a),
+ handleGetBaseThac0: (...a) => LibFunctions.handleGetBaseThac0(...a),
+ characterLevel: (...a) => LibFunctions.characterLevel(...a),
+ caster: (...a) => LibFunctions.caster(...a),
+ creatureAttkDefs: (...a) => LibFunctions.creatureAttkDefs(...a),
+ creatureWeapDefs: (...a) => LibFunctions.creatureWeapDefs(...a),
+ getSetPlayerConfig: (...a) => LibFunctions.getSetPlayerConfig(...a),
+ makeConfigMenu: (...a) => LibFunctions.makeConfigMenu(...a),
+ displayClassLevel: (...a) => LibFunctions.displayClassLevel(...a),
+ updateClassLevel: (...a) => LibFunctions.updateClassLevel(...a),
+ };
+
+})();
+
+{try{throw new Error('');}catch(e){API_Meta.libRPGMaster.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.libRPGMaster.offset);}}
diff --git a/RPGMlibrary AD+D2e/libRPGMaster2e.js b/RPGMlibrary AD+D2e/libRPGMaster2e.js
index dc7409c90..a342b6a2a 100644
--- a/RPGMlibrary AD+D2e/libRPGMaster2e.js
+++ b/RPGMlibrary AD+D2e/libRPGMaster2e.js
@@ -85,13 +85,14 @@ API_Meta.libRPGMaster={offset:Number.MAX_SAFE_INTEGER,lineCount:-1};
* operators for ceil(...) and floor(...). Grey out database item action buttons on "view"
* (toggled by config button)
* v3.5.1 03/08/2024 Changed thief detect noise skill to be on a configurable GM roll.
+ * v3.5.2 14/09/2024 Fixed evalAttr() to correctly deal with attribute [...] descriptors
**/
const libRPGMaster = (() => { // eslint-disable-line no-unused-vars
'use strict';
- const version = '3.5.1';
+ const version = '3.5.2';
API_Meta.libRPGMaster.version = version;
- const lastUpdate = 1717750563;
+ const lastUpdate = 1726328386;
const schemaVersion = 0.1;
log('now in seconds is '+Date.now()/1000);
@@ -3625,35 +3626,28 @@ const libRPGMaster = (() => { // eslint-disable-line no-unused-vars
type:'spells',
avatar:'https://s3.amazonaws.com/files.d20.io/images/163483347/1CLiNzi4jlxXK1-lVr7MTQ/max.png?1599726214',
version:8.02,
- db:[{name:'Banishment',type:'muspelll7',ct:'7',charge:'uncharged',cost:'10',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nBanishment\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Abjuration}}Specs=[Banishment,MUspellL7,1H,Abjuration]{{components=V,S,M}}{{time=[[7]]}}{{range=[[20]] yards}}{{duration=Instantaneous}}{{aoe=[60ft radius](!rounds --aoe @{selected|token_id}|circle|yards|20|40||magic)}}{{save=Special}}{{reference=PHB p182}}SpellData=[w:Banishment,lv:7,sp:7,gp:10,cs:VSM]{{effects=Enables the caster to force some extraplanar creature out of the caster\'s home plane. The effect is instantaneous, and the subject cannot come back without some special summoning or means of egress from its own plane to the one from which it was banished. Up to 2 Hit Dice or levels of creature per caster level can be banished.}}{{hide1=The caster must both name the type of creature(s) to be sent away and give its name and title as well, if any. In any event, the creature\'s magic resistance must be overcome for the spell to be effective.\nThe material components of the spell are substances harmful, hateful, or opposed to the nature of the subject(s) of the spell. For every such substance included in the casting, the subject creature(s) loses 5% from its magic resistance and suffers a -2 penalty to its saving throw vs. spell. For example, if iron, holy water, sunstone, and a sprig of rosemary were used in casting a banishment upon a being that hates those things, its saving throw versus the spell would be made with a -8 penalty (four substances times the factor of -2). Special items, such as hair from the tail of a ki-rin or couatl feathers, could also be added to change the factor to -3 or -4 per item. In contrast, a titan\'s hair or mistletoe blessed by a druid might lower the factor to -1 with respect to the same creature. If the subject creature successfully rolls its saving throw vs. spell, the caster is stung by a backlash of energy, suffers 2d6 points of damage, and is stunned for one round.}}'},
- {name:'Bigbys-Grasping-Hand',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0.1',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nBigby\'s Grasping Hand\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Evocation}}Specs=[Bigbys Grasping Hand,MUspellL7,1H,Evocation]{{components=V,S,M}}{{time=[[7]]}}{{range=[[10*@{selected|mu-casting-level}]] yards}}{{duration=[[@{selected|mu-casting-level}]] rounds}}{{aoe=Special}}{{save=None}}{{Use=[Create hand](!rounds --target-nosave caster|@{selected|token_id}|Bigbys-Grasping-hand|@{selected|mu-casting-level}|-1|They\'ve given you a Big Grasping Hand! HP@{selected|hp|max}, AC0|fist)}}{{reference=PHB p183}}SpellData=[w:Bigbys Grasping Hand,lv:7,sp:7,gp:0.1,cs:VSM]{{effects=*Bigby\'s grasping hand* is a superior version of the 6th-level spell *Bigby\'s forceful hand*. It creates a man-sized (5 feet) to gargantuan-sized (21 feet) hand that appears and grasps a creature designated by the caster, regardless of what the spellcaster does or how the opponent tries to escape it. The grasping hand has an Armor Class of 0, has [[@{selected|hp|max}]] HP, and vanishes when destroyed.}}{{hide1=The grasping hand can hold motionless a creature or object of up to 1,000 pounds weight, slow movement to 10 feet per round if the creature weighs between 1,000 and 4,000 pounds, or slow movement by 50% if the creature weighs up to 16,000 pounds. The hand itself inflicts no damage. The caster can order it to release a trapped opponent or can dismiss it on command.}}{{materials=A leather glove, costing 1gp and reusable 10 times}}'},
- {name:'Cacodemon',type:'muspelll7',ct:'360',charge:'uncharged',cost:'15',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nCacodemon\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Conjuration-Summoning}}Specs=[Cacodemon,MUspellL7,1H,Conjuration-Summoning]{{components=V, S, M}}{{time=Up to [[6]] hours}}{{range=[[30]]yards}}{{duration=Special}}{{aoe=Creature Summoned}}{{save=Special}}{{reference=AD\\ampD1e PHB p86}}SpellData=[w:Cacodemon,lv:7,sp:360,gp:15,cs:SM]{{effects=Summons a powerful demon of type IV, V, or VI depending on the demon\'s name being known to the Magic User.}}{{hide1=This perilous exercise in dweomercraeft summons up a powerful demon of type IV, V, or VI, depending upon the demon\'s name being known to the magic- user. Note thti this spell is not of sufficient power to bring a demon of greater power, and lesser sorts are not called as they have no known names. In any event, the spell caster must know the name of the type IV, V, or VI demon he or she is summoning. As the spetl name implies, the demon so summoned is most angry and evilly disposed. The spell caster musi be within a circle of protection {or a thaumoturgic triangle with *protection from evil*) and the demon confined within a pentagram (circled pentangle) if he or she is to avoid being slain or carried off by the summoned cocodemon. The summoned demon can be treated with as follows:\n1) The magic-user can require the monster to perform a desired course of action by force of threat and pain of a *spiritwrack* spell (q.v), allowing freedom whenever the demon performs the full extent of the service, and forcing the demon to pledge word upon It, This is exceedingly dangerous, as a minor error in such a bargain will be seized upon by the monster to reverse the desired outcome or simply to kill and devour the summoner. Furthermore, the demon will bear great enmity for the magic-user forever over such forced obedience, so the spell caster had better be most powerful and capable.\n2) By tribute of fresh human blood and the promise of 1 or more human sacrifices, the summoner can bargain with the demon for willing service. Again, the spell caster is welI advised to have ample protection and power lo defend himself or herself, as the demon might decide the offer is insufficient — or it is easier to enjoy the summoner\'s slow death — and decide not to accept the bargain as offered. Although the demon will have to abide by a pledge, as his name is known, he will have to hold only to the exact word of the arrangement, not to the spirit of the agreement. On the other hand, only highly evil magic-users are likely to attempt to strike such a bargain, and the summoned cocodemon might be favorably disposed towards such a character, especially if he or she is also chaotic.\n3} The summoned demon can be the object of a *trap the soul* spell. In this cose, the magic-user will not speak with or bargain for the demon\'s services, although the cocodemon might be eager to reach an accord with the dweomercraefter before he is forced into imprisonment. The trapping of the demon is risky only if proper precautions have not been token, for failure to confine the monster usually means only that it is able to escope to its own plane. Once trapped, the demon must remain imprisoned until the possessor of his object of confinement breaks it and frees him, ond this requires one service from the now loosed monster. If the individual(s) freeing the demon fails to demand a service when the monster asks what is required of him, the demon is under no constraint not to slay the liberator(s) on the spot, but if a service is required, the creature must first do his best to perform it and then return to the Abyss.\nThe duration of service of any demon must be limited unless the demon is willing to serve for an extended period. Any required course of action or service which effectively requires an inordinate period of time to perform, or is impossible to perform, is 50% likely to free the demon from his obligations and enable him to be unconstrained in his vengeance upon the spell caster if he or she is not thereafter continually protected, for a demon so freed can remain on the plane it was summoned to for as long as 666 days.\nThe demon summoned will be exceptionally strong, i.e. 8 hit points per hit die. Casting Time is 1 hour per type (numeric) of the demon to be summoned, If there is any interruption during this period, the spell fails. If there is an interruption while the cocodemon is summoned, it is 10% probable that it will be able to escape its boundaries and attack the magic-user, this percentage rising cumulatively each round of continued interruption.\nEach demon is entitled to a saving throw versus this summoning spell, if a score higher than the level of the magic-user summoning is rolled with 3d6 {2d10 with respect a type VI demons), that particular spell failed to bring the desired demon. When this occurs, it is certain that the named demon is imprisoned or destroyed or the name used was not perfectly correct, so the spell caster will have to call upon another name to bring forth a cocodemon.}}{{materials=5 flaming black candles (1gp each); a brazier of hot coals on which must be burned sulphur, bat hairs, lard, soot, mercuric-nitric acid crystals, mandrake root, alcohol, and a parchment with the demon\'s name inscribed within a pentangle (total value 10gp); and a dish with mammal (preferably human) blood placed inside the area where the cocodemon is to be held.}}'},
- {name:'Charm-Plants',type:'muspelll7',ct:'100',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nCharm Plants\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Enchantment-Charm}}Specs=[Charm Plants,MUspellL7,1H,Enchantment-Charm]{{components=V,S,M}}{{time=[[1]] turn}}{{range=[[30]] yards}}{{duration=Permanent}}{{aoe=[10 x 30ft](!rounds --aoe @{selected|token_id}|rectangle|feet|90|30|10|magic)}}{{save=Negates}}{{reference=PHB p183}}SpellData=[w:Charm Plants,lv:7,sp:100,gp:0,cs:VSM]{{effects=Bring under command vegetable life forms and communicate with them. }}{{hide1=These plants obey instructions to the best of their ability. The spell will charm plants in a 30-foot x 10-foot area. While the spell does not endow the vegetation with new abilities, it does enable the wizard to command the plants to use whatever they have in order to fulfill his instructions. If the plants in the area of effect do have special or unusual abilities, these are used as commanded by the wizard.\nFor example, this spell can generally duplicate the effects of the 1st-level priest spell [*entangle*](!magic --display-ability @{selected|token_id}|PR-Spells-DB|entangle), if the caster desires. The saving throw applies only to intelligent plants, and it is made with a -4 penalty to the die roll.}}{{materials=A pinch of humus, a drop of water, and a twig or leaf (no cost)}}'},
- {name:'Control-Undead',type:'muspelll7',ct:'10',charge:'uncharged',cost:'0.02',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nControl Undead\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Necromancy}}Specs=[Control Undead,MUspellL7,1H,Necromancy]{{components=V,S,M}}{{time=[[1]] round}}{{range=[[60]] feet}}!setattr --silent --charid @{selected|character_id} --spell-duration|{{duration=[[3d4+@{selected|mu-casting-level}]] rounds}}!!!{{aoe=[20HD or 6 undead](!rounds --target-save area|@{selected|token_id}|\\amp#64;{target|Which Undead do you want to control?|token_id}|Control-undead|\\amp#64;{selected|spell-duration}|-1|This undead creature is controlled by @{selected|character_name}|padlock)}}{{save=Special}}{{reference=PHB p183}}{{Use=As each undead is individually targeted, the GM will need to assess success and confirm or reject the effect, if necessary making a saving throw}}SpellData=[w:Control Undead,lv:7,sp:10,gp:0.02,cs:VSM]{{effects=Select one point within range of the spell. Those undead nearest to this point are controlled, until either [[@{selected|mu-casting-level}]] Hit Dice of undead to a max of six undead are affected. }}{{hide1=Undead with 3 Hit Dice or less are automatically controlled. Those of greater Hit Dice are allowed a saving throw vs. spell, which, if successful, negates the attempt to control that creature. Regardless of the success or failure of the saving throw, each creature required to make a check counts toward the Hit Dice limit of the spell.\nThose creatures under the control of the wizard can be commanded by the caster if they are within hearing range. There is no telepathic communication or language requirement between the caster and the controlled undead. Even if communication is impossible, the controlled undead do not attack the spellcaster. At the end of the spell, the controlled undead revert to their normal behaviors. Those not mindless will remember the control exerted by the wizard.}}{{materials=A small piece each of bone and raw meat, cost 2cp}}'},
- {name:'Delayed-Blast-Fireball',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0.02',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nDelayed Blast Fireball\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Evocation}}Specs=[Delayed Blast Fireball,MUspellL7,1H,Evocation]{{components=V,S,M}}{{time=[[7]]}}{{range=[[100+(10*@{selected|mu-casting-level})]] yards}}{{duration=Delay up to 5 rounds}}{{aoe=20ft radius}}{{save=Halves}}{{damage=[[{10, @{selected|mu-casting-level}}kl1]] + [[{10, @{selected|mu-casting-level}}kl1]]d6}}{{damagetype=Fire}}{{reference=PHB p183}}{{Use=Click [Delay Fireball](!rounds (!rounds --aoe @{selected|token_id}|circle|feet|[[300+(30*@{selected|mu-casting-level})]]|3||fire||@{selected|token_id}|caster|Delayed-blast-fireball|\\amp#63;{What delay in rounds?|5|4|3|2|1}|-1|Waiting for the firball to go off|stopwatch), specify the delay when asked, then mark the area of effect by moving the crosshair and confirming to place the *fireball seed*. Once the fireball goes off, an *explode* button and a *damage* button will be displayed to the caster to enact the effect}}SpellData=[w:Delayed Blast Fireball,lv:7,sp:7,gp:0.02,cs:VSM]{{effects=Creates a fireball, with a +1 bonus to each of its dice of damage, which releases its blast anytime from instantly to five rounds later, according to the command given by the wizard. In other respects, the spell is the same as the 3rd-level spell [*fireball*](!magic --display-ability @{selected|token_id}|MU-Spells-DB|Fireball).}}{{materials=A tiny ball of bat guano and sulphur, cost 2cp}}'},
- {name:'Drawmijs-Instant-Summons',type:'muspelll7',ct:'1',charge:'uncharged',cost:'5000',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nDrawmijs Instant Summons\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Conjuration-Summoning}}Specs=[Drawmijs-Instant-Summons,MUspellL7,1H,Conjuration-Summoning]{{components=V,S,M}}{{time=[[1]]}}{{range=infinite + special}}{{duration=Instantaneous}}{{aoe=1 small object}}{{save=None}}{{reference=PHB p183}}SpellData=[w:Drawmijs-Instant-Summons,lv:7,sp:1,gp:5000,cs:VSM]{{effects=Teleports some desired item from virtually any location directly to his hand.}}{{hide1=The single object can be no longer in any dimension than a sword, can have no more weight than a shield (about eight pounds), and must be\nnonliving.\nTo prepare this spell, the wizard must hold a gem of not less than 5,000 gp value in his hand and utter all but the final word of the conjuration. At some point in the future, he must crush the gem and utter the final word. The desired item is then transported instantly into the spellcaster\'s right or left hand, as he desires.\nThe item must have been previously touched during the initial incantation and specifically named; only that particular item is summoned by the spell. During the initial incantation, the gem becomes magically inscribed with the name of the item to be summoned. The inscription is invisible and unreadable, except by means of a read magic spell, to all but the wizard who cast the summons.\nIf the item is in the possession of another creature, the spell does not work, and the caster knows who the possessor is and roughly where he, she, or it is located when the summons is cast. Items can be summoned from other planes of existence, but only if such items are not in the possession (not necessarily the physical grasp) of another creature. For each level of experience above the 14th, the wizard is able to summon a desired item from one plane farther removed from the plane he is in at the time the spell is cast (one plane away at 14th level, two planes away at 15th, etc.). Thus, a wizard of 16th level could cast the spell even if the desired item was on the second layer of one of the Outer Planes, but at 14th level the wizard would be able to summon the item only if it were no farther than one of the Inner Planes, the Ethereal Plane, or the Astral Plane (see the Planescape Campaign Setting boxed set). Note that special wards or barriers, or factors that block the teleport or plane shift spells, may also block the operation of this spell. Objects in Leomund\'s secret chest cannot be recovered by using this spell. \nNote: If the item is wizard marked, it can be summoned from anywhere on the same plane unless special local conditions apply. Furthermore, the details of the location of the item are more specific, and the item is more easily traceable with other types of scrying magic.}}{{materials=A gem of not less than 5,000 gp value}}'},
- {name:'Duo-Dimension',type:'muspelll7',ct:'7',charge:'uncharged',cost:'750',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nDuo-Dimension\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Alteration}}Specs=[Duo-Dimension,MUspellL7,1H,Alteration]{{components=V,S,M}}{{time=[[7]]}}{{range=[[0]]}}{{duration=[[[3+@{selected|mu-casting-level}]] rounds](!rounds --target-nosave caster|@{selected|token_id}|Duo-dimension|[[3+@{selected|mu-casting-level}]]|-1|Becomes 2-dimensional like paper|three-leaves)}}{{aoe=The caster}}{{save=None}}{{reference=PHB p184}}SpellData=[w:Duo-Dimension,lv:7,sp:7,gp:750,cs:VSM]{{effects=Causes the caster to have only two dimensions, height and width, with no depth. Invisible when turned sideways.}}{{hide1=This invisibility can be detected only by means of a *true seeing* spell or similar methods. In addition, the duodimensional wizard can pass through the thinnest of spaces as long as these have the proper height--going through the space between a door and its frame is a simple matter. The wizard can perform all actions normally. He can turn and become invisible, move in this state, and appear again next round and cast a spell, disappearing on the following round.\nNote that when turned, the wizard cannot be affected by any form of attack, but when visible, he is subject to double the amount of damage normal for an attack form; for example, a dagger thrust would inflict 2d4 points of damage if it struck a duodimensional wizard. Furthermore, the wizard has a portion of his existence in the Astral Plane when the spell is in effect, and he is subject to possible notice by creatures there. If noticed, it is 25% probable that the wizard is pulled entirely into the Astral Plane by any attack from an astral creature. Such an attack (and any subsequent attack received on the Astral Plane) inflicts normal damage.\nThe material components of this spell are a flat ivory likeness of the spellcaster (which must be of finest workmanship, gold filigreed, and enameled and gem-studded at an average cost of 500 to 1,000 gp) and a strip of parchment. As the spell is uttered, the parchment is given half a twist and joined at the ends. The figurine is then passed through the parchment loop, and both disappear forever.}}{{materials=A flat ivory likeness of the spellcaster (which must be of finest workmanship, gold filigreed, and enamelled and gem-studded at an average cost of 500 to 1,000 gp) and a strip of parchment}}'},
- {name:'Finger-of-Death',type:'muspelll7',ct:'5',charge:'uncharged',cost:'500',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nFinger of Death\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Necromancy}}Specs=[Finger of Death,MUspellL7,1H,Necromancy]{{components=V,S}}{{time=[[5]]}}{{range=[[[60]] yards](!rounds --aoe @{selected|token_id}|circle|yards|0|120||dark|true)}}{{duration=Permanent}}{{aoe=1 creature}}{{save=Negates}}{{reference=PHB p184}}SpellData=[w:Finger of Death,lv:7,sp:5,gp:500,cs:VS]{{effects=Snuffs out the victim\'s life force. A creature successfully saving still receives [2d8+1](!\\amp#13;\\amp#47;r 2d8+1) points of damage. If the subject dies of damage, no internal changes occur and the victim can then be revived normally.}}{{hide1=If successful, the victim can be neither raised nor resurrected. In addition, in human subjects the spell initiates changes to the body such that after three days the caster can, by means of a special ceremony costing not less than 1,000 gp plus 500 gp per body, animate the corpse as a juju zombie under the control of the caster. The changes can be reversed before animation by a limited wish or similar spell cast directly upon the body, and a full wish restores the subject to life.\nThe caster utters the finger of death spell incantation, points his index finger at the creature to be slain, and unless the victim succeeds in a saving throw vs. spell, death occurs.}}'},
- {name:'Forcecage',type:'muspelll7',ct:'3',charge:'uncharged',cost:'1000',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nForcecage\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Evocation}}Specs=[Forcecage,MUspellL7,1H,Evocation]{{components=V,S, special}}{{time=[[3]]}}{{range=[[10*ceil(@{selected|mu-casting-level}/2)]] yards}}{{duration=[[[6+@{selected|mu-casting-level}]] turns](!rounds --target-nosave multi|@{selected|token_id}|Forcecage|[[6+@{selected|mu-casting-level}]]|-1|Containing creatures in a Forcecage|fishing-net)}}{{aoe=[20ft. cube](!rounds --aoe @{selected|token_id}|square|feet|[[60*ceil(@{selected|mu-casting-level}/2)]]|20||magic)}}{{save=None}}{{reference=PHB p184}}{{Use=Show the area of effect, and then use the *duration* button - select all the creatures in the area and press the *add status changes* button in the chat window. The GM can remove the status for creatures that make *magic resistance* and escape using the *maint menu*}}SpellData=[w:Forcecage,lv:7,sp:3,gp:1000,cs:VSM]{{effects=Bring into being a cube of force, but it is unlike the magical item of that name in one important respect: The forcecage does not have solid walls of force; it has alternating bands of force with 1/2-inch gaps between.}}{{hide1=Thus, it is truly a cage, rather than an enclosed space with solid walls. Creatures within the area of effect of the spell are caught and contained unless they are able to pass through the openings--and, of course, all spells and breath weapons can pass through the gaps in the bars of force of the forcecage.\nA creature with magic resistance has a single attempt to pass through the walls of the cage. If the resistance check is successful, the creature escapes. If it fails, the creature is caged. Note that a successful check does not destroy the cage, nor does it enable other creatures (save familiars) to flee with the escaping creature. The forcecage is also unlike the solid-walled protective device, cube of force, in that it can be gotten rid of only by means of a dispel magic spell or by the expiration of the spell.\nBy means of special preparation at the time of memorization, a *forcecage* spell can be altered to a [*forcecube*](!magic --display-ability @{selected|token_id}|MU-Spells-DB|Forcecube) spell.\nAlthough the actual casting of either application of the spell requires no material component, the study required to commit it to memory does demand that the wizard powder a diamond of at least 1,000 gp value, using the diamond dust to trace the outlines of the cage or cube he desires to create via spellcasting at some later time. Thus, in memorization, the diamond dust is employed and expended, for upon completion of study, the wizard must then toss the dust into the air and it will disappear.}}{{materials=A diamond of at least 1,000gp value}}'},
- {name:'Forcecube',type:'muspelll7',ct:'4',charge:'uncharged',cost:'1000',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nForcecube\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Evocation}}Specs=[Forcecage,MUspellL7,1H,Evocation]{{components=V,S, special}}{{time=[[4]]}}{{range=[[10*ceil(@{selected|mu-casting-level}/2)]] yards}}{{duration=[[[6+@{selected|mu-casting-level}]] turns](!rounds --target caster|@{selected|token_id}|Forcecage|[[6+@{selected|mu-casting-level}]]|-1|Containing creatures in a Forcecube|fishing-net)}}{{aoe=[10ft. cube](!rounds --aoe @{selected|token_id}|square|feet|[[60*ceil(@{selected|mu-casting-level}/2)]]|10||magic)}}{{save=None}}{{reference=PHB p184}}{{Use=Show the area of effect, and then use the *duration* button - select all the creatures in the area and press the *add status changes* button in the chat window. The GM can remove the status for creatures that make *magic resistance* and escape using the *maint menu*}}SpellData=[w:Forcecage,lv:7,sp:4,gp:1000,cs:VSM]{{effects=By means of special preparation at the time of memorization, a *forcecage* spell (see separate spell) can be altered to a *forcecube* spell. The cube created is 10 feet on a side, and the spell then resembles that of a cube of force in all respects save that of the differences between a cast spell and the magic of a device, including the methods of defeating its power.}}{{hide1=Although the actual casting of either application of the spell requires no material component, the study required to commit it to memory does demand that the wizard powder a diamond of at least 1,000 gp value, using the diamond dust to trace the outlines of the cage or cube he desires to create via spellcasting at some later time. Thus, in memorization, the diamond dust is employed and expended, for upon completion of study, the wizard must then toss the dust into the air and it will disappear.}}{{materials=A diamond of at least 1,000gp value}}'},
- {name:'Limited-Wish',type:'muspelll7',ct:'10',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nLimited Wish\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Conjuration-Summoning, Invocation-Evocation}}Specs=[Limited Wish,MUspellL7,0H,Conjuration-Summoning|Invocation-Evocation]{{components=V}}{{time=Special}}{{range=Unlimited}}{{duration=Special}}{{aoe=Special}}{{save=None}}{{reference=PHB p184}}SpellData=[w:Limited Wish,lv:7,sp:10,gp:0,cs:V]{{effects=It will fulfil literally, but only partially or for a limited duration, the utterance of the spellcaster.}}{{hide1=Thus, the actuality of the past, present, or future might be altered (but possibly only for the wizard unless the wording of the spell is most carefully stated) in some limited manner. The use of a limited wish will not substantially change major realities, nor will it bring wealth or experience merely by asking. The spell can, for example, restore some hit points (or all hit points for a limited duration) lost by the wizard. It can reduce opponent hit probabilities or damage, increase duration of some magical effect, cause a creature to be favorably disposed to the spellcaster, mimic a spell of 7th level or less, and so on (see the 9th-level wish spell). Greedy desires usually end in disaster for the wisher. Casting time is based on the time spent preparing the wording for the spell (clever players decide what they want to say before using the spell). Normally, the casting time is one round (most of it being taken up by deciding what to say). Casting this spell ages the caster one year per 100 years of regular life span.}}'},
- {name:'Mass-Invisibility',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0.05',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nMass Invisibility\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Illusion-Phantasm}}Specs=[Mass Invisibility,MUspellL7,1H,Mass Invisibility]{{components=V,S,M}}{{time=[[7]]}}{{range=[[10*@{selected|mu-casting-level}]] yards}}{{duration=Special}}{{aoe=[60 x 60 yards](!rounds --aoe @{selected|token_id}|square|yards|[[10*@{selected|mu-casting-level}]]|60|60|magic)}}{{save=None}}{{reference=PHB p184}}SpellData=[w:Mass Invisibility,lv:7,sp:7,gp:0.05,cs:VSM]{{effects=*Invisibility* spell for battlefield use. Can hide creatures in a 60-yard x 60-yard area: up to 400 man-sized creatures, 30 to 40 giants, or six to eight large dragons. The effect is mobile with the unit and is broken when the unit attacks. Individuals leaving the unit become visible. The wizard can end this spell upon command.}}{{materials=An eyelash and a bit of gum arabic, the former encased in the latter, costing 5cp}}'},
- {name:'Monster-Summoning-V',type:'muspelll7',ct:'6',charge:'uncharged',cost:'0.1',body:'\\amp{template:'+fields.spellTemplate+'}{{}}Specs=[Monster Summoning V,MUspellL7,1H,Conjuration-Summoning]{{}}SpellData=[w:Monster Summoning V,lv:7,sp:6,gp:0.1,cs:VSM]{{}}%{MU-Spells-DB|Monster-Summoning-IV}{{title=@{selected|casting-name} casts\nMonster Summoning V\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Conjuration-Summoning}}{{components=V,S,M}}{{time=[[6]]}}{{range=Special}}{{duration=[[6+@{selected|mu-casting-level}]] rounds}}{{aoe=[70yd radius](!rounds --aoe @{selected|token_id}|circle|yards|0|140||magic|true)}}{{save=None}}{{reference=PHB p185}}{{effects=[1d3](!\\amp#13;\\amp#47;r 1d3) 5th level monsters (selected by the DM from the Monster Summoning V Table in the MC) appear in the area, placed by the caster. Attack to best of their ability until caster demands attack cease, spell expires, or are slain. Disappear when slain, no morale checks. If can communicate and not attacking, caster can ask them to perform tasks.}}{{materials=A tiny bag and a small candle, worth 1sp}}'},
- {name:'Mordenkainens-Magnificent-Mansion',type:'muspelll7',ct:'7',charge:'uncharged',cost:'100',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nMordenkainens Magnificent Mansion\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Alteration, Conjuration}}Specs=[Mordenkainens-Magnificent-Mansion,MUspellL7,1H,Alteration|Conjuration]{{components=V,S,M}}{{time=[[7]]}}{{range=[[10]] yards}}{{duration=[[[@{selected|mu-casting-level}]] hours](!rounds --target-nosave caster|@{selected|token_id}|MM-mansion|[[60*@{selected|mu-casting-level}]]|-1|)}}{{aoe=[[300*@{selected|mu-casting-level}]]sq.ft. - extradimensional with a [4ft x 8ft portal](!rounds --aoe @{selected|token_id}|wall|feet|30|4|1|magic)}}{{save=None}}{{reference=PHB p185}}{{Use=Use the *duration* button to set a duration timer}}SpellData=[w:Mordenkainens Magnificent Mansion,lv:7,sp:7,gp:100,cs:VSM]{{effects=Conjures up an extradimensional dwelling, entrance to which can be gained only at a single point of space on the plane from which the spell was cast.)}}{{hide1=From the entry point, those creatures observing the area see only a faint shimmering in the air, in an area 4 feet wide and 8 feet high. The caster of the spell controls entry to the mansion, and the portal is shut and made invisible behind him when he enters. He may open it again from his own side at will. Once observers have passed beyond the entrance, they behold a magnificent foyer and numerous chambers beyond. The place is furnished and contains sufficient foodstuffs to serve a nine-course banquet to as many dozens of people as the spellcaster has levels of experience. There is a staff of near-transparent servants, liveried and obedient, to wait upon all who enter. The atmosphere is clean, fresh, and warm.\nSince the place can be entered only through its special portal, outside conditions do not affect the mansion, nor do conditions inside it pass to the plane beyond. Rest and relaxation within the place is normal, but the food is not. It seems excellent and quite filling as long as one is within the place. Once outside, however, its effects disappear immediately, and if those resting have not eaten real food within a reasonable time span, ravenous hunger strikes. Failure to eat normal food immediately results in the onset of fatigue or starvation penalties as decided by the DM.\n(It is worth mentioning that this spell has been used in conjunction with a normal portal, as well as with illusion magic. There is evidence that the design and interior of the space created can be altered to suit the caster\'s wishes.)}}{{materials=A miniature portal carved from ivory, a small piece of polished marble, and a tiny silver spoon. These cost 100gp to source, and are utterly destroyed when the spell is cast}}'},
- {name:'Mordenkainens-Sword',type:'innate-melee|muspelll7',ct:'5',charge:'uncharged',cost:'500',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nMordenkainens Sword\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Evocation}}Specs=[Mordenkainens Sword,Innate-Melee|MUspellL7,1H,Evocation]{{components=V,S,M}}ToHitData=[w:Mordenkainens Sword,+:0,ch:19,sp:5]{{time=[[7]]}}DmgData=[w:Mordenkainens Sword,+:0,sm:5d4,L:5d6]{{range=[[[30]] yards](!rounds --aoe @{selected|token_id}|circle|yards|0|60||light|true)}}WeapData=[on:\\api;rounds --target-nosave caster|@{selected|token_id}|Mordenkainens-Sword|\\lbrak;\\lbrak;@{selected|mu-casting-level}\\rbrak;\\rbrak;|-1|Magical weapon in direction facing requires concentration|all-for-one,off:\\api;rounds --removetargetstatus @{selected|token_id}|Mordenkainens-Sword]{{duration=[[@{selected|mu-casting-level}]] rounds}}{{aoe=Special}}{{save=None}}{{reference=PHB p185}}SpellData=[w:Mordenkainens Sword,lv:7,sp:7,gp:500,cs:VSM]{{effects=Brings into being a shimmering, swordlike plane of force. Can mentally wield this weapon (to the exclusion of all activities other than movement), causing it to move and strike as if it were being used by a fighter.\nSelect Mordenkainen\'s Sword as a weapon when prompted after casting the spell.}}{{hide1=The basic chance for Mordenkainen\'s sword to hit is the same as the chance for a sword wielded by a fighter of half the level of the spellcaster. For example, if cast by a 14th-level wizard, the weapon has the same hit probability as a sword wielded by a 7th level fighter.\nThe sword has no magical attack bonuses, but it can hit nearly any sort of opponent, even those normally struck only by +3 weapons or those who are astral, ethereal, or out of phase. It hits any Armor Class on a roll of 19 or 20. It inflicts 5d4 points of damage to opponents of man size or smaller, and 5d6 points of damage to opponents larger than man size. It lasts until the spell duration expires, a dispel magic is used successfully upon it, or its caster no longer desires it.}}{{materials=A miniature platinum sword with a grip and pommel of copper and zinc, which costs 500 gp to construct, and which disappears after the spell\'s completion}}'},
- {name:'Phase-Door',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nPhase Door\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Alteration}}Specs=[Phase Door,MUspellL7,0H,Alteration]{{components=V}}{{time=[[7]]}}{{range=Touch}}{{duration=[[floor(@{selected|mu-casting-level}/2)]] uses}}{{aoe=[5 x 8 x 10ft](!rounds --aoe @{selected|token_id}|rectangle|feet|0|10|5|light)}}{{save=None}}{{reference=PHB p185}}SpellData=[w:Phase Door,lv:7,sp:7,gp:0,cs:V]{{effects=The wizard attunes his body, and a section of wall is affected as if by a *passwall* spell.}}{{hide1=The phase door is invisible to all creatures save the spellcaster, and only he can use the space or passage the spell creates, disappearing when the phase door is entered, and appearing when it is exited. If the caster desires, one other creature of man size or less can be taken through the door; this counts as two uses of the door. The door does not pass light, sound, or spell effects, nor can the caster see through it without using it. Thus, the spell can provide an escape route, though certain creatures, such as phase spiders, can follow with ease. A gem of true seeing and similar magic will reveal the presence of a phase door but will not allow its use.\nThe phase door lasts for one usage for every two levels of experience of the spellcaster. It can be dispelled only by a casting of dispel magic from a higher-level wizard, or from several lower-level wizards, casting in concert, whose combined levels of experience are more than double that of the wizard who cast the spell (this is the only instance in which dispel effects can be combined).\nRumor has it that this spell has been adapted by a certain powerful wizard (or wizards) to create renewable (or permanent) portals, which may (or may not) be keyed to specific individuals (henchmen) or items (such as rings).}}'},
- {name:'Power-Word-Stun',type:'muspelll7',ct:'1',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nPower Word, Stun\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Conjuration-Summoning}}Specs=[Power Word Stun,MUspellL7,0H,Conjuration-Summoning]{{components=V}}{{time=[[1]]}}{{range=[[[5*@{selected|mu-casting-level}]] yards](!rounds --aoe @{selected|token_id}|circle|yards|0|[[10*@{selected|mu-casting-level}]]||magic)}}{{duration=Special}}{{aoe=1 creature}}{{save=None}}{{reference=PHB p185}}SpellData=[w:Power Word Stun,lv:7,sp:1,gp:0,cs:V]{{effects=Any creature of the wizard\'s choice is stunned--reeling and unable to think coherently or act--for a duration dependent on its current hit points.}}{{hide1=Of course, the wizard must be facing the creature, and the creature must be within the range of 5 yards per experience level of the caster. Creatures with 1 to 30 hit points are stunned for 4d4 rounds, those with 31 to 60 hit points are stunned for 2d4 rounds, those with 61 to 90 hit points are stunned for 1d4 rounds, and creatures with over 90 hit points are not affected. Note that if a creature is weakened so that its hit points are below its usual maximum, the current number of hit points is used.}}'},
- {name:'Prismatic-Spray',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nPrismatic Spray\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Conjuration-Summoning}}Specs=[Prismatic Spray,MUspellL7,1H,Conjuration-Summoning]{{components=V,S}}{{time=[[7]]}}{{range=[[0]]}}{{duration=Instantaneous}}{{aoe=[70 x 15ft. spray](!rounds --aoe @{selected|token_id}|cone|feet|0|70|15|magic)}}{{save=Special}}{{reference=PHB p186}}SpellData=[w:Prismatic Spray,lv:7,sp:7,gp:0,cs:VS]{{Use=All with fewer than 8HD are [blinded](!rounds --target area|@{selected|token_id}|\\amp64;{target|Which creatures are blinded?|token_id}|Blinded|\\amp#91;[2d4]\\amp#93;|-1|Blinded by the prismatic spray|bleeding-eye), apply other effects manually}}{{effects=Causes seven shimmering, multicolored rays of light to flash from his hand in a triangular spray. To determine which ray strikes a creature, roll [1d8](!\\amp#13;\\amp#47;r 1d8) and consult the table on PHB p186.}}{{hide1=It includes all colors of the visible spectrum; each ray has a different power and purpose. Any creature with fewer than 8 Hit Dice struck by a ray is blinded for 2d4 rounds, regardless of any other effect.\nAny creature in the area of effect will be touched by one or more of the rays. To determine which ray strikes a creature, roll 1d8 and consult the following table:\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;th\\ampgt;[d8 roll](!\\amp#13;\\amp#47;r 1d8)\\amplt;/th;\\ampgt;\\amplt;th\\ampgt;Color of Ray\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Order of Ray\\amplt;/th;\\ampgt;\\amplt;th;\\ampgt;Effect of Ray\\amplt;/th\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;1\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Red\\amplt;/td\\ampgt;\\amplt;td\\ampgt;1st\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Inflicts 20 points of damage, save vs. spell for half.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;2\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Orange\\amplt;/td\\ampgt;\\amplt;td\\ampgt;2nd\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Inflicts 40 points of damage, save vs. spell for half.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;3\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Yellow\\amplt;/td\\ampgt;\\amplt;td\\ampgt;3rd\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Inflicts 80 points of damage, save vs. spell for half.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;4\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Green\\amplt;/td\\ampgt;\\amplt;td\\ampgt;4th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Save vs. poison or die; survivors suffer 20 points of poison damage.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;5\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Blue\\amplt;/td\\ampgt;\\amplt;td\\ampgt;5th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Save vs. petrification or be turned to stone.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;6\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Indigo\\amplt;/td\\ampgt;\\amplt;td\\ampgt;6th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Save vs. wand or go insane.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;7\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Violet\\amplt;/td\\ampgt;\\amplt;td\\ampgt;7th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Save vs. spell or be sent to another plane.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;8\\amplt;/td\\ampgt;\\amplt;td\\ampgt; \\amplt;/td\\ampgt;\\amplt;td\\ampgt; \\amplt;/td\\ampgt;\\amplt;td\\ampgt;Struck by 2 rays, roll again twice (ignore 8s)\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;}}'},
- {name:'Reverse-Gravity',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0.1',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nReverse Gravity\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Alteration}}Specs=[Reverse Gravity,MUspellL7,1H,Alteration]{{components=V,S,M}}{{time=[[7]]}}{{range=[[5*@{selected|mu-casting-level}]] yards}}{{duration=[@{selected|mu-casting-level} rounds](!rounds --target-nosave caster|@{selected|token_id}|Reverse-gravity|@{selected|mu-casting-level}|-1|Gravity is reversed in the area of effect. How uplifting!|fluffy-wing)}}{{aoe=[30ft x 30ft.](!rounds --aoe @{selected|token_id}|square|yards|[[5*@{selected|mu-casting-level}]]|10||magic)}}{{save=None}}{{Use=Use the *duration* button to set a status timer for the spell}}{{reference=PHB p186}}SpellData=[w:Reverse Gravity,lv:7,sp:7,gp:0.1,cs:VSM]{{effects=Reverses gravity in the area of effect, causing all unattached objects and creatures within it to "fall" upward.}}{{hide1=The reverse gravity lasts as long as the caster desires or until the spell expires. If some solid object is encountered in this "fall," the object strikes it in the same manner as it would during a normal downward fall. At the end of the spell duration, the affected objects and creatures fall downward. As the spell affects an area, objects tens, hundreds, or even thousands of feet in the air above the area can be affected.}}{{materials=A loadstone and iron filings, at a total cost of 1sp}}'},
- {name:'Sequester',type:'muspelll7',ct:'7',charge:'uncharged',cost:'10',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nSequester\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Illusion/Phantasm, Abjuration}}Specs=[Sequester,MUspellL7,1H,Illusion/Phantasm|Abjuration]{{components=V,S,M}}{{time=[[7]]}}{{range=Touch}}{{duration=[[7+@{selected|mu-casting-level}]] days}}{{aoe=[@{selected|mu-casting-level} x 2ft. cubes](!rounds --aoe @{selected|token_id}|bolt|feet|0|||magic)}}{{save=Special}}{{reference=PHB p186}}SpellData=[w:Sequester,lv:7,sp:7,gp:10,cs:VSM]{{effects=Not only prevents detection and location spells from working to detect or locate the objects affected by the sequester spell, it also renders the affected object(s) invisible to any form of sight or seeing.}}{{hide1=Thus, a *sequester* spell can mask a secret door, a treasure vault, etc. Of course, the spell does not prevent the subject from being discovered through tactile means or through the use of devices (such as a robe of eyes or a gem of seeing). If cast upon a creature who is unwilling to be affected, the creature receives a normal saving throw. Living creatures (and even undead types) affected by a sequester spell become comatose and are effectively in a state of suspended animation until the spell wears off or is dispelled.}}{{materials=A basilisk eyelash (difficult to source), gum arabic, and a dram of whitewash. Total cost 10gp}}'},
- {name:'Shadow-Walk',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nShadow Walk\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Illusion, Enchantment}}Specs=[Shadow Walk,MUspellL7,1H,Illusion|Enchantment]{{components=V,S}}{{time=[[1]]}}{{range=Touch}}{{duration=[[6*@{selected|mu-casting-level}]] turns}}{{aoe=Touched Creatures at time of casting}}{{save=Special}}{{reference=PHB p186}}{{Use=Click [touched creature](!rounds --target-save area|@{selected|token_id}|\\amp#64;{target|Who is touching you?|token_id}|Shadow-walk|[[60*@{selected|mu-casting-level}]]|-1|Walking on the edge between the Prime Material plane \\amp the Demiplane of Shadow|half-haze) then target each creature in turn (start with the caster). The GM will need to confirm selections, making or asking for saving throws where required}}SpellData=[w:Shadow Walk,lv:7,sp:7,gp:0,cs:VS]{{effects=Must be in an area of heavy shadows. The caster and any creature he touches are then transported to the edge of the Prime Material Plane where it borders the Demiplane of Shadow and can move at a rate of up to 7 miles per turn, moving normally on the borders of the Demiplane of Shadow but much more rapidly relative to the Prime Material Plane. The wizard knows where he will come out on the Prime Material Plane.}}{{hide1=The *shadow walk* spell can also be used to travel to other planes that border on the Demiplane of Shadow, but this requires the potentially perilous transit of the Demiplane of Shadow to arrive at a border with another plane of reality.\nAny creatures touched by the wizard when *shadow walk* is cast also make the transition to the borders of the Demiplane of Shadow. They may opt to follow the wizard, wander off through the plane, or stumble back into the Prime Material Plane (50% chance for either result if they are lost or abandoned by the wizard). Creatures unwilling to accompany the wizard into the Demiplane of Shadow receive a saving throw, negating the effect if successful.}}'},
- {name:'Simulacrum',type:'muspelll7',ct:'7',charge:'uncharged',cost:'1000',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nSimulacrum\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Illusion-Phantasm}}Specs=[Shadow Walk,MUspellL7,1H,Illusion-Phantasm]{{components=V,S,M}}{{time=Special}}{{range=Touch}}{{duration=Permanent}}{{aoe=1 creature}}{{save=None}}{{reference=PHB p186}}{{Use=If the creature is in the *Drag \\amp Drop* creature library, GM can *Drag \\amp Drop* one and alter the HP via the dropped token}}SpellData=[w:Simulacrum,lv:7,sp:7,gp:1000,cs:VSM]{{effects=Create what appears as a duplicate of any creature, but with only 51% to 60% (50% + 1d10%) of the hit points of the real creature, there are personality differences, there are areas of knowledge that the duplicate does not have, and a *detect magic* spell will instantly reveal it as a simulacrum, as will a *true seeing* spell.}}{{hide1=At all times the simulacrum remains under the absolute command of the wizard who created it. No special telepathic link exists, so command must be exercised in some other manner. The spell creates the form of the creature, but it is only a zombielike creation. A *reincarnation* spell must be used to give the duplicate a vital force, and a *limited wish* spell must be used to empower the duplicate with 40% to 65% (35% + 5 to 30%) of the knowledge and personality of the original. The level of the simulacrum, if any, is from 20% to 50% of that of the original creature.\nThe duplicate creature is formed from ice or snow. The spell is cast over the rough form and some piece of the creature to be duplicated must be placed inside the snow or ice. Additionally, the spell requires powdered ruby.\nThe simulacrum has no ability to become more powerful; it cannot increase its level or abilities. If destroyed, it reverts to snow and melts into nothingness. Damage to the simulacrum can be repaired by a complex process requiring at least one day, 100 gp per hit point, and a fully equipped laboratory.}}{{materials=Powdered Ruby worth 1,000gp}}'},
- {name:'Spell-Turning',type:'muspelll7',ct:'7',charge:'uncharged',cost:'1',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nSpell Turning\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Abjuration}}Specs=[Spell Turning,MUspellL7,1H,Abjuration]{{components=V,S,M}}{{time=[[7]]}}{{range=[[0]]}}{{duration=Up to [[[3*@{selected|mu-casting-level}]] rounds](!rounds --target-nosave caster|@{selected|token_id}|Spell-turning|[[3*@{selected|mu-casting-level}]]|-1|Those spells tend to just bounce off you|aura)}}{{aoe=The caster}}{{save=None}}{{reference=PHB p187}}{{Use=Use the *duration* button to set a status timer on the caster. Targetable spells should be re-targeted on the reflected target. Spells with effects placed on the caster will need to be *rejected* by the GM or the *Maint Menu* used to remove the effect. Some reflected spells may need to be dealt with manually}}SpellData=[w:Spell Turning,lv:7,sp:7,gp:1,cs:VSM]{{effects=Causes spells cast against the wizard to rebound on the original caster.}}{{hide1=This includes spells cast from scrolls and innate spell-like abilities, but specifically excludes the following: area effects that are not centered directly upon the protected wizard, spell effects delivered by touch, and spell effects from devices such as wands, staves, etc. Thus, a light spell cast to blind the protected wizard could be turned back upon and possibly blind the caster, while the same spell would be unaffected if cast to light an area within which the protected wizard is standing.\nFrom seven to ten spell levels are affected by the turning. The exact number is secretly rolled by the DM; the player never knows for certain how effective the spell is.\nA spell may be only partially turned--divide the number of remaining levels that can be turned by the spell level of the incoming spell to see what fraction of the effect is turned, with the remainder affecting the caster. For example, an incoming fireball is centered on a wizard with one level of spell turning left. This means that 2/3 of the fireball affects the protected wizard, 1/3 affects the caster, and each is the center of a fireball effect. If the rolled damage is 40 points, the protected wizard receives 27 points of damage and the caster suffers 13. Both (and any creatures in the respective areas) can roll saving throws vs. spell for half damage. A partially turned hold or paralysis spell will act as a slow spell on those who are 50% or more affected.\nIf the protected wizard and a spellcasting attacker both have spell turning effects operating, a resonating field is created that has the following effects:\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;th\\ampgt;[D100 Roll](!\\amp#13;\\amp#47;gr 1d100)\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Effect\\amplt;/th\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;01-70\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Spell drains away without effect\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;71-80\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Spell affects both equally at full damage\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;81-97\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Both turning effects are rendered nonfunctional for 1d4 turns\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;98-00\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Both casters go through a rift into the Positive Energy plane\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;}}{{materials=A small silver mirror worth 20gp that can be reused 20 times}}'},
- {name:'Statue',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0.03',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nStatue\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Alteration}}Specs=[Statue,MUspellL7,1H,Alteration]{{components=V,S,M}}{{time=[[7]]}}{{range=Touch}}{{duration=[[@{selected|mu-casting-level}]] hours}}{{aoe=[Creature touched](!rounds --target-save single|@{selected|token_id}|\\amp#64;{target|Who wants a statue of themselves?|token_id}|Statue|[[60*@{selected|mu-casting-level}]]|-1|Wow - you can stand really still!|white-tower)}}{{save=Special}}{{reference=PHB p187}}SpellData=[w:Statue,lv:7,sp:7,gp:0.03,cs:VSM]{{effects=The wizard or other creature is apparently turned to solid stone, along with any garments and equipment worn or carried.}}{{hide1=The initial transformation from flesh to stone requires one full round after the spell is cast.\nDuring the transformation, there\'s an 18% chance that the targeted creature suffers a system shock failure and dies. The creature must roll percentile dice and add its Constitution score to the roll. If the total is 18 or less, the creature dies. If the total is 19 or more, the creature survives the transformation; the creature can withstand any inspection and appear to be a stone statue, although faint magic is detected from the stone if someone checks for it. Note that a creature with a Constitution of 18 or more will always survive the transformation.\nDespite being in this condition, the petrified individual can see, hear, and smell normally. Feeling is limited to those sensations that can affect the granite-hard substance of the individual\'s body--i.e., chipping is equal to a slight wound, but breaking off one of the statue\'s arms is serious damage.\nThe individual under the magic of a statue spell can return to his normal state instantly, act, and then return to the statue state, if he so desires, as long as the spell duration is in effect.}}{{materials=Lime, sand, and a drop of water stirred by an iron bar, such as a nail or spike (total cost 3cp)}}'},
- {name:'Steal-Enchantment',type:'muspelll7',ct:'600',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nSteal Enchantment\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Enchantment}}Specs=[Steal Enchantment,MUspellL7,1H,Enchantment]{{components=V,S,M}}{{time=[[1]] Hour}}{{range=Touch}}{{duration=Permanent}}{{aoe=[[1]] Item}}{{save=Negates}}{{reference=The Complete Wizard\'s Handbook}}SpellData=[w:Steal-Enchantment,lv:7,sp:600,gp:0,cs:VSM]{{effects="Steals" the enchantment from a magical item and places it within another, non-magical item (the material component).}}{{hide1=Both objects must be touched by the wizard during casting. The two items must be of the same category (blunt weapon, edged weapon, ring, amulet, shield, armor, wand, etc.).\nThe enchantment can be transferred only to a nonmagical item. Only the energy of one item can be transferred; it is not possible to combine two magical items into one item. The new item has all the properties of the original magical item (including the same number of charges, if any).\nAt the culmination of the spell, the original magical object is allowed an item saving throw vs. disintegration with all modifiers it is allowed as a magical item. Exceptionally powerful objects (such as artifacts) may be considered to automatically succeed the saving throw at the DM\'s discretion.\nIf the saving throw is successful, the magical object resists the effect and the spell ends in failure. If the roll is failed, the magical item loses all of its powers, which are transferred to the previously nonmagical object.\nEven if the magical item fails its saving throw, the spell\'s success is not guaranteed. There is a chance that the enchantment might be lost. The base chance of this occurring is 100%, modified by -5% per level of the caster. Thus, a 20th-level wizard has no chance of losing the magic. If the enchantment is lost, both items become nonmagical.}}{{materials=The nonmagical item which is to receive the enchantment. It must be of equal or greater value than the object to be drained. Paid for when bought}}'},
- {name:'Teleport-Without-Error',type:'muspelll7',ct:'1',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nTeleport Without Error\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Alteration}}Specs=[Teleport Without Error,MUspellL7,0H,Alteration]{{components=V}}{{time=[[1]]}}{{range=Touch}}{{duration=Instantaneous}}{{aoe=Special}}{{save=None}}{{reference=PHB p187}}SpellData=[w:Teleport Without Error,lv:7,sp:1,gp:0,cs:V]{{effects=Transport the caster, along with the material weight noted for a *teleport* spell (see PHB p172), to any known location in his home plane with no chance for error.}}{{hide1=The spell also enables the caster to travel to other planes of existence, but any such plane is, at best, "studied carefully." This assumes that the caster has, in fact, actually been to the plane and carefully perused an area for an eventual teleportation without error spell. The table for the teleport spell is used, with the caster\'s knowledge of the area to which transportation is desired used to determine the chance of error. (For an exception, see the 9th-level wizard spell succor.) The caster can do nothing else in the round that he appears from a teleport.}}'},
- {name:'Vanish',type:'muspelll7',ct:'2',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nVanish\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Alteration}}Specs=[Vanish,MUspellL7,0H,Alteration]{{components=V}}{{time=[[2]]}}{{range=Touch}}{{duration=Special}}{{aoe=1 object}}{{save=None}}{{reference=PHB p187}}SpellData=[w:Vanish,lv:7,sp:2,gp:0,cs:V]{{effects=Causes an object to vanish (i.e., to be teleported as if by a teleport spell) if it weighs no more than [[50*@{selected|mu-casting-level}]] pounds. The maximum volume of material that can be affected is [[3*@{selected|mu-casting-level}]] cu.ft. An object that exceeds either limitation is unaffected and the spell fails.}}{{hide1=If desired, a vanished object can be placed deep within the Ethereal Plane. In this case, the point from which the object vanished remains faintly magical until the item is retrieved. A successful dispel magic spell cast on the point will bring the vanished item back from the Ethereal Plane. Note that creatures and magical forces cannot be made to vanish.\nThere is a 1% chance that a vanished item will be disintegrated instead. There is also a 1% chance that a creature from the Ethereal Plane is able to gain access to the Prime Material Plane through the vanished item\'s connection.}}'},
- {name:'Vision',type:'muspelll7',ct:'7',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nVision\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 7 Wizard}}{{school=Divination}}Specs=[Vision,MUspellL7,0H,Divination]{{components=V,S,M}}{{time=[[7]]}}{{range=[[0]]}}{{duration=Special}}{{aoe=The caster}}{{save=None}}{{reference=PHB p187}}SpellData=[w:Vision,lv:7,sp:7,gp:0,cs:VSM]{{effects=Calls upon whatever power the caster desires aid from and can ask a question that will be answered with a vision.}}{{materials=The sacrifice of something valued by the spellcaster or by the power supplicated. The more precious the sacrifice, the better the chance of spell success. A very precious item grants a bonus of +1 to the dice roll, an extremely precious item adds +2, and a priceless item adds +3. DM will adjudicate the cost, which must be deducted from the character}}{{hide1=Two six-sided dice are rolled. If they total 2 to 6, the power is annoyed and refuses to answer the question; instead, the power causes the wizard to perform some service (by an ultrapowerful geas or quest). If the dice total 7 to 9, the power is indifferent and gives some minor vision, though it may be unrelated to the question. If the dice total 10 or better, the power grants the vision.\nThe material component of the spell is the sacrifice of something valued by the spellcaster or by the power supplicated. The more precious the sacrifice, the better the chance of spell success. A very precious item grants a bonus of +1 to the dice roll, an extremely precious item adds +2, and a priceless item adds +3.}}'},
+ db:[{name:'Antipathy-Sympathy',type:'muspelll8',ct:'600',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\n**Antipathy-Sympathy**\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Enchantment-Charm}}Specs=[Antipathy-Sympathy,MUspellL8,1H,Enchantment-Charm]{{components=V,S,M}}{{time=[[1]] hour}}{{range=[[30]] yards}}{{duration=[[2*@{selected|mu-casting-level}]] hours}}{{aoe=[10ft. cube](!rounds --aoe @{selected|token_id}|square|feet|[[120*@{selected|mu-casting-level}]]|10||magic) or one item}}{{save=Special}}{{reference=PHB p188}}{{Use=Select [Antipathy](!rounds --target-nosave caster|@{selected|token_id}|Antipathy|[[120*@{selected|mu-casting-level}]]|-1|Area of Antipathy is currently active|screaming) or [Sympathy](!rounds --target-nosave caster|@{selected|token_id}|Sympathy|[[120*@{selected|mu-casting-level}]]|-1|Area of Sympathy is currently active|grab) to set duration status timer on the caster token}}SpellData=[w:Antipathy-Sympathy,lv:8,sp:600,gp:0,cs:VSM]{{effects=This spell allows the wizard to set certain vibrations to emanate from an object or location that tend to either repel or attract a specific type of intelligent creature or characters of a particular alignment. The wizard must decide which effect is desired with regard to what creature type or alignment before beginning the spellcasting, for the components of each application differ. The spell cannot be cast upon living creatures.}}{{hide1=[Antipathy](!rounds --target caster|@{selected|token_id}|Antipathy|[[120*@{selected|mu-casting-level}]]|-1|Area of Antipathy is currently active|screaming): This spell causes the affected creature or alignment type to feel an overpowering urge to leave the area or to not touch the affected item. If a saving throw vs. spell is successful, the creature can stay in the area or touch the item, but the creature will feel very uncomfortable, and a persistent itching will cause it to suffer the loss of 1 point of Dexterity per round (for the spell\'s duration), subject to a maximum loss of 4 points and a minimum Dexterity of 3. Failure to save vs. spell forces the being to abandon the area or item, shunning it permanently and never willingly returning to it until the spell is removed or expires.\nThe material component for this application of the spell is a lump of alum soaked in vinegar.\n[Sympathy](!rounds --target caster|@{selected|token_id}|Sympathy|[[120*@{selected|mu-casting-level}]]|-1|Area of Sympathy is currently active|grab): By casting the sympathy application of the spell, the wizard can cause a particular type of creature or alignment of character to feel elated and pleased to be in an area or touching or possessing an object or item. The desire to stay in the area or touch the object is overpowering. Unless a saving throw vs. spell is successfully rolled, the creature or character will stay or refuse to release the object. If the saving throw is successful, the creature or character is released from the enchantment, but a subsequent saving throw must be made 1d6 turns later. If this saving throw fails, the affected creature will return to the area or object.\nThe material components of this spell are 1,000 gp worth of crushed pearls and a drop of honey.\nNote that the particular type of creature to be affected must be named specifically - for example, red dragons, hill giants, wererats, lammasu, catoblepas, vampires, etc. Likewise, the specific alignment must be named - for example, chaotic evil, chaotic good, lawful neutral, true neutral, etc.\nIf this spell is cast upon an area, a 10-foot cube can be enchanted for each experience level of the caster. If an object or item is enchanted, only that single thing can be enchanted; affected creatures or characters save vs. spell with a -2 penalty.}}{{materials=\n*Antipathy*: a lump of alum soaked in vinegar.\n*Sympathy*: 1,000 gp worth of crushed pearls and a drop of honey}}'},
+ {name:'Bigbys-Clenched-Fist',type:'magic|muspelll8',ct:'600',charge:'uncharged',cost:'1',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nBigby\'s Clenched Fist\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Evocation}}Specs=[Bigbys Clenched Fist,Magic|MUspellL8,1H|2H,Evocation],[Bigbys Clenched Fist,Magic|MUspellL8,1H|2H,Evocation],[Bigbys Clenched Fist,Magic|MUspellL8,1H|2H,Evocation],[Bigbys Clenched Fist,Magic|MUspellL8,1H|2H,Evocation],[Bigbys Clenched Fist,Magic|MUspellL8,1H|2H,Evocation]{{components=V,S,M}}ToHitData=[w:Fist Roll d20,cmd:!\\amp#13;\\amp#47;r 1d20],[w:Fist 1-12,cmd:!\\amp#13;\\amp#47;r 1d6],[w:Fist 13-16,cmd:!\\amp#13;\\amp#47;r 2d6],[w:Fist 17-19,cmd:!rounds --target single|\\amp#64;{selected|token_id}|\\amp#64;{target|Who did you punch?|token_id}|stunned|1|-1|Stunned for 1 round|back-pain\\amp#13;\\amp#47;r 3d6],[w:Fist 20,cmd:!rounds --target single|\\amp#64;{selected|token_id}|\\amp#64;{target|Who did you punch?|token_id}|stunned|3|-1|Stunned for 3 rounds|back-pain\\amp#13;\\amp#47;r 4d6]{{time=[[8]]}}{{range=[[[5*@{selected|mu-casting-level}]] yards](!rounds --aoe @{selected|token_id}|circle|yards|0|[[10*@{selected|mu-casting-level}]]||magic|true)}}{{duration=[[@{selected|mu-casting-level}]] rounds}}{{aoe=Special}}{{save=None}}{{reference=PHB p188}}SpellData=[w:Bigbys Clenched Fist,lv:8,sp:600,gp:1,cs:VSM,on:!rounds --target caster|@{selected|token_id}|bigbys-fist|@{selected|mu-casting-level}|-1|Wielding Bigby\'s Clenched Fist|fist,off:!rounds --removetargetstatus @{selected|token_id}|bigbys-fist|silent]{{Use=After casting this spell, use the *Change Weapon* dialog (which should be automatically displayed) to take *Bigby\'s Clenched Fist* in-hand as a weapon, and then attack with it each round until it expires}}{{effects=Brings forth a huge, disembodied hand that is balled into a fist. Spellcaster can cause it to strike one opponent each round, which always hits and with the following effects:\n\\amplt;table\\ampgt;\\amplt;tr\\ampgt;\\amplt;th\\ampgt;D20 Roll\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Result\\amplt;/th\\ampgt;\\amplt;/tr\\ampgt;\n\\amplt;tr\\ampgt;\\amplt;td\\ampgt;1-12\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Glancing blow - 1d6\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;13-16\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Solid punch - 2d6\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;17-19\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Hard punch - 3d6 \\amp stunned for 1 round\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;20\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Crushing blow\\amp#42; - 4d6 \\amp stunned for 3 rounds\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\n\\amp#42;The wizard adds +4 to the die rolls of subsequent attacks if the opponent is stunned, as the opponent is not capable of dodging or defending against the attack effectively.\nThe fist has AC 0, and [[@{selected|hp|max}]]HP}}{{materials=A leather glove and a small device (similar to brass knuckles) consisting of four rings joined so as to form a slightly curved line, with an "I" upon which the bottoms of the rings rest. The device must be fashioned of an alloy of copper and zinc: total cost 20gp to procure, but can be used up to 20 times}}'},
+ {name:'Binding',type:'muspelll8',ct:'100',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nBinding\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Enchantment, Evocation}}Specs=[Binding,MUspellL8,1H,Enchantment|Evocation]{{components=V,S,M}}{{time=Special}}{{range=[[[10]] yards](!rounds --aoe @{selected|token_id}|circle|yards|0|20|0|magic|true)}}{{duration=Special}}{{aoe=1 creature}}{{save=Special}}{{reference=PHB p188}}SpellData=[w:Binding,lv:8,sp:100,gp:0,cs:VSM]{{effects=Creates a magical restraint to hold a creature, usually from another plane of existence.}}{{hide1=Extraplanar creatures must be confined by a circular diagram; other creatures can be physically confined. The duration of the spell depends upon the form of the binding and the level of the caster(s), as well as the length of time the spell is actually uttered. The components vary according to the form of the spell, but they include a continuous chanting utterance read from the scroll or book page giving the spell; gestures appropriate to the form of binding; and materials such as miniature chains of special metal (silver for lycanthropes, etc.), soporific herbs of the rarest sort, a corundum or diamond gem of great size (1,000 gp value per Hit Die of the subject creature), and a vellum depiction or carved statuette of the subject to be captured.\nMagic resistance applies unless the subject\'s true name is used. A saving throw is not applicable as long as the experience level of the caster is at least twice as great as the Hit Dice of the subject. The caster\'s level can be augmented by one-third of the levels of each assisting wizard of 9th level or higher, and by one level for each assistant of 4th through 8th level. No more than six other wizards can assist with this spell. If the caster\'s level is less than twice the Hit Dice of the subject, the subject gains a saving throw vs. spell, modified by the form of binding being attempted. The various forms of binding are:\n***Chaining:*** The subject is confined by restraints that generate an antipathy spell affecting all creatures who approach the subject, except the caster. Duration is as long as one year per level of the caster(s). The subject of this form of binding (as well as in the slumber and bound slumber versions) remains within the restraining barrier.\n***Slumber:*** Brings a comatose sleep upon the subject for a duration of up to one year per level of the caster(s).\n***Bound Slumber:*** A combination of chaining and slumber that lasts for up to one month per level of the caster(s).\n***Hedged Prison:*** The subject is transported to or otherwise brought within a confined area from which it cannot wander by any means until freed. The spell remains until the\nmagical hedge is somehow broken.\n***Metamorphosis:*** Causes the subject to change to some noncorporeal form, save for its head or face. The binding is permanent until some prescribed act frees the subject.\n***Minimus Containment:*** The subject is shrunken to a height of 1 inch or even less and held within the hedged prison of some gem or similar object. The subject of a minimus containment, metamorphosis, or hedged prison radiates a very faint aura of magic.\nThe subject of the *chaining* form of the spell receives a saving throw with no modifications. However, *slumber* allows the subject a +1 bonus, *bound slumber* a +2 bonus, *hedged prison* a +3 bonus, *metamorphosis* a +4 bonus, and *minimus containment* a +5 bonus to the saving throw. If the subject is magically weakened, the DM can assign a -1, -2, or even -4 penalty to the saving throw. A successful saving throw enables the subject to burst its bonds and do as it pleases.\nA binding spell can be renewed in the case of the first three forms of the spell, for the subject does not have the opportunity to break the bonds. (If anything has caused a weakening of a chaining or slumber version, such as attempts to contact the subject or magically touch it, a normal saving throw applies to the renewal of the spell.) Otherwise, after one year, and each year thereafter, the subject gains a normal saving throw vs. the spell. Whenever it is successful, the binding spell is broken and the creature is free.}}{{materials=Various materials are required for various forms. DM to determine cost. See PHB p188}}'},
+ {name:'Clone',type:'muspelll8',ct:'100',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nClone\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Necromancy}}Specs=[Clone,MUspellL8,1H,Necromancy]{{components=V,S,M}}{{time=[[1]] turn}}{{range=Touch}}{{duration=Permanent}}{{aoe=1 clone}}{{save=None}}{{reference=PHB p189}}{{Use=Drag a copy token on to the playing surface from the journal, then the GM should make the token a "mob" token by deleting the token bar links to the character (leave the token itself linked to the character sheet)}}SpellData=[w:Clone,lv:8,sp:100,gp:0,cs:VSM]{{effects=Creates a duplicate of a human, demihuman, or humanoid creature.}}{{hide1=This clone is in most respects the duplicate of the individual, complete to the level of experience, memories, etc. However, the duplicate really is the person, so if the original and a duplicate exist at the same time, each knows of the other\'s existence; the original erson and the clone will each desire to do away with the other, for such an alter-ego is unbearable to both. If one cannot destroy the other, one will go insane and destroy itself (90% likely to be the clone), or possibly both will become mad and destroy themselves (2% chance). These events nearly always occur within one week of the dual existence.\nNote that the clone is the person as he existed at the time at which the flesh was taken for the spell component, and all subsequent knowledge, experience, etc., is totally unknown to the clone. The clone is a physical duplicate, and possessions of the original are another matter entirely. A clone takes 2d4 months to grow, and only after that time is dual existence established. Furthermore, the clone has one less Constitution point than the body it was cloned from; the cloning fails if the clone would have a Constitution of 0.\nThe DM may, in addition, add other stipulations to the success of a cloning effort, requiring that some trace of life must remain in the flesh sample, that some means of storing and preserving the sample must be devised and maintained, etc.}}{{materials=A small piece of the flesh from the person to be duplicated. The DM may add other stipulations to the success of a cloning effort, e.g. some trace of life must remain in the flesh sample, some means of storing and preserving the sample, etc}}'},
+ {name:'Demand',type:'muspelll8',ct:'100',charge:'uncharged',cost:'1',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nDemand\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Evocation, Enchantment-Charm}}Specs=[Demand,MUspellL8,1H,Evocation|Enchantment-Charm]{{components=V,S,M}}{{time=[[1]] turn}}{{range=Unlimited}}{{duration=Special}}{{aoe=1 creature}}{{save=Special}}{{reference=PHB p189}}SpellData=[w:Demand,lv:8,sp:100,gp:1,cs:VSM]{{effects=Allows a brief contact with a far distant creature. The message can also contain a *suggestion*}}{{hide1=This spell is very much like the 5th-level wizard spell sending, allowing a brief contact with a far distant creature. However, with this spell the message can also contain a [*suggestion*](!magic --display-ability @{selected|token_id}|MU-Spells-DB|Suggestion) (see the 3rd-level wizard spell suggestion), which the subject will do its best to carry out if it fails its saving throw vs. spell, made with a -2 penalty. Of course, if the message is impossible or meaningless according to the circumstances that exist for the subject at the time the demand comes, the message is understood but no saving throw is necessary and the suggestion is ineffective.\nThe caster must be familiar with the creature contacted and must know its name and appearance well. If the creature in question is not in the same plane of existence as the spellcaster, there is a base 5% chance that the demand does not arrive. Local conditions on other planes may worsen this chance considerably at the option of the DM. The demand, if received, will be understood even if the creature has an Intelligence ability score as low as 1 (animal Intelligence). Creatures of demigod status or higher can choose to come or not, as they please.\nThe demand message to the creature must be 25 words or less, including the suggestion. The creature can also give a short reply immediately.}}{{materials=A pair of cylinders, each open at one end, connected by a thin piece of copper wire costing 20gp to procure, reusable 20 times, and some small part of the subject creature--a hair, a bit of nail, etc}}'},
+ {name:'Glassteel',type:'muspelll8',ct:'8',charge:'uncharged',cost:'0.5',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nGlassteel\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Alteration}}Specs=[Glasteel,MUspellL8,1H,Alteration]{{components=V,S,M}}{{time=[[8]]}}{{range=Touch}}{{duration=Permanent}}{{aoe=[Object touched](!rounds --aoe @{selected|token_id}||feet|0)}}{{save=None}}{{reference=PHB p189}}SpellData=[w:Glassteel,lv:8,sp:8,gp:0.5,cs:VSM]{{effects=Turns normal, nonmagical crystal or glass into a transparent substance that has the tensile strength and unbreakability of actual steel. Only a relatively small volume of material can be affected (a maximum weight of [[10*@{selected|mu-casting-level}]] pounds), and it must form one whole object. AC of the substance is 1}}{{materials=A small piece of glass and a small piece of steel, total cost 5sp}}'},
+ {name:'Incendiary-Cloud',type:'muspelll8',ct:'2',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nIncendiary Cloud\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Alteration, Evocation}}Specs=[Incendiary Cloud,MUspellL8,1H,Alteration|Evocation]{{components=V,S,M}}{{time=[[2]]}}{{range=[[30]] yards}}{{duration=1d6+4 rounds}}{{aoe=[Special](!rounds --aoe @{selected|token_id}||feet|90|||fire --target-nosave caster|@{selected|token_id}|Incendiary-cloud|\\amp#91;[4+1d6]\\amp#93;|-1|An incendiary cloud forms|rolling-bomb)}}{{save=Halves}}{{reference=PHB p189}}{{Use=Select the *area of effect* and a status round counter will be set on the caster. Damage buttons will appear for the caster each round}}SpellData=[w:Incendiary Cloud,lv:8,sp:2,gp:0,cs:VSM]{{effects=Minimum dimensions are a cloud 10 feet tall, 20 feet wide, and 20 feet long. This dense vapor cloud billows forth, and 3rd round begins to flame, for @{selected|mu-casting-level}d2 HP. 4th round @{selected|mu-casting-level}d4 HP, and 5th round @{selected|mu-casting-level}d2 HP as its flames burn out, and in successive rounds is simply harmless smoke that obscures vision within its confines. Creatures within the cloud need to make only one saving throw if it is successful, but if they fail the first saving throw, they roll again on the fourth and fifth rounds (if necessary) to attempt to reduce the damage sustained by one-half.}}{{materials=An available fire source (just as with a pyrotechnics spell), scrapings from beneath a dung pile, and a pinch of dust (no cost)}}'},
+ {name:'Mass-Charm',type:'muspelll8',ct:'8',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nMass Charm\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Enchantment-Charm}}Specs=[Mass Charm,MUspellL8,0H,Enchantment-Charm]{{components=V}}{{time=[[8]]}}{{range=[[5*@{selected|mu-casting-level}]] yards}}{{duration=Special}}{{aoe=[30ft cube](!rounds --aoe @{selected|token_id}|square|feet|[[15*@{selected|mu-casting-level}]]|30||magic)}}{{save=Negates}}{{reference=PHB p190}}{{Use=[Charm Them](!rounds --target multi|@{selected|token_id}|Mass-Charm|99|0|Wow, @{selected|character_name} is so great! There\'s something about them...|chained-heart|svspe\\clon;-2)}}SpellData=[w:Mass Charm,lv:8,sp:8,gp:0,cs:V]{{effects=Affects large numbers of either persons or monsters just as a *charm person* or *charm monster* spell.}}{{hide1=The mass charm spell, however, affects a number of creatures whose combined levels of experience or Hit Dice does not exceed twice the level of experience of the spellcaster. All affected creatures must be within the spell range and within a 30-foot cube. Note that the creatures\' saving throws are unaffected by the number of recipients (see the *charm person* and *charm monster* spells), but all target creatures are subject to a penalty of -2 on their saving throws because of the efficiency and power of this spell. The Wisdom bonus against charm spells does apply.}}'},
+ {name:'Maze',type:'muspelll8',ct:'3',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nMaze\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Conjuration-Summoning}}Specs=[Maze,MUspellL8,1H,Conjuration-Summoning]{{components=V,S}}{{time=[[3]]}}{{range=[[[5*@{selected|mu-casting-level}]] yards](!rounds --aoe @{selected|token_id}|circle|yards|0|[[10*@{selected|mu-casting-level}]]||magic|true)}}{{duration=[Special](!rounds --target single|@{selected|token_id}|\\amp#64;{target|Who do you want to a-Maze?|token_id}|Maze|\\amp#91;[\\amp#63;{What intelligence is target creature?|under 3,2d4|3-5,1d4|6-8,5d4|9-11,4d4|12-14,3d4|15-17,2d4|18+,1d4}]\\amp#93;|-1|You are in a labyrinth of endless corridors... You are in a labyrinth of tiny, twisting corridors... You are in...|screaming)}}{{aoe=1 creature}}{{save=None}}{{reference=PHB p190}}SpellData=[w:Maze,lv:8,sp:3,gp:0,cs:VS]{{effects=An extradimensional space is brought into being. The subject vanishes into the shifting labyrinth of force planes for a period of time that is dependent upon its Intelligence.}}{{hide1=(Note: Minotaurs are not affected by this spell.)\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;th\\ampgt;Intelligence of Mazed Creature\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Time Trapped in Maze\\amplt;/th\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;under 3\\amplt;/td\\ampgt;\\amplt;td\\ampgt;[2d4](!\\amp#13;\\amp#47;r 2d4) turns\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;3-5\\amplt;/td\\ampgt;\\amplt;td\\ampgt;[1d4](!\\amp#13;\\amp#47;r 1d4) turns\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;6-8\\amplt;/td\\ampgt;\\amplt;td\\ampgt;[5d4](!\\amp#13;\\amp#47;r 5d4) rounds\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;9-11\\amplt;/td\\ampgt;\\amplt;td\\ampgt;[4d4](!\\amp#13;\\amp#47;r 4d4) rounds\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;12-14\\amplt;/td\\ampgt;\\amplt;td\\ampgt;[3d4](!\\amp#13;\\amp#47;r 3d4) rounds\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;15-17\\amplt;/td\\ampgt;\\amplt;td\\ampgt;[2d4](!\\amp#13;\\amp#47;r 2d4) rounds\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;18+\\amplt;/td\\ampgt;\\amplt;td\\ampgt;[1d4](!\\amp#13;\\amp#47;r 1d4) rounds\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\nNote that teleport and dimension door spells will not help a character escape a maze spell, although a plane shifting spell will.}}'},
+ {name:'Mind-Blank',type:'muspelll8',ct:'1',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nMind Blank\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Abjuration}}Specs=[Mind Blank,MUspellL8,1H,Abjuration]{{components=V,S}}{{time=[[1]]}}{{range=[30 yards](!rounds --aoe @{selected|token_id}|circle|yards|0|60||magic|true)}}{{duration=[1 day](!rounds --target-nosave single|@{selected|token_id}|\\amp#64;{target|Who\'s mind is blank to others?|token_id}|Mind-blank|99|0|You are protected from all forms of mind reading|white-tower)}}{{aoe=1 creature}}{{save=None}}{{reference=PHB p190}}SpellData=[w:Mind Blank,lv:8,sp:1,gp:0,cs:VS]{{effects=The creature is totally protected from all devices and spells that detect, influence, or read emotions or thoughts.}}{{hide1=This protects against *augury, charm, command, confusion, divination, empathy* (all forms), *ESP, fear, feeblemind, mass suggestion, phantasmal killer, possession, rulership, soul trapping, suggestion,* and *telepathy*. Cloaking protection also extends to the prevention of discovery or information gathering by *crystal balls* or other scrying devices, *clairaudience, clairvoyance, communing, contacting other planes,* or wish-related methods (*wish* or *limited wish*). Of course, exceedingly powerful deities can penetrate the spell\'s barrier.}}'},
+ {name:'Monster-Summoning-VI',type:'muspelll8',ct:'8',charge:'uncharged',cost:'0.1',body:'\\amp{template:'+fields.spellTemplate+'}{{}}Specs=[Monster Summoning VI,MUspellL8,1H,Conjuration-Summoning]{{}}SpellData=[w:Monster Summoning VI,lv:8,sp:8,gp:0.1,cs:VSM]{{}}%{MU-Spells-DB|Monster-Summoning-V}{{title=@{selected|casting-name} casts\nMonster Summoning VI\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Conjuration-Summoning}}{{components=V,S,M}}{{time=[[8]]}}{{range=Special}}{{duration=[[7+@{selected|mu-casting-level}]] rounds}}{{aoe=[80yd radius](!rounds --aoe @{selected|token_id}|circle|yards|0|160||magic|true)}}{{save=None}}{{reference=PHB p190}}{{effects=[1d3](!\\amp#13;\\amp#47;r 1d3) 6th level monsters (selected by the DM from the Monster Summoning VI Table in the MC) appear in the area, placed by the caster. Attack to best of their ability until caster demands attack cease, spell expires, or are slain. Disappear when slain, no morale checks. If can communicate and not attacking, caster can ask them to perform tasks.}}{{materials=A tiny bag and a small candle, costing 1sp}}'},
+ {name:'Otilukes-Telekinetic-Sphere',type:'muspelll8',ct:'4',charge:'uncharged',cost:'500',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nOtiluke\'s Telekinetic Sphere\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Evocation, Alteration}}Specs=[Otilukes Telekinetic Sphere,MUspellL8,1H,Evocation|Alteration]{{components=V,S,M}}{{time=[[4]]}}{{range=20 yards}}{{duration=[[2*@{selected|mu-casting-level}]] rounds}}{{aoe=[@{selected|mu-casting-level}ft. diameter sphere](!rounds --aoe @{selected|token_id}|circle|feet|60|@{selected|mu-casting-level}||light)}}{{save=Negates}}{{reference=PHB p190}}SpellData=[w:Otilukes Telekinetic Sphere,lv:8,sp:4,gp:500,cs:VSM]{{effects=Exactly the same as the 4th-level wizard spell Otiluke\'s resilient sphere, with the addition that the creatures or objects inside the globe are nearly weightless--anything contained within it weighs only 1/16 its normal weight.}}{{hide1=Any subject weighing up to 5,000 pounds can be telekinetically lifted in the sphere by the caster. Range of control extends to a maximum distance of 10 yards per level after the sphere has actually succeeded in encapsulating a subject or subjects. Note that even if more than 5,000 pounds of weight is englobed, the perceived weight is only 1/16 of the actual weight, so the orb can be rolled without exceptional effort. Because of the reduced weight, rapid motion or falling within the field of the sphere is relatively harmless to the object therein, although it can be disastrous should the globe disappear when the subject inside is high above a hard surface. The caster can dismiss the effect with a word.}}{{materials=A hemispherical piece of diamond costing 500gp, a matching piece of gum arabic, and a pair of small bar magnets, which are consumed by the spell}}'},
+ {name:'Ottos-Irresistible-Dance',type:'muspelll8',ct:'5',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nOtto\'s Irresistible Dance\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Enchantment-Charm}}Specs=[Ottos Irresistible Dance,MUspellL8,1H,Enchantment-Charm]{{components=V}}{{time=[[5]]}}{{range=[Touch](~selected|To-Hit-Spell)}}!setattr --silent --charid @{selected|character_id} --spell-duration|{{duration=[[1d4+1]] rounds}}!!!{{aoe=Creature Touched}}{{save=None}}{{reference=PHB p190}}SpellData=[w:Ottos Irresistible Dance,lv:8,sp:5,gp:0,cs:V]{{effects=Causes the recipient to begin dancing, complete with feet shuffling and tapping.}}{{hide1=This dance makes it impossible for the victim to do anything other than caper and prance; this cavorting worsens the Armor Class of the creature by -4, makes saving throws impossible except on a roll of 20, and negates any consideration of a shield. Note that the creature must be touched, as if melee combat were taking place and the spellcaster were striking to do damage.}}\n!magic --touch @{selected|token_id}|Ottos-irresistable-dance|\\amp#64;{selected|spell-duration}|-1|Dancing the night away...|trophy'},
+ {name:'Permanency',type:'muspelll8',ct:'20',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nPermanency\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Alteration}}Specs=[Permanency,MUspellL8,1H,Alteration]{{components=V,S}}{{time=[[2]] rounds}}{{range=Special}}{{duration=Permanent}}{{aoe=Special}}{{save=None}}{{reference=PHB p190}}SpellData=[w:Permanency,lv:8,sp:20,gp:0,cs:VS]{{effects=This spell affects the duration of certain other spells, making the duration permanent.}}{{hide1=The personal spells upon which a permanency is known to be effective are as follows:\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*comprehend languages*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*protection from evil*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*detect evil*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*protection from normal missiles*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*detect invisibility*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*read magic*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*detect magic*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*tongues*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*infravision*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*unseen servant*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*protection from cantrips*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\nThe wizard casts the desired spell and then follows it with the permanency spell. Each permanency spell lowers the wizard\'s Constitution by 1 point. The wizard cannot cast these spells upon other creatures. This application of permanency can be dispelled only by a wizard of greater level than the spellcaster was when he cast the spell.\nIn addition to personal use, the permanency spell can be used to make the following object/creature or area-effect spells permanent:\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*enlarge*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*prismatic sphere*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*fear*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*stinking cloud*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*gust of wind*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*wall of fire*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*invisibility*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*wall of force*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*magic mouth*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*web*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\nAdditionally, the following spells can be cast upon objects or areas only and rendered permanent:\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*alarm*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*wall of fire*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*audible glamer*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*distance distortion*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*dancing lights*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*teleport*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*solid fog*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\nThese applications to other spells allow it to be cast simultaneously with any of the latter when no living creature is the target, but the entire spell complex then can be dispelled normally, and thus negated.\nThe permanency spell is also used in the fabrication of magical items (see the 6th-level spell enchant an item). At the DM\'s option, permanency might become unstable or fail after a long period of at least 1,000 years. Unstable effects might operate intermittently or fail altogether.\nThe DM may allow other selected spells to be made permanent. Researching this possible application of a spell costs as much time and money as independently researching the selected spell. If the DM has already determined that the application is not possible, the research automatically fails. Note that the wizard never learns what is possible except by the success or failure of his research.}}'},
+ {name:'Polymorph-Any-Object',type:'muspelll8',ct:'10',charge:'uncharged',cost:'5',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nPolymorph Any Object\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Alteration}}Specs=[Polymorph Any Object,MUspellL8,1H,Alteration]{{components=V,S,M}}{{time=[[1]] round}}{{range=[[[5*@{selected|mu-casting-level}]] yards](!rounds --aoe @{selected|token_id}|circle|yards|0|[[5*@{selected|mu-casting-level}]]||magic)}}{{duration=Variable}}{{aoe=Special}}{{save=Special}}{{reference=PHB p191}}SpellData=[w:Polymorph Any Object,lv:8,sp:10,gp:5,cs:VSM]{{effects=Changes one object or creature into another.}}{{hide1=When used as a *polymorph other* or *stone to flesh* spell, simply treat the spell as a more powerful version, with saving throws made with -4 penalties to the die roll. When it is cast in order to change other objects, the duration of the spell depends on how radical a change is made from the original state to its enchanted state, as well as how different it is in size. The DM determines the changes by using the following guidelines:\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Kingdom*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Animal, vegetable, mineral\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Class*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Mammals, bipeds, fungi, metals, etc.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Relationship*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Twig is to tree, sand is to beach, etc.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Size*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Smaller, equal, larger\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Shape*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Comparative resemblance of the original to the polymorphed state\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Intelligence*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Particularly with regard to a change in which the end product is more intelligent\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\nA change in *kingdom* makes the spell work for hours (if removed by one kingdom) or turns (if removed by two). Other changes likewise affect spell duration. Thus, changing a lion to an androsphinx would be permanent, but turning a turnip to a purple worm would be a change with a duration measured in hours. Turning a tusk into an elephant would be permanent, but turning a twig into a sword would be a change with a duration of several turns.\nAll polymorphed objects radiate a strong magic, and if a *dispel magic* spell is successfully cast upon them, they return to their natural form. Note that a *stone to flesh* spell or its reverse will affect objects under this spell. As with other polymorph spells, damage sustained in the new form can result in the injury or death of the polymorphed creature.\nFor example, it is possible to polymorph a creature into rock and grind it to dust, causing damage, perhaps even death. If the creature was changed to dust to start with, more creative methods to damage it would be needed; perhaps the wizard could use a *gust of wind* spell to scatter the dust far and wide. In general, damage occurs when the new form is altered through physical force, although the DM will have to adjudicate many of these situations.\nThe system shock roll must be applied to living creatures, as must the restrictions noted regarding the *polymorph other* and *stone to flesh* spells. Also note that a polymorph effect often detracts from an item\'s or creature\'s powers, but does not add new powers, except possibly movement capabilities not present in the old form. Thus, a vorpal sword polymorphed into a dagger would not retain vorpal capability. Likewise, valueless items cannot be made into permanent valuable items.}}'},
+ {name:'Power-Word-Blind',type:'muspelll8',ct:'1',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nPower Word, Blind\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Conjuration-Summoning}}Specs=[Power Word Blind,MUspellL8,0H,Conjuration-Summoning]{{components=V}}{{time=[[1]]}}{{range=[[5*@{selected|mu-casting-level}]] yards}}{{duration=[Special](!rounds --target-nosave multi|@{selected|token_id}|Blindness|\\amp#63;{How many Hit Dice?|25 or less,99|26-50,[[10*[[1d4+1]]]]|51-100,[[1d4+1]]}|-1|You are blinded, -4 attk \\amp save|bleeding-eye)}}{{aoe=[15ft radius](!rounds --aoe @{selected|token_id}|circle|yards|[[5*@{selected|mu-casting-level}]]|10||dark)}}{{save=None}}{{reference=PHB p191}}SpellData=[w:Power Word Blind,lv:8,sp:1,gp:0,cs:V]{{effects=One or more creatures within the area of effect become sightless.}}{{hide1=The spellcaster selects one creature as the target center, and the effect spreads outward from the center, affecting creatures with the lowest hit point totals first; the spell can also be focused to affect only an individual creature. The spell affects up to 100 hit points of creatures; creatures who currently have 100 or more hit points are not affected and do not count against the number of creatures affected. The duration of the spell depends upon how many hit points are affected. If 25 or fewer hit points are affected, the blindness is permanent until cured. If 26 to 50 hit points are affected, the blindness lasts for 1d4+1 turns. If 51 to 100 hit points are affected, the spell lasts for 1d4+1 rounds. An individual creature cannot be partially affected. If all of its current hit points are affected, it is blinded; otherwise, it is not. Blindness can be removed by a cure blindness or dispel magic spell.}}'},
+ {name:'Prismatic-Wall',type:'muspelll8',ct:'7',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nPrismatic Wall\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Conjuration-Summoning}}Specs=[Prismatic Wall,MUspellL8,1H,Conjuration-Summoning]{{components=V,S}}{{time=[[7]]}}{{range=[[10]] yards}}{{duration=[[@{selected|mu-casting-level}]] turns}}{{aoe=[[2*@{selected|mu-casting-level}]]ft high x [[[4*@{selected|mu-casting-level}]]ft long](!rounds --aoe @{selected|token_id}|wall|feet|30|[[4*@{selected|mu-casting-level}]]|2|magic)}}{{save=Special}}{{damage=[Blind creatures](!rounds --target area|@{selected|token_id}|\\amp#64;{target|Who is too close?|token_id}|Prismatic-Wall-Blind|\\amp#91;[2d4]\\amp#93;|-1|Blinded by the colors, -4 attk \\amp AC|bleeding-eye) with less than 8HD in 20ft}}{{reference=PHB p191}}SpellData=[w:Prismatic Wall,lv:8,sp:7,gp:0,cs:VS]{{effects=Conjure a vertical, opaque wall--a shimmering, multicolored plane of light that protects him from all forms of attack.}}{{hide1=The wall flashes with all colors of the visible spectrum, seven of which have a distinct power and purpose. The wall is immobile, and the spellcaster can pass through the wall without harm. However, any creature with fewer than 8 Hit Dice that is within 20 feet of the wall and does not shield its vision is blinded for 2d4 rounds by the colors.\nEach color in the wall has a special effect. Each color can also be negated by a specific magical effect, but the colors must be negated in the precise order of the spectrum. The accompanying table shows the seven colors of the wall, the order in which they appear, their effects on creatures trying to attack the spellcaster, and the magic needed to negate each color.\nThe wall\'s maximum proportions are 4 feet wide per level of experience of the caster and 2 feet high per level of experience. A prismatic wall spell cast to materialize in a space occupied by a creature is disrupted and the spell is wasted.\n\\amplt;table style="border:1px solid black" width="100%"\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;th colspan="4"\\ampgt;Prismatic Wall Effects\\amplt;/th\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;th\\ampgt;Color\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Order\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Effect of Color\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Spell Negated By\\amplt;/th\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;td\\ampgt;Red\\amplt;/td\\ampgt;\\amplt;td\\ampgt;1st\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Stops nonmagical missiles--inflicts 20 points of damage, save for half\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*cone of cold*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;td\\ampgt;Orange\\amplt;/td\\ampgt;\\amplt;td\\ampgt;2nd\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Stops magical missiles--inflicts 40 points of damage, save for half\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*gust of wind*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;td\\ampgt;Yellow\\amplt;/td\\ampgt;\\amplt;td\\ampgt;3rd\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Stops poisons, gases, and petrification--inflicts 80 pointsof damage, save for half \\amplt;/td\\ampgt;\\amplt;td\\ampgt;*disintegrate*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;td\\ampgt;Green\\amplt;/td\\ampgt;\\amplt;td\\ampgt;4th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Stops breath weapons--save vs. poison or die;\nsurvivors suffer 20 points of damage \\amplt;/td\\ampgt;\\amplt;td\\ampgt;*passwall*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;td\\ampgt;Blue\\amplt;/td\\ampgt;\\amplt;td\\ampgt;5th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Stops location/detection and mental attacks--save vs.petrification or turn to stone\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*magic missile*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;td\\ampgt;Indigo\\amplt;/td\\ampgt;\\amplt;td\\ampgt;6th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Stops magical spells--save vs. wand or go insane \\amplt;/td\\ampgt;\\amplt;td\\ampgt;*continual light*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr style="border:1px solid black"\\ampgt;\\amplt;td\\ampgt;Violet\\amplt;/td\\ampgt;\\amplt;td\\ampgt;7th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;Force field protection--save vs. spell or be sent to\nanother plane\\amplt;/td\\ampgt;\\amplt;td\\ampgt;*dispel magic*\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;}}'},
+ {name:'Screen',type:'muspelll8',ct:'100',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nScreen\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Divination, Illusion}}Specs=[Screen,MUspellL8,1H,Divination|Illusion]{{components=V,S}}{{time=[[1]] turn}}{{range=[[0]]}}{{duration=[[[@{selected|mu-casting-level}]] hours](!rounds --target-nosave caster|@{selected|token_id}|Screen|[[60*@{selected|mu-casting-level}]]|-1|Maintaining an area screened from scrying eyes|white-tower)}}{{aoe=[[[@{selected|mu-casting-level}]] x 30ft cubes](!rounds --aoe @{selected|token_id}|rectangle|feet|0|||magic)}}{{save=Special}}{{reference=PHB p192}}SpellData=[w:Screen,lv:8,sp:100,gp:0,cs:VS]{{effects=Combines several elements to create a powerful protection from scrying and direct observation.}}{{hide1=When the spell is cast, the wizard dictates what will and will not be observed in the area of effect. The illusion created must be stated in general terms. Thus, the caster could specify the illusion of him and another playing chess for the duration of the spell, but he could not have the illusionary chess players take a break, make dinner, and then resume their game. He could have a crossroads appear quiet and empty even while an army is actually passing through the area. He could specify that no one be seen (including passing strangers), that his troops be undetected, or even that every fifth man or unit should be visible. Once the conditions are set, they cannot be changed.\nAttempts to scry the area automatically detect the image stated by the caster with no saving throw allowed. Sight and sound are appropriate to the illusion created. A band of men standing in a meadow could be concealed as an empty meadow with birds chirping, etc. Direct observation may allow a saving throw (as per a normal illusion), if there is cause to disbelieve what is seen. Certainly onlookers in the area would become suspicious if the column of a marching army disappeared at one point to reappear at another! Even entering the area does not cancel the illusion or necessarily allow a saving throw, assuming the hidden beings take care to stay out of the way of those affected by the illusion.}}'},
+ {name:'Sertens-Spell-Immunity',type:'muspelll8',ct:'10',charge:'uncharged',cost:'500',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nSerten\'s Spell Immunity\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Abjuration}}Specs=[Sertens Spell Immunity,MUspellL8,1H,Abjuration]{{components=V,S,M}}{{time=1 round for each recipient}}{{range=Touch}}!setattr --silent --charid @{selected|character_id} --spell-duration|{{duration=[[floor(10*@{selected|mu-casting-level}/?{How many recipients?})]] rounds}}!!!{{aoe=[Creature(s) touched](!rounds --target-nosave caster|@{selected|token_id}|Sertens-Immunity-casting|[[?{How many recipients?}]]|-1|Casting Serten\'s Spell Immunity on ?{How many recipients?} creatures takes ?{How many recipients?} rounds|stopwatch --target-nosave single|@{selected|token_id}|\\amp#64;{target|Who to give immunity to?|token_id}|Sertens-Immunity|[[floor(10*@{selected|mu-casting-level}/?{How many recipients?})]]|-1|Better saves against many spells - see PHB p192|white-tower)}}{{save=None}}{{reference=PHB p192}}{{Use=Select the first creature to be given immunity using the *area of effect* button. Subsequent creatures to protect will be prompted for on subsequent turns of the caster. Additional save types are automatically added to creature\'s saving throw tables}}SpellData=[w:Sartens Spell Immunity,lv:8,sp:10,gp:500,cs:VSM]{{effects=Confer virtual immunity to certain spells and magical attack forms upon those he touches.}}{{hide1=For every four levels of experience of the wizard, one creature can be protected by the Serten\'s spell immunity spell; however, if more than one is protected, the duration of the protection is divided among the protected creatures.\nFor example, a 16th-level wizard can cast the spell upon one creature and it will last 16 turns, or place it upon two creatures for eight turns, or four creatures for four turns.) The protection gives a bonus to saving throws, according to spell type and level, as shown in the following table.\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;th\\ampgt;Spell Level\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Wizard Spell\\amplt;/th\\ampgt;\\amplt;th\\ampgt;Priest Spell\\amplt;/th\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;1st-\\amplt;tr\\ampgt;\\amplt;td\\ampgt;3rd\\amplt;/td\\ampgt;\\amplt;td\\ampgt;+9^\\amplt;/td\\ampgt;\\amplt;td\\ampgt;+7\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;4th-6th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;+7\\amplt;/td\\ampgt;\\amplt;td\\ampgt;+5\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;7th-8th\\amplt;/td\\ampgt;\\amplt;td\\ampgt;+5\\amplt;/td\\ampgt;\\amplt;td\\ampgt;+3\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\n^ Includes beguiling effects.}}{{materials=A diamond of at least 500 gp value, which must be crushed and sprinkled over the spell recipients. Each such creature must also have in its possession a diamond of at least one carat size, intact and carried on its person}}\n!setattr --silent --charid @{selected|character_id} --SSI-creatures|?{How many recipients?}'},
+ {name:'Sink',type:'muspelll8',ct:'18',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nSink\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Enchantment, Alteration}}Specs=[Sink,MUspellL8,1H,Enchantment|Alteration]{{components=V,S}}{{time=[[8]]}}{{range=[[10*@{selected|mu-casting-level}]] yards}}{{duration=1 or 2 rounds}}{{aoe=1 creature or object, max [[[@{selected|mu-casting-level}]] cu.ft.](!rounds --aoe @{selected|token_id}|rectangle|feet|[[10*@{selected|mu-casting-level}]]|||magic)}}{{save=Special}}{{reference=PHB p192}}SpellData=[w:Sink,lv:8,sp:18,gp:0,cs:VS]{{effects=Force a creature or object into the very earth or floor upon which it stands.}}{{hide1=When casting the spell, the wizard must chant the spell for the remainder of the round without interruption. At that juncture, the subject creature or object becomes rooted to the spot unless a saving throw vs. spell (for a creature) or disintegration (for an object with magical properties) is successful. (Note: "magical properties" include those of magical items as listed in the Dungeon Master Guide, those of items enchanted or otherwise of magical origin, and those of items with protectiontype spells or with permanent magical properties or similar spells upon them.) Items of a nonmagical nature are not entitled to a saving throw. If a subject fails its saving throw, it becomes of slightly greater density than the surface upon which it stands.\nThe spellcaster now has the option of ceasing his spell and leaving the subject as it is, in which case the spell expires in four turns, and the subject returns to normal. If the caster proceeds with the spell (into the next round), the subject begins to sink slowly into the ground. Before any actions are taken in the new round, the subject sinks one-quarter of its height; after the first group acts, another quarter; after the second group acts, another; and at the end of the round, the victim is totally sunken into the ground.\nThis entombment places a creature or object in a state of suspended animation. The cessation of time means that the subject does not grow older. Bodily and other functions virtually cease, but the subject is otherwise unharmed. The subject exists in undamaged form in the surface into which it was sunk, its upper point as far beneath the surface as the subject has height--a 6-foot-tall victim will be 6 feet beneath the surface, while a 60-foot-tall subject will have its uppermost point 60 feet below ground level. If the ground around the subject is somehow removed, the spell is broken and the subject returns to normal, but it does not rise up. Spells such as dig, transmute rock to mud, and freedom (the reverse of the 9th-level spell imprisonment) will not harm the sunken creature or object and will often be helpful in recovering it. If a detect magic spell is cast over an area upon which a sink spell was used, it reveals a faint magical aura of undefinable nature, even if the subject is beyond detection range. If the subject is within range of the detection, the spell\'s schools can be discovered (alteration and enchantment).}}'},
+ {name:'Symbol',type:'muspelll8',ct:'8',charge:'uncharged',cost:'10000',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casts\nSymbol\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Conjuration/Summoning}}Specs=[Symbol,MUspellL8,1H,Conjuration-Summoning]{{components=V, S, M}}{{time=8}}{{range=Touch}}{{duration=Special}}{{aoe=Special}}{{save=Special}}{{reference=PHB p193}}SpellData=[w:Symbol,lv:8,sp:8,gp:10000,cs:VSM]{{effects=Creates magical runes affecting creatures that pass over, touch, or read the runes, or pass through a portal upon which the symbol is inscribed. \n**Death, Discord, Fear, Hopelessness, Insanity, Pain, Sleep, Stunning**}}{{hide1=Upon casting the spell, the wizard inscribes the symbol upon whatever surface he desires. Likewise, the spellcaster is able to place the symbol of his choice, using any one of the following:\n\\amplt;table width="100%"\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Death*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;One or more creatures, whose total hit points do not exceed 80, are slain.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Discord*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;All creatures are affected and immediately fall to loud bickering and arguing; there is a 50% probability that creatures of different alignments attack each other. The bickering lasts for 5d4 rounds, the fighting for 2d4 rounds.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Fear*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;This symbol creates an extra-strong fear spell, causing all creatures to save vs. spell with -4 penalties to the die roll, or panic and flee as if attacked by a fear spell.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Hopelessness*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;All creatures are affected and must turn back in dejection unless they save vs. spell. Affected creatures submit to the demands of any opponent--for example, surrender, get out, etc. The hopelessness lasts for 3d4 turns; during this period it is 25% probable that affected creatures take no action during any round, and 25% likely that those taking action turn back or retire from battle, as applicable.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Insanity*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;One or more creatures whose total hit points do not exceed 120 become insane and remain so, acting as if a confusion spell had been placed upon them, until a heal, restoration, or wish spell is used to remove the madness.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Pain*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;All creatures are afflicted with wracking pains shooting through their bodies, causing a -2 penalty to Dexterity and a -4 penalty to attack rolls for 2d10 turns.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Sleep*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;All creatures under 8+1 Hit Dice immediately fall into a catatonic slumber and cannot be awakened for 1d12+4 turns.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;tr\\ampgt;\\amplt;td\\ampgt;*Stunning*\\amplt;/td\\ampgt;\\amplt;td\\ampgt;One or more creatures whose total hit points do not exceed 160 are stunned and reeling for 3d4 rounds, dropping anything they are holding.\\amplt;/td\\ampgt;\\amplt;/tr\\ampgt;\\amplt;/table\\ampgt;\nThe type of symbol cannot be recognized without being read and thus activating its effects.\nThe material components of this spell are powdered black opal and diamond dust, worth not less than 5,000 gp each.}}{{materials=Powdered black opal and diamond dust, worth not less than 5,000 gp each.}}'},
+ {name:'Trap-The-Soul',type:'muspelll8',ct:'1',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.spellTemplate+'}{{title=@{selected|casting-name} casting\nTrap The Soul\nas a level @{selected|mu-casting-level} caster}}{{splevel=Level 8 Wizard}}{{school=Conjuration-Summoning}}Specs=[Trap The Soul,MUspellL8,1H,Conjuration-Summoning]{{components=V,S,M}}{{time=Special+1}}{{range=[[10]] yards}}{{duration=Permanent until broken}}{{aoe=1 creature}}{{save=Special}}{{reference=PHB p193}}SpellData=[w:Trap The Soul,lv:8,sp:1,gp:0,cs:VSM]{{effects=Forces the creature\'s life force (and its material body) into a special prison gem enchanted by the spellcaster.}}{{hide1=The creature must be seen by the caster when the final word is uttered.\nThe spell can be triggered in one of two ways. First, the final word of the spell can be spoken when the creature is within spell range. This allows magic resistance (if any) and a saving throw vs. spell to avoid the effect. If the creature\'s real name is spoken as well, any magic resistance is ignored and the saving throw vs. spell suffers a penalty of -2. If the saving throw is successful, the prison gem shatters.\nThe second method is far more insidious, for it tricks the victim into accepting a trigger object inscribed with the final spell word, automatically placing the creature\'s soul in the trap. To use this method, both the creature\'s true name and the trigger word must be inscribed on the trigger item when the gem is enchanted. A sympathy spell can also be placed on the trigger item. As soon as the subject creature picks up or accepts the trigger item, its life force is automatically transferred to the gem, without the benefit of magic resistance or saving throw.\nThe gem prison will hold the trapped entity indefinitely, or until the gem is broken and the life force is released, allowing the material body to reform. If the trapped creature is a powerful creature from another plane (which could mean a character trapped by an inhabitant of another plane when the character is not on the Prime Material Plane), it can be required to perform a service immediately upon being freed. Otherwise, the creature can go free once the gem imprisoning it is broken.\nBefore the actual casting of the trap the soul spell, the wizard must prepare the prison, a gem of at least 1,000 gp value for every Hit Die or level of experience possessed by the creature to be trapped (for example, it requires a gem of 10,000 gp value to trap a 10 Hit Die or 10th-level creature). If the gem is not valuable enough, it shatters when the entrapment is attempted. (Note that while characters have no concept of level as such, the value of the gem needed to trap an individual can be researched. Remember that this value can change over time as characters advance.) Creating the prison gem requires an enchant an item spell and the placement of a maze spell into the gem, thereby forming the prison to contain the life force.}}'},
]},
MU_Spells_DB_L9:{bio:'Magic User Spell Database: Level 1 v8.02 07/05/2024
This database holds the definitions and API calls to enact Level 9 Wizard Spells. Spells can be memorised, and once used disapear from memory, only being refreshed on a long rest (1st level spells can optionally be refreshed on a short rest). Characters, NPCs and Monsters can learn, memorise and use these spells via the abilities, menus and commands of the MagicMaster API
Important Note: most of the spell macros require a Roll20 Pro membership, and the installation of the ChatSetAttr, TokenMod, MagicMaster and RoundMaster API Scripts, to allow parameter passing between macros, update of character sheet variables, and marking spell effects on tokens. If you do not have this level of subscription, I highly recommend you get it as a DM, as you get lots of other goodies as well. If you want to know how to load the API Scripts to your game, the RoLL20 API help here gives guidance, or Richard can help you.
Instructions
In order to understand the format of spell macros in this database and how to change or add to them, please refer to the MagicMaster API documentation.',
gmnotes:'Change Log: v8.02 07/05/2024 Updated spell effects to use latest features, e.g. save mod table v8.01 09/04/2024 Split spells by level into separate databases for easier management. For earlier changes, see MU-Spells-DB',
@@ -9625,17 +9619,17 @@ const libRPGMaster = (() => { // eslint-disable-line no-unused-vars
LibFunctions.evalAttr = function(v) {
function reRoll(m,n,p,r) { return LibFunctions.rollDice(n,p,r); };
var handoutIDs = LibFunctions.getHandoutIDs(),
- orig = String(v);
+ orig = String(v).match(/([^\[\]]+)\s*?([^\[]?\[[^\[].*\])?/i);
const rePar = /\([\d\+\-\*\/\.]+?\)/g,
reRange = /\d+\:\d+/g,
reDice = /(\d+)d(\d+)(?:r(\d+))?/ig,
reMinMax = /[Mthmaxinflorce\s\.\,\(\)\d\+\-\*\/]+/g;
try {
- v = String(v);
- if (!v || !v.length) {
+ if (!orig || !orig.length) {
return '';
} else {
+ v = orig[1];
v = v.replace(/;/g,',')
.replace(/\^\(/g,'Math.max(')
.replace(/v\(/g,'Math.min(')
@@ -9653,11 +9647,11 @@ const libRPGMaster = (() => { // eslint-disable-line no-unused-vars
} while (rePar.test(v) || reRange.test(v) || reDice.test(v));
v = v.replace(reMinMax,eval).replace(/\-\-/g,'+').replace(/\+\-/g,'-');
} while (rePar.test(v) || reRange.test(v) || reDice.test(v));
- return v;
+ return String(v)+(orig[2] || '');
};
} catch (e) {
- LibFunctions.sendError('Invalid attribute value given: calculating "'+orig+'" but only **\'+ - * / ( ) : d ^ v ,\'** can be used. Current evaluation is '+v+'. See **[CommandMaster Help]('+fields.journalURL+handoutIDs.CommandMasterHelp+')** for allowed Creature attribute specification formats.');
- LibFunctions.sendCatchError('LibFunctions',null,e);
+ LibFunctions.sendError('Invalid attribute value given: calculating "'+orig[0]+'" but only **\'+ - * / ( ) : d r f c ^ v , ;\'** can be used. Current evaluation is '+v);
+// LibFunctions.sendCatchError('LibFunctions',null,e);
return v;
};
};
diff --git a/RPGMlibrary AD+D2e/script.json b/RPGMlibrary AD+D2e/script.json
index a5583b298..4b6945a78 100644
--- a/RPGMlibrary AD+D2e/script.json
+++ b/RPGMlibrary AD+D2e/script.json
@@ -2,8 +2,8 @@
"$schema": "https://github.com/DameryDad/roll20-api-scripts/blob/RPGMlibrary/RPGMlibrary AD+D2e/Script.json",
"name": "RPGMaster library AD+D2e",
"script": "libRPGMaster2e.js",
- "version": "3.5.1",
- "previousversions": ["1.3.00","1.3.01","1.3.02","1.3.03","1.3.04","1.4.01","1.4.02","1.4.03","1.4.04","1.4.05","1.4.06","1.4.07","1.5.01","1.5.02","1.5.03","1.5.04","1.5.05","1.5.06","2.1.0","2.2.0","2.2.1","2.2.2","2.3.0","2.3.1","2.3.2","2.3.3","2.3.4","3.0.0","3.0.1","3.0.2","3.1.3","3.2.0","3.2.1","3.3.0","3.4.0","3.5.0"],
+ "version": "3.5.2",
+ "previousversions": ["1.3.00","1.3.01","1.3.02","1.3.03","1.3.04","1.4.01","1.4.02","1.4.03","1.4.04","1.4.05","1.4.06","1.4.07","1.5.01","1.5.02","1.5.03","1.5.04","1.5.05","1.5.06","2.1.0","2.2.0","2.2.1","2.2.2","2.3.0","2.3.1","2.3.2","2.3.3","2.3.4","3.0.0","3.0.1","3.0.2","3.1.3","3.2.0","3.2.1","3.3.0","3.4.0","3.5.0","3.5.1"],
"description": "RPGMaster Library for AD&D2e provides all of the game-version-specific data and rule processing for the RPGMaster series of APIs to work with the Advanced Dungeon & Dragons 2nd Edition rule set, and with the Advanced D&D2e Character Sheet by Peter B. Other versions of the library will support other gave versions in future. In order for versions of the RPGMaster series APIs later than 1.0.0 to work (i.e. those that have version numbers with three segments) they require one of the RPGMaster Libraries to be loaded with them: which one determines which rule set and character sheet they work with. The Library does not support any API commands itself (the other RPGMaster APIs provide those), but it supports a unique set of Roll Templates, and provides API Authors with a number of callable functions that might be of use. See the RPGMaster Library Help handout that the Library creates in your campaign when initially loaded for details.\n\n[RPGMaster Documentation](https://wiki.roll20.net/RPGMaster) \n### Getting Started\n1. When all APIs in the RPGMaster suite are loaded, run `!cmd --initialise` and add the player macros created to the Macro Bar, then\n2. Select tokens and use the `Token Setup` macro bar button just created to add all relevant Action Buttons to the token(s) (plus set the tokens/Characters up in any other way provided in the menu displayed) \n3. Once steps 1 & 2 have been done, the players and DM can then use the buttons displayed at the top of the screen when their character's token is selected to perform all actions needed in normal play.",
"authors": "Richard E.",
"roll20userid": "6497708",
From 19ebda6091f2a268e2649f80c6f7b030a823cd27 Mon Sep 17 00:00:00 2001
From: DameryDad <74715860+DameryDad@users.noreply.github.com>
Date: Sat, 14 Sep 2024 17:01:16 +0100
Subject: [PATCH 04/14] Force Creature-A-E DB update
* Even though Race-DB-Creatures-A-E has not changed, users might have completed a temporary fix I documented, which now needs removing which this update does
---
RPGMlibrary AD+D2e/3.5.2/libRPGMaster2e.js | 6 +++---
RPGMlibrary AD+D2e/libRPGMaster2e.js | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/RPGMlibrary AD+D2e/3.5.2/libRPGMaster2e.js b/RPGMlibrary AD+D2e/3.5.2/libRPGMaster2e.js
index a342b6a2a..295300bfa 100644
--- a/RPGMlibrary AD+D2e/3.5.2/libRPGMaster2e.js
+++ b/RPGMlibrary AD+D2e/3.5.2/libRPGMaster2e.js
@@ -899,14 +899,14 @@ const libRPGMaster = (() => { // eslint-disable-line no-unused-vars
{name:'Tallfellow-Halfling',type:'HumanoidRace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=Tallfellow Halfling}}{{subtitle=Race}}Specs=[Tallfellow Halfling,HumanoidRace,0H,Humanoid]{{Alignment=Any (usually NG)}}{{Languages=Often *common, halfling, dwarf, elf, gnome, goblin, orc,* and any one Elven language}}{{Height=Males [40+2d8](!\\amp#13;\\amp#47;r 40+2d8 ins height)ins, Females [38+2d8](!\\amp#13;\\amp#47;r 38+2d8 ins height)ins}}{{Weight=Males [52+5d4](!\\amp#13;\\amp#47;r 52+5d4 lbs weight)lbs, Females [48+5d4](!\\amp#13;\\amp#47;r 48+5d4 lbs weight)lbs}}{{Life Expectancy=Average at 180 years}}{{Section=**Attributes**}}{{Minimum=Con:10, Dex:8, Int:6, Wis:7, Chr:5}}{{Maximum=Str:17, Dex:19, Wis:19}}{{Adjustment=Wis *or* Dex:+1, Str:-1}}{{Section1=**Powers**}}{{Section2=None}}{{Section3=**Special Advantages**}}{{Secret Doors=Like elves, a Tallfellow can recognize a secret door on a [1 in 6](!\\amp#13;\\amp#47;r 1d6\\lt1) if passing within 10 feet.}}{{Magic Resistance=Magic-resistant, giving a bonus to saving throws against magical wands, staves, rods, and spells of +1 for every 3.5 points of Constitution score.}}{{Poison Resistance=Save vs. poison at +1 for every 3.5 points of Constitution score.}}{{Attack bonus=+1 To Hit with slings and thrown weapons}}{{Hide in Wood=Tallfellows receive a +2 bonus to surprise rolls when in forest or wooded terrain under all circumstances.}}{{Other Surprise=Enemies get a –4 penalty to surprise if the halfling is: 1) moving alone, 2) is 90 feet away from the rest of their party, or 3) is with other elves or halflings and all are in nonmetal armor. If the halfling must open a door or screen to get to the enemy, the penalty is reduced to –2.}}{{Section5=**Special Disadvantages**}}{{Infravision=***None***}}RaceData=[w:Tallfellow Halfling, attr:str=3:17|con=10|dex=8:19|int=6|wis=7:19|chr=5, align:any, weaps:any, ac:any, thmod:throwing=1|dart=1|hand-axe=1|magical-stone=1|slings=1, svatt:con, svpoi:3.5 svrod:3.5, svsta:3.5, svwan:3.5, svspe:3.5, ns:1],[cl:PW,w:Elf Detect Secret Doors,lv:0,sp:0,pd:-1]{{desc=This subrace of halflings is not so common as the Stout or Hairfoot but exists in significant numbers in many areas of temperate woodland. Averaging a little over 4\' in height, Tallfellows are slender and light-boned, weighing little more than the average Hairfoot.\nThey enjoy the company of elves, and most Tallfellow villages will be found nearby populations of that sylvan folk, with a flourishing trade between the two peoples.\nTallfellows display the greatest affinity toward working with wood of any halfling. They make splendid carpenters (often building boats or wagons for human customers), as well as loggers, carvers, pipesmiths, musicians, shepherds, liverymen, dairymen, cheesemakers, hunters, and scouts. They are better farmers than Stouts (although not as good as Hairfeet) and more adept than any other subrace at harvesting natural bounties of berries, nuts, roots, and wild grains.\nThe only halflings who enjoy much proficiency at riding, Tallfellows favor small ponies. Indeed, many unique breeds of diminutive horse have been bred among Tallfellow clans: fast, shaggy-maned, nimble mounts with great endurance. In a charge, of course, they lack the impact of a human-mounted warhorse; nonetheless, Tallfellow companies have served admirably as light lancers and horsearchers during many a hardfought campaign.\nOn foot, Tallfellows wield spears with rare skill. They are adept at forming bristling `porcupine\' formations with these weapons, creating such a menacing array that horses and footmen alike are deterred from attacking. This is one of the few halfling formations capable of standing toe-to-toe with a larger opponent in the open field.}}'},
{name:'Tinker-Gnome',type:'HumanoidRace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=Tinker Gnome}}{{subtitle=Race}}Specs=[Tinker Gnome,HumanoidRace,0H,Gnome]{{Alignment=Any (Usually NG)}}{{Languages=*Tinker Gnome, Gnome Common, various human tongues*}}{{Height=Males [38+1d6](!\\amp#13;\\amp#47;r 38+1d6 ins height)ins, Females [36+1d6](!\\amp#13;\\amp#47;r 36+1d6 ins height)ins}}{{Weight=Males [72+5d4](!\\amp#13;\\amp#47;r 72+5d4 lbs weight)lbs, Females [68+5d4](!\\amp#13;\\amp#47;r 68+5d4 lbs weight)lbs}}{{Life Expectancy=250 to 300 years (rare)}}{{Section=**Attributes**}}{{Minimum=Str:6, Con:8, Dex:8, Int:8}}{{Maximum=Wis:12}}{{Adjustment=Dex:+2, Str:-1, Wis:-1}}{{Section1=**Powers**}}{{Expert Miners=Detect slopes, unsafe walls, cielings \\amp floors, determine approximate depth and direction underground}}{{Section3=**Special Advantages**}}{{Infravision=*Infravision* to 60ft.}}{{Magic Resistance=Gnomes are magic-resistant, giving a bonus to saving throws against magical wands, staves, rods, and spells of +1 for every 3.5 points of Constitution score.}}{{Attack bonus=+1 To Hit kobolds and goblins}}{{Small size=Gnolls, bugbears, ogres, trolls, ogre magi, giants, and titans suffer a -4 penalty to attack}}{{Section5=**Special Disadvantages**}}{{Item failure=20% chance for failure of any magical item except weapons, armor, shields, illusionist items, and (if the character is a thief) items that duplicate thieving abilities.}}RaceData=[w:Tinker Gnome, attr:str=6|con=8|Dex=8|int=8|Wis=3:12]{{desc=The Tinkers are a very courageous and curious bunch of gnomes.\nTinkers resemble the rest of gnomedom - in the fact that they do value various types of stones, attributing to them great and supernatural powers. However, whereas the other subraces seek gems, the Tinkers hold a different substance as the grandest rock of all: coal. The Tinkers hold that coal (also known as the "Father of Steam") is the most valuable substance of the world, and those places where it can be mined quickly become Tinker Gnome warrens.\nIn size and stature, the Tinkers resemble Rock Gnomes--so much so that the difference is not immediately apparent, at least when based only upon appearance.\nTinkers who live out their lives can attain an age of 250 or 300 years, but it must be noted that this is a rare occurrence among the members of this subrace. If one of his or her own inventions doesn\'t do a Tinker in, chances are good that one of his or her neighbor\'s gadgets will.\nEven in childhood, Tinkers are encouraged to experiment with gadgets and gimmicks, trying different means of making things to perform tasks that could otherwise be easily done by hand. The Tinker reaches adulthood at about the age of fifty (by which time perhaps 10-15% of them have already succumbed to the common fate of their kind). Despite this high attrition, it\'s not until maturity that a Tinker Gnome\'s activities begin to get really dangerous.\nUpon reaching adulthood, the Tinker Gnome must select a guild for himself or herself. The number of guilds available varies by location, but in Mount Nevermind on Krynn--which is the center of Tinker civilization and by far the largest community of these inventive creatures anywhere--there are more than 150 active guilds. These include virtually all areas of practical endeavor, and quite a few impractical ones as well.\nAfter selecting a guild, each member of the subrace settles upon a Life-quest. The actual choice of the quest may take several decades, but once it has been decided, it becomes the reason behind that Tinker\'s existence. The Lifequest is an attempt to reach a perfect understanding of some device (anything from a spelljamming helm to a screw), a task at which the Tinker very rarely succeeds. Indeed, the best estimate is that less than 1% of these gnomes ever do fully grasp the nature of the object that has occupied their attention for so much of their adult lives; the rest of these easily-distracted gnomes get hopelessly sidetracked somewhere along the way.\nDespite the vagaries of their existence, the Tinkers are a fun-loving and generally sociable race. Their speech is unique in both its speed and complexity. Two Tinkers can rattle off information and opinion to each other in a succession of thousand-word sentences, speaking simultaneously and yet listening and understanding (as much as is possible, given the esoteric nature of many discussions) each other even as they voice their own points of view. Those Tinkers who have had some experience interacting with other races have learned to slow the pace of their communication but never quite overcome their frustration with those who can\'t talk and listen at the same time.}}'},
]},
- Race_DB_Creatures_A_E:{bio:'Creatures Database v2.02 14/10/2023
This sheet holds definitions of pre-defined creatures from The Monsterous Compendium that can be used by the RPGMaster API system (creatures can also be added directly to a character sheet by editing the Monster tab on the sheet). The definitions include automatically setable attributes, valid alignments, the weapons & armour each creature can use, bonuses and penalties to saves, attacks, surprise etc, and the powers that the creature gets. Depending on API configuration, the APIs can restrict creatures to these specifications, or not as desired.',
- gmnotes:'Change Log: v2.02 14/10/2023 Fixed issue with War Dog & added Leopard & Snow Leopard v2.01 29/09/2023 Added several families of Giants, and all Chromatic & Metalic Dragons, Titans, & others with substantial functional upgrades v1.34 24/09/2023 Fixed issues with Goblin definition v1.33 13/08/2023 Added a basic chest to act as the basis for the *Drag & Drop* container system v1.32 11/07/2023 Added creatures that can be contained in an Iron Flask v1.31 07/06/2023 Corrected some spattk & spdef entries with wrong syntax v1.30 30/04/2023 Added creatures to support Figurines of Wonderous Power and other MIs v1.28 03/03/2023 Added Elephant, Rhino and Mouse to support Wand of Wonder v1.27 12/02/2023 Added Adder as a creature to support Staff of the Serpent (Adder) v1.26 16/01/2023 Added both attkmsg & dmgmsg to display with attack & damage respectively. v1.25 14/01/2023 Switched round creature attack names and dice rolls so will work with character sheet buttons as well as APIs v1.15-24 16/12/2022 Added more creatures and changed format for inherrited template fields v1.14 25/11/2022 Added more creatures, especially undead at DM request v1.10 14/11/2022 Initial live release of a sample creatures database v1.02 10/11/2022 Fixes and additional creatures v1.01 01/11/2022 First version of Race-DB-Creatures',
+ Race_DB_Creatures_A_E:{bio:'Creatures Database v2.04 14/09/2024
This sheet holds definitions of pre-defined creatures from The Monsterous Compendium that can be used by the RPGMaster API system (creatures can also be added directly to a character sheet by editing the Monster tab on the sheet). The definitions include automatically setable attributes, valid alignments, the weapons & armour each creature can use, bonuses and penalties to saves, attacks, surprise etc, and the powers that the creature gets. Depending on API configuration, the APIs can restrict creatures to these specifications, or not as desired.',
+ gmnotes:'Change Log: v2.04 14/09/2024 Version change to force deletion of any extracted databases used for temporary fixes v2.02 14/10/2023 Fixed issue with War Dog & added Leopard & Snow Leopard v2.01 29/09/2023 Added several families of Giants, and all Chromatic & Metalic Dragons, Titans, & others with substantial functional upgrades v1.34 24/09/2023 Fixed issues with Goblin definition v1.33 13/08/2023 Added a basic chest to act as the basis for the *Drag & Drop* container system v1.32 11/07/2023 Added creatures that can be contained in an Iron Flask v1.31 07/06/2023 Corrected some spattk & spdef entries with wrong syntax v1.30 30/04/2023 Added creatures to support Figurines of Wonderous Power and other MIs v1.28 03/03/2023 Added Elephant, Rhino and Mouse to support Wand of Wonder v1.27 12/02/2023 Added Adder as a creature to support Staff of the Serpent (Adder) v1.26 16/01/2023 Added both attkmsg & dmgmsg to display with attack & damage respectively. v1.25 14/01/2023 Switched round creature attack names and dice rolls so will work with character sheet buttons as well as APIs v1.15-24 16/12/2022 Added more creatures and changed format for inherrited template fields v1.14 25/11/2022 Added more creatures, especially undead at DM request v1.10 14/11/2022 Initial live release of a sample creatures database v1.02 10/11/2022 Fixes and additional creatures v1.01 01/11/2022 First version of Race-DB-Creatures',
root:'Race-DB',
api:'cmd',
type:'class,race',
controlledby:'all',
avatar:'https://s3.amazonaws.com/files.d20.io/images/241737383/GL25pkAS2z5JJ4S9cMKkjw/max.png?1629918721',
- version:2.03,
+ version:2.04,
db:[{name:'Adder',type:'creaturerace',ct:'0',charge:'uncharged',cost:'0',body:'%{Race-DB-Creatures|Poison-Snake-20}{{}}Specs=[Poison Snake,CreatureRace,0H,Poison Snake 20]{{}}RaceData=[w:Poison Snake 20]{{title=Adder}}'},
{name:'Advanced-Bullywug',type:'creaturerace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=, Advanced}}RaceData=[w:Advanced Bullywug, cattr:int=8:10|size=M]{{subtitle=Creature}}%{Race-DB-Creatures|Bullywug}{{Intelligence=Average (8 to 10)}}{{Size=M, 5-6ft tall}}Specs=[Bullywug Leader,CreatureRace,0H,Bullywug]{{desc1=**Advanced Bullywug:** A small number of bullywugs are larger and more intelligent than the rest of their kind. These bullywugs make their homes in abandoned buildings and caves, and send out regular patrols and hunting parties. These groups tend to be well equipped and organized, and stake out a regular territory, which varies with the size of the group. They are more aggressive than their smaller cousins, and will fight not only other bullywugs but other monsters as well. The intelligent bullywugs also organize regular raids outside their territory for food and booty, and especially prize human flesh. Since they are chaotic evil, all trespassers, including other bullywugs, are considered threats or sources of food.}}'},
{name:'Advanced-Bullywug-Shaman',type:'creaturerace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=, Advanced Shaman}}RaceData=[w:Advanced Bullywug Shaman, cattr:int=8:10|size=M|cl=pr:Shaman|lv=2]{{subtitle=Creature}}%{Race-DB-Creatures|Bullywug}{{Intelligence=Average (8 to 10)}}{{Size=M, 5-6ft tall}}Specs=[Advanced Bullywug Shaman,CreatureRace,0H,Bullywug]{{desc=**Advanced Bullywug Shaman:** For every 10 advanced bullywugs in a community, there is a 10% chance of a 2nd-level shaman being present. The creature requires the spellbook setting up, and spells to be memorised}}'},
diff --git a/RPGMlibrary AD+D2e/libRPGMaster2e.js b/RPGMlibrary AD+D2e/libRPGMaster2e.js
index a342b6a2a..295300bfa 100644
--- a/RPGMlibrary AD+D2e/libRPGMaster2e.js
+++ b/RPGMlibrary AD+D2e/libRPGMaster2e.js
@@ -899,14 +899,14 @@ const libRPGMaster = (() => { // eslint-disable-line no-unused-vars
{name:'Tallfellow-Halfling',type:'HumanoidRace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=Tallfellow Halfling}}{{subtitle=Race}}Specs=[Tallfellow Halfling,HumanoidRace,0H,Humanoid]{{Alignment=Any (usually NG)}}{{Languages=Often *common, halfling, dwarf, elf, gnome, goblin, orc,* and any one Elven language}}{{Height=Males [40+2d8](!\\amp#13;\\amp#47;r 40+2d8 ins height)ins, Females [38+2d8](!\\amp#13;\\amp#47;r 38+2d8 ins height)ins}}{{Weight=Males [52+5d4](!\\amp#13;\\amp#47;r 52+5d4 lbs weight)lbs, Females [48+5d4](!\\amp#13;\\amp#47;r 48+5d4 lbs weight)lbs}}{{Life Expectancy=Average at 180 years}}{{Section=**Attributes**}}{{Minimum=Con:10, Dex:8, Int:6, Wis:7, Chr:5}}{{Maximum=Str:17, Dex:19, Wis:19}}{{Adjustment=Wis *or* Dex:+1, Str:-1}}{{Section1=**Powers**}}{{Section2=None}}{{Section3=**Special Advantages**}}{{Secret Doors=Like elves, a Tallfellow can recognize a secret door on a [1 in 6](!\\amp#13;\\amp#47;r 1d6\\lt1) if passing within 10 feet.}}{{Magic Resistance=Magic-resistant, giving a bonus to saving throws against magical wands, staves, rods, and spells of +1 for every 3.5 points of Constitution score.}}{{Poison Resistance=Save vs. poison at +1 for every 3.5 points of Constitution score.}}{{Attack bonus=+1 To Hit with slings and thrown weapons}}{{Hide in Wood=Tallfellows receive a +2 bonus to surprise rolls when in forest or wooded terrain under all circumstances.}}{{Other Surprise=Enemies get a –4 penalty to surprise if the halfling is: 1) moving alone, 2) is 90 feet away from the rest of their party, or 3) is with other elves or halflings and all are in nonmetal armor. If the halfling must open a door or screen to get to the enemy, the penalty is reduced to –2.}}{{Section5=**Special Disadvantages**}}{{Infravision=***None***}}RaceData=[w:Tallfellow Halfling, attr:str=3:17|con=10|dex=8:19|int=6|wis=7:19|chr=5, align:any, weaps:any, ac:any, thmod:throwing=1|dart=1|hand-axe=1|magical-stone=1|slings=1, svatt:con, svpoi:3.5 svrod:3.5, svsta:3.5, svwan:3.5, svspe:3.5, ns:1],[cl:PW,w:Elf Detect Secret Doors,lv:0,sp:0,pd:-1]{{desc=This subrace of halflings is not so common as the Stout or Hairfoot but exists in significant numbers in many areas of temperate woodland. Averaging a little over 4\' in height, Tallfellows are slender and light-boned, weighing little more than the average Hairfoot.\nThey enjoy the company of elves, and most Tallfellow villages will be found nearby populations of that sylvan folk, with a flourishing trade between the two peoples.\nTallfellows display the greatest affinity toward working with wood of any halfling. They make splendid carpenters (often building boats or wagons for human customers), as well as loggers, carvers, pipesmiths, musicians, shepherds, liverymen, dairymen, cheesemakers, hunters, and scouts. They are better farmers than Stouts (although not as good as Hairfeet) and more adept than any other subrace at harvesting natural bounties of berries, nuts, roots, and wild grains.\nThe only halflings who enjoy much proficiency at riding, Tallfellows favor small ponies. Indeed, many unique breeds of diminutive horse have been bred among Tallfellow clans: fast, shaggy-maned, nimble mounts with great endurance. In a charge, of course, they lack the impact of a human-mounted warhorse; nonetheless, Tallfellow companies have served admirably as light lancers and horsearchers during many a hardfought campaign.\nOn foot, Tallfellows wield spears with rare skill. They are adept at forming bristling `porcupine\' formations with these weapons, creating such a menacing array that horses and footmen alike are deterred from attacking. This is one of the few halfling formations capable of standing toe-to-toe with a larger opponent in the open field.}}'},
{name:'Tinker-Gnome',type:'HumanoidRace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=Tinker Gnome}}{{subtitle=Race}}Specs=[Tinker Gnome,HumanoidRace,0H,Gnome]{{Alignment=Any (Usually NG)}}{{Languages=*Tinker Gnome, Gnome Common, various human tongues*}}{{Height=Males [38+1d6](!\\amp#13;\\amp#47;r 38+1d6 ins height)ins, Females [36+1d6](!\\amp#13;\\amp#47;r 36+1d6 ins height)ins}}{{Weight=Males [72+5d4](!\\amp#13;\\amp#47;r 72+5d4 lbs weight)lbs, Females [68+5d4](!\\amp#13;\\amp#47;r 68+5d4 lbs weight)lbs}}{{Life Expectancy=250 to 300 years (rare)}}{{Section=**Attributes**}}{{Minimum=Str:6, Con:8, Dex:8, Int:8}}{{Maximum=Wis:12}}{{Adjustment=Dex:+2, Str:-1, Wis:-1}}{{Section1=**Powers**}}{{Expert Miners=Detect slopes, unsafe walls, cielings \\amp floors, determine approximate depth and direction underground}}{{Section3=**Special Advantages**}}{{Infravision=*Infravision* to 60ft.}}{{Magic Resistance=Gnomes are magic-resistant, giving a bonus to saving throws against magical wands, staves, rods, and spells of +1 for every 3.5 points of Constitution score.}}{{Attack bonus=+1 To Hit kobolds and goblins}}{{Small size=Gnolls, bugbears, ogres, trolls, ogre magi, giants, and titans suffer a -4 penalty to attack}}{{Section5=**Special Disadvantages**}}{{Item failure=20% chance for failure of any magical item except weapons, armor, shields, illusionist items, and (if the character is a thief) items that duplicate thieving abilities.}}RaceData=[w:Tinker Gnome, attr:str=6|con=8|Dex=8|int=8|Wis=3:12]{{desc=The Tinkers are a very courageous and curious bunch of gnomes.\nTinkers resemble the rest of gnomedom - in the fact that they do value various types of stones, attributing to them great and supernatural powers. However, whereas the other subraces seek gems, the Tinkers hold a different substance as the grandest rock of all: coal. The Tinkers hold that coal (also known as the "Father of Steam") is the most valuable substance of the world, and those places where it can be mined quickly become Tinker Gnome warrens.\nIn size and stature, the Tinkers resemble Rock Gnomes--so much so that the difference is not immediately apparent, at least when based only upon appearance.\nTinkers who live out their lives can attain an age of 250 or 300 years, but it must be noted that this is a rare occurrence among the members of this subrace. If one of his or her own inventions doesn\'t do a Tinker in, chances are good that one of his or her neighbor\'s gadgets will.\nEven in childhood, Tinkers are encouraged to experiment with gadgets and gimmicks, trying different means of making things to perform tasks that could otherwise be easily done by hand. The Tinker reaches adulthood at about the age of fifty (by which time perhaps 10-15% of them have already succumbed to the common fate of their kind). Despite this high attrition, it\'s not until maturity that a Tinker Gnome\'s activities begin to get really dangerous.\nUpon reaching adulthood, the Tinker Gnome must select a guild for himself or herself. The number of guilds available varies by location, but in Mount Nevermind on Krynn--which is the center of Tinker civilization and by far the largest community of these inventive creatures anywhere--there are more than 150 active guilds. These include virtually all areas of practical endeavor, and quite a few impractical ones as well.\nAfter selecting a guild, each member of the subrace settles upon a Life-quest. The actual choice of the quest may take several decades, but once it has been decided, it becomes the reason behind that Tinker\'s existence. The Lifequest is an attempt to reach a perfect understanding of some device (anything from a spelljamming helm to a screw), a task at which the Tinker very rarely succeeds. Indeed, the best estimate is that less than 1% of these gnomes ever do fully grasp the nature of the object that has occupied their attention for so much of their adult lives; the rest of these easily-distracted gnomes get hopelessly sidetracked somewhere along the way.\nDespite the vagaries of their existence, the Tinkers are a fun-loving and generally sociable race. Their speech is unique in both its speed and complexity. Two Tinkers can rattle off information and opinion to each other in a succession of thousand-word sentences, speaking simultaneously and yet listening and understanding (as much as is possible, given the esoteric nature of many discussions) each other even as they voice their own points of view. Those Tinkers who have had some experience interacting with other races have learned to slow the pace of their communication but never quite overcome their frustration with those who can\'t talk and listen at the same time.}}'},
]},
- Race_DB_Creatures_A_E:{bio:'Creatures Database v2.02 14/10/2023
This sheet holds definitions of pre-defined creatures from The Monsterous Compendium that can be used by the RPGMaster API system (creatures can also be added directly to a character sheet by editing the Monster tab on the sheet). The definitions include automatically setable attributes, valid alignments, the weapons & armour each creature can use, bonuses and penalties to saves, attacks, surprise etc, and the powers that the creature gets. Depending on API configuration, the APIs can restrict creatures to these specifications, or not as desired.',
- gmnotes:'Change Log: v2.02 14/10/2023 Fixed issue with War Dog & added Leopard & Snow Leopard v2.01 29/09/2023 Added several families of Giants, and all Chromatic & Metalic Dragons, Titans, & others with substantial functional upgrades v1.34 24/09/2023 Fixed issues with Goblin definition v1.33 13/08/2023 Added a basic chest to act as the basis for the *Drag & Drop* container system v1.32 11/07/2023 Added creatures that can be contained in an Iron Flask v1.31 07/06/2023 Corrected some spattk & spdef entries with wrong syntax v1.30 30/04/2023 Added creatures to support Figurines of Wonderous Power and other MIs v1.28 03/03/2023 Added Elephant, Rhino and Mouse to support Wand of Wonder v1.27 12/02/2023 Added Adder as a creature to support Staff of the Serpent (Adder) v1.26 16/01/2023 Added both attkmsg & dmgmsg to display with attack & damage respectively. v1.25 14/01/2023 Switched round creature attack names and dice rolls so will work with character sheet buttons as well as APIs v1.15-24 16/12/2022 Added more creatures and changed format for inherrited template fields v1.14 25/11/2022 Added more creatures, especially undead at DM request v1.10 14/11/2022 Initial live release of a sample creatures database v1.02 10/11/2022 Fixes and additional creatures v1.01 01/11/2022 First version of Race-DB-Creatures',
+ Race_DB_Creatures_A_E:{bio:'Creatures Database v2.04 14/09/2024
This sheet holds definitions of pre-defined creatures from The Monsterous Compendium that can be used by the RPGMaster API system (creatures can also be added directly to a character sheet by editing the Monster tab on the sheet). The definitions include automatically setable attributes, valid alignments, the weapons & armour each creature can use, bonuses and penalties to saves, attacks, surprise etc, and the powers that the creature gets. Depending on API configuration, the APIs can restrict creatures to these specifications, or not as desired.',
+ gmnotes:'Change Log: v2.04 14/09/2024 Version change to force deletion of any extracted databases used for temporary fixes v2.02 14/10/2023 Fixed issue with War Dog & added Leopard & Snow Leopard v2.01 29/09/2023 Added several families of Giants, and all Chromatic & Metalic Dragons, Titans, & others with substantial functional upgrades v1.34 24/09/2023 Fixed issues with Goblin definition v1.33 13/08/2023 Added a basic chest to act as the basis for the *Drag & Drop* container system v1.32 11/07/2023 Added creatures that can be contained in an Iron Flask v1.31 07/06/2023 Corrected some spattk & spdef entries with wrong syntax v1.30 30/04/2023 Added creatures to support Figurines of Wonderous Power and other MIs v1.28 03/03/2023 Added Elephant, Rhino and Mouse to support Wand of Wonder v1.27 12/02/2023 Added Adder as a creature to support Staff of the Serpent (Adder) v1.26 16/01/2023 Added both attkmsg & dmgmsg to display with attack & damage respectively. v1.25 14/01/2023 Switched round creature attack names and dice rolls so will work with character sheet buttons as well as APIs v1.15-24 16/12/2022 Added more creatures and changed format for inherrited template fields v1.14 25/11/2022 Added more creatures, especially undead at DM request v1.10 14/11/2022 Initial live release of a sample creatures database v1.02 10/11/2022 Fixes and additional creatures v1.01 01/11/2022 First version of Race-DB-Creatures',
root:'Race-DB',
api:'cmd',
type:'class,race',
controlledby:'all',
avatar:'https://s3.amazonaws.com/files.d20.io/images/241737383/GL25pkAS2z5JJ4S9cMKkjw/max.png?1629918721',
- version:2.03,
+ version:2.04,
db:[{name:'Adder',type:'creaturerace',ct:'0',charge:'uncharged',cost:'0',body:'%{Race-DB-Creatures|Poison-Snake-20}{{}}Specs=[Poison Snake,CreatureRace,0H,Poison Snake 20]{{}}RaceData=[w:Poison Snake 20]{{title=Adder}}'},
{name:'Advanced-Bullywug',type:'creaturerace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=, Advanced}}RaceData=[w:Advanced Bullywug, cattr:int=8:10|size=M]{{subtitle=Creature}}%{Race-DB-Creatures|Bullywug}{{Intelligence=Average (8 to 10)}}{{Size=M, 5-6ft tall}}Specs=[Bullywug Leader,CreatureRace,0H,Bullywug]{{desc1=**Advanced Bullywug:** A small number of bullywugs are larger and more intelligent than the rest of their kind. These bullywugs make their homes in abandoned buildings and caves, and send out regular patrols and hunting parties. These groups tend to be well equipped and organized, and stake out a regular territory, which varies with the size of the group. They are more aggressive than their smaller cousins, and will fight not only other bullywugs but other monsters as well. The intelligent bullywugs also organize regular raids outside their territory for food and booty, and especially prize human flesh. Since they are chaotic evil, all trespassers, including other bullywugs, are considered threats or sources of food.}}'},
{name:'Advanced-Bullywug-Shaman',type:'creaturerace',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:'+fields.defaultTemplate+'}{{name=, Advanced Shaman}}RaceData=[w:Advanced Bullywug Shaman, cattr:int=8:10|size=M|cl=pr:Shaman|lv=2]{{subtitle=Creature}}%{Race-DB-Creatures|Bullywug}{{Intelligence=Average (8 to 10)}}{{Size=M, 5-6ft tall}}Specs=[Advanced Bullywug Shaman,CreatureRace,0H,Bullywug]{{desc=**Advanced Bullywug Shaman:** For every 10 advanced bullywugs in a community, there is a 10% chance of a 2nd-level shaman being present. The creature requires the spellbook setting up, and spells to be memorised}}'},
From 6f69fa3e2c4fbe6e40e65742631f5146df528d2b Mon Sep 17 00:00:00 2001
From: DameryDad <74715860+DameryDad@users.noreply.github.com>
Date: Sat, 21 Sep 2024 11:06:22 +0100
Subject: [PATCH 05/14] Fixed --addStatus for Players
* Fix --addStatus command when invoked by Players to accept extensions such as the multi-token saving-throw extension introduced in v5.056
---
RoundMaster/5.057/RoundMaster.js | 7111 ++++++++++++++++++++++++++++++
RoundMaster/RoundMaster.js | 17 +-
RoundMaster/script.json | 4 +-
3 files changed, 7122 insertions(+), 10 deletions(-)
create mode 100644 RoundMaster/5.057/RoundMaster.js
diff --git a/RoundMaster/5.057/RoundMaster.js b/RoundMaster/5.057/RoundMaster.js
new file mode 100644
index 000000000..cd1bfd813
--- /dev/null
+++ b/RoundMaster/5.057/RoundMaster.js
@@ -0,0 +1,7111 @@
+// Github: https://github.com/Roll20/roll20-api-scripts/tree/master/RoundMaster
+// Beta: https://github.com/DameryDad/roll20-api-scripts/tree/RoundMasterAPI/RoundMaster
+// By: Richard @ Damery
+// Contact: https://app.roll20.net/users/6497708/richard-at-damery
+
+var API_Meta = API_Meta||{}; // eslint-disable-line no-var
+API_Meta.RoundMaster={offset:Number.MAX_SAFE_INTEGER,lineCount:-1};
+{try{throw new Error('');}catch(e){API_Meta.RoundMaster.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-8);}}
+
+/**
+ * roundMaster.js
+ *
+ * * Copyright 2015: Ken L.
+ * Licensed under the GPL Version 3 license.
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ * This script is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This script is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ *
+ * Extended for D&D2e game play by Richard Edwards, July-October, 2020
+ *
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ *
+ * The goal of this script is to be an initiative tracker, that manages statuses,
+ * effects, and durations.
+ *
+ * 1. It should advance the turn order and display a notification in chat with
+ * optional toggles.
+ *
+ * 1.1 It should have the ability to announce rounds
+ *
+ * 2. It should allow some kind of underlay graphic with or without some kind of
+ * underlay graphic like TurnMarker.js
+ *
+ * 3. It should have the ability to tie status conditions to tokens with concise
+ * visual cues to relay to chat (IE fog cloud has X turns remaining on it or has lasted N turns).
+ *
+ * 4. It should be extensible to other scripts by exposing a call structure for
+ * a speedier access of innate functions without cluttering up the message queue. TODO
+ *
+ * 5. It should be verbose in terms of error reporting where all are recoverable.
+ *
+ * 6. It should save turn information within the state object to ensure recovery
+ * of all effects in the event of API connection failure.
+ *
+ * 7. It should be lightweight with a minimal amount of passed messages.
+ *
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ *
+ * Added by Richard Edwards (comments preceeded by RED in code
+ *
+ * 7. It should expose an accessible Round Counter variable for use in macros.
+ *
+ * 8. It should provide for the addition of custom items to the Turn Order,
+ * allowing more than one entry with the same name but only with different values.
+ *
+ * v1.000 to 3.030 See earlier versions for change log
+ * v4.031 20/03/2022 Added use of libTokenMarkers API library and extended to use any loaded
+ * token marker sets. Changed s-marker command to --listmarkers. Fixed token
+ * marker "stacking" issues.
+ * v4.032 26/03/2022 Fixed multi-user libTokenMarkers API call
+ * v4.033 05/04/2022 Changed --viewer mode so tokens/characters controlledby 'all' will not have
+ * vision status changed: fixes trapped/locked chests getting erroneous "vision".
+ * Trapped multiple Lib check errors within 10sec and only send 1. Changed order
+ * in which token fields are checked for AC/Thac0/HP so only reverts to defaults after
+ * checking others.
+ * v4.034 11/05/2022 Added effects to turn on and off Underwater Infravision. Added error messages when
+ * editing or moving statuses and no tokens are selected. Sent --redo message for each
+ * to affected token to InitMaster API when clearing the turn order. Allow relative
+ * measurements to caster token for Area of Effects (using +/-). Fix errors in
+ * gatTokenValues().
+ * v4.035 07/10/2022 Added additional effects for new magic items recently programmed. Moved Effects-DB
+ * to be held as data, as per other RPGM APIs. Added --extract-db function. Changed
+ * initiative modifier field from comreact to custom field init-mod
+ * v4.036 11/11/2022 Added new effects to support the new race and creature databases
+ * v4.037 30/11/2022 Extended the status name syntax to support hiding the effect name from the Players
+ * v4.038 08/12/2022 Added ability to extend or reduce existing statuses using duration of +# or -#
+ * v4.039 16/12/2022 Added more effects for creature powers.
+ * v4.040 26/01/2023 Updated getTokenValues() to use new configurable default token bar mappings
+ * v4.041 03/03/2023 Added effects for Rods, Staves & Wands. Fixed aoe maths for non-standard cell sizes.
+ * Extended --aoe parameters to support sequential --target command to overcome
+ * asynchronous command processing.
+ * v4.042 16/04/2023 Added ^^duration^^ attribute tag to sendAPImacro() to pass number of rounds passed
+ * on an effect turn for use in the Effect macro. Added effects to support added items.
+ * v4.043 21/05/2023 Added --rotateTracker command that takes 'on' or 'off' as a parameter to start or
+ * stop the rotation of the arrows surrounding the toekn at the top of the turn order.
+ * Fixed aoe targeting where the caster is the only target. Added more effects to
+ * support new items.
+ * v4.044 31/05/2023 Increment/decrement the Round Number by using +/- before the number with --reset.
+ * v4.045 07/07/2023 Additional effects added. Added "always" parameter to --start which forces the
+ * tracker into an active state. Updated menu colours to be more readable.
+ * Fixed tracker rotation status to be saved properly between sessions.
+ * v4.046 08/09/2023 Added error handling based on The Aaron's technique. Ensured switch statements are
+ * based on consistent case of characters.
+ * v4.047 08/10/2023 Fixed bug that prevented '-turn' effects working! Added '#' qualifier to duration
+ * value of --addstatus and related commands which spawns a new effect of the same
+ * name without causing clashes.
+ * v4.048 19/10/2023 Fixed issue with TokenMod calls not working unless the --api-as parameter is used
+ * v5.049 01/11/2023 Improved "Scabbard-of-Enchanting" effects. Improved timing & sequence of effect
+ * message output relative to turn announcements to make more obvious to players.
+ * v5.050 02/02/2024 Automated the delivery of dancing weapons by using "template" dancing effects.
+ * Added the --dancer command to support this automation
+ * v5.051 08/02/2024 Added '$#' as a duration option for statuses which overwrites current duration and
+ * also runs a '-start' effect.
+ * v5.052 20/02/2024 Store dynamic "dancer" effect definitions on character sheet of character wielding
+ * a dancing weapon, rather than in memory, so re-start rebuild is unnecessary. Added
+ * more effects in support of newly added magic items.
+ * v5.053 05/03/2024 Added new function --removeglobalstatus which removes a named status from all tokens.
+ * Added alt titles to token images so token names appear on hover. Fixed issues with
+ * doPlayerAddStatus(). Added new function --gm-target which forces a --target command
+ * as if it came from the GM.
+ * v5.054 01/04/2024 Fixed mob tokens not having statuses and markers moved between pages
+ * v5.055 26/05/2024 Additional and updated Effects to support latest RPGMaster features. --target multi mode.
+ * --target saving throws. --target-save and --target-nosave. --nosave to flag if
+ * --target-nosave actually = --target. Allow RPGM maths for numeric values such as
+ * "duration" and "direction" as well as save mods. Addition of '#' to RPGM maths to
+ * represent number of creatures targeted with a --target multi. Fix support of ^^duration^^
+ * tag in effects.
+ * v5.056 22/06/2024 Added --state-extract & --state-load functions to support migration to JumpGate
+ * v5.057 20/09/2024 Corrected --addStatus to accept saving throw and other extensions to args.
+ **/
+
+var RoundMaster = (function() {
+ 'use strict';
+ var version = 5.057,
+ author = 'Ken L. & RED',
+ pending = null;
+ const lastUpdate = 1726905793;
+
+ var RW_StateEnum = Object.freeze({
+ ACTIVE: 0,
+ PAUSED: 1,
+ STOPPED: 2,
+ FROZEN: 3
+ });
+
+ var PR_Enum = Object.freeze({
+ YESNO: 'YESNO',
+ CUSTOM: 'CUSTOM',
+ });
+
+ var TO_SortEnum = Object.freeze({
+ NUMASCEND: 'NUMASCEND',
+ NUMDESCEND: 'NUMDESCEND',
+ ALPHAASCEND: 'ALPHAASCEND',
+ ALPHADESCEND: 'ALPHADESCEND',
+ NOSORT: 'NOSORT'
+ });
+
+ var msg_orig = {};
+ var undoList = {};
+ const doneMsgDiv = '' ;
+
+ var fields = {
+ feedbackName: 'RoundMaster',
+ feedbackImg: 'https://s3.amazonaws.com/files.d20.io/images/11514664/jfQMTRqrT75QfmaD98BQMQ/thumb.png?1439491849',
+ trackerId: '',
+ trackerName: 'RoundMaster_tracker',
+ trackerImg: 'https://s3.amazonaws.com/files.d20.io/images/11920268/i0nMbVlxQLNMiO12gW9h3g/thumb.png?1440939062',
+ //trackerImg: 'https://s3.amazonaws.com/files.d20.io/images/6623517/8xw1KOSSOO1WocN3KQYmzw/thumb.png?1417994946',
+ coneImage: 'https://s3.amazonaws.com/files.d20.io/images/250318958/dFggs3eDRDXntGCEHDUbVw/thumb.png?1634215364',
+ trackerImgRatio: 2.25,
+ rotation_degree: 10,
+ effectlib: 'Effects-DB',
+ crossHairName: 'RoundMaster_crosshair',
+ chCircleImage: 'https://s3.amazonaws.com/files.d20.io/images/246879699/udrkMIWIio5-ZsMFlsdwSA/thumb.png?1632500227',
+ chSquareImage: 'https://s3.amazonaws.com/files.d20.io/images/246880604/wawFdevkLcoCWNElMEHt_g/thumb.png?1632500699',
+ chConeImage: 'https://s3.amazonaws.com/files.d20.io/images/246950559/Pliz5b-O8k_Sin7KuoPnJw/thumb.png?1632518407',
+
+ defaultTemplate: 'default',
+ initMaster: '!init',
+ attackMaster: '!attk',
+ dbVersion: ['db-version','current'],
+ Token_Thac0: ['bar2','value'],
+ Token_MaxThac0: ['bar2','max'],
+ Thac0_base: ['thac0-base','current'],
+ Thac0: ['thac0','current'],
+ MonsterThac0: ['monsterthac0','current'],
+ Token_HP: ['bar3','value'],
+ Token_MaxHP: ['bar3','max'],
+ HP: ['HP','current'],
+ Token_AC: ['bar1','value'],
+ Token_MaxAC: ['bar1','max'],
+ MonsterAC: ['monsterarmor','current'],
+ AC: ['AC','current'],
+ ItemWeaponList: ['spellmem','current'],
+ ItemArmourList: ['spellmem2','current'],
+ ItemRingList: ['spellmem3','current'],
+ ItemMiscList: ['spellmem4','current'],
+ ItemPotionList: ['spellmem10','current'],
+ ItemScrollList: ['spellmem11','current'],
+ ItemWandsList: ['spellmem12','current'],
+ ItemDMList: ['spellmem13','current'],
+ };
+
+ var dbNames = Object.freeze({
+ Effects_DB: {bio:' Token Marker Effects Macro Library v6.25 07/06/2024 This database holds the definitions for all token status effects. These are macros that optionally are triggered when a status of the same root name is placed on a token (statusname-start), each round it is still on the token (statusname-turn), and when the status countdown reaches zero or the token dies or is deleted (statusname-end) There are also other possible status conditions such as weaponname-inhand, weaponname-dancing and weaponname-sheathed. See the RoundMaster API documentation for further information. Important Note: Effects require a Roll20 Pro membership, and the installation of the ChatSetAttr, Tokenmod and RoundMaster API Scripts, to allow parameter passing between macros, update of character sheet variables, and marking spell effects on tokens. If you do not have this level of subscription, I highly recommend you get it as a DM, as you get lots of other goodies as well. If you want to know how to load the API Scripts to your game, the RoLL20 API help here gives guidance, or Richard can help you. Important Note for DMs: if a monster character sheet has multiple tokens associated with it, and token markers with associated Effects are placed on more than one of those Tokens, any Effect macros will run multiple times and, if changing variables on the Character Sheet using e.g. ChatSetAttr will make the changes multiple times to the same Character Sheet - generally this will cause unexpected results! If using these Effect macros for Effects that could affect monsters in this way, it is HIGHLY RECOMMENDED that a 1 monster Token : 1 character sheet approach is adopted.',
+ gmnotes:' Change Log: v6.25 07/06/2024 More updates to use latest features such as # duration and direction v6.24 27/04/2024 Updated effects affecting saves to use --set-savemod v6.22-3 04/04/2024 Added more MI effects v6.21 25/02/2024 Fixed Slow spell effects v6.20 23/02/2024 Added more effects for new items v6.19 08/02/2024 Added effects for Sword of Wounding v6.18 04/02/2024 Changed the way dancing weapons work by using template effect definitions v6.16 17/10/2023 A number of effect definition fixes v6.15 09/10/2023 Added dragon fear and roper attack effects v6.13 11/07/2023 More effects for powers, spells and items v6.09 03/03/2023 Added more effects for new magic items v6.08 16/12/2022 Added more creature effects, such as poisons v6.07 09/12/2022 Added effects to support the new Creatures database v6.06 14/11/2022 Added effects to support new Race Database & Powers v6.04 16/10/2022 Added effect for Spiritual-Hammer-end and for Chromatic-Orb Heat effects v6.03 12/10/2022 Changed the Initiative dice roll modification field from "comreact" to the new custom field "init-mod" v6.02 07/10/2022 Added new effects to support newly programmed magic items v6.01 11/05/2022 Added effects to turn on and off underwater infravision v5.8 04/02/2022 Fixed old field references when Raging v5.7 17/01/2022 Fixed magical To-Hit adjustments for Chant to work in same way as dmg adjustment v5.6 01/01/2022 Added multiple Effect Macros to support MagicMaster spell enhancements v5.2-5.5 skipped to bring version numbering in line across all APIs v5.1 10/11/2021 Changed to use virtual Token bar field names, so bar allocations can be altered v5.0 29/10/2021 First version loaded into roundMaster API v4.2.4 03/10/2021 Added Hairy Spider poison v4.2.3 23/05/2021 Added a Timer effect that goes with the Time-Recorder Icon, to tell you when a Timer you set starts and ends. v4.2.2 28/03/2021 Added Regeneration every Round for @conregen points v4.2.1 25/02/2021 Added end effect for Wandering Monster check, so it recurs every n rounds v4.2 23/02/2021 Added effect for Infravision to change night vision settings for token. v4.1 17/12/2020 Added effects for Dr Lexicon use of spells, inc. Vampiric Touch & Spectral Hand v4.0.3 09/11/2020 Added effects for Cube of Force v4.0.2 20/10/2020 Added effects of a Slow spell v4.0.1 17/10/2020 Added Qstaff-Dancing-turn to increment a dancing quarterstaff\'s round counter v4.0 27/09/2020 Released into the new Version 4 Testbed v1.0.1 16/09/2020 Initial full release for Lost & Found v0.1 30/08/2020 Initial testing version',
+ controlledby:'all',
+ root:'effects-db',
+ avatar:'https://s3.amazonaws.com/files.d20.io/images/2795868/caxnSIYW0gsdv4kOmO294w/thumb.png?1390102911',
+ version:6.25,
+ db:[{name:'3min-geyser-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtotracker 3min-Geyser|-1|[[1d10]]|0|3min Geyser blows'},
+ {name:'5min-geyser-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtotracker 5min-Geyser|-1|[[1d10]]|0|5min Geyser blows'},
+ {name:'AE-Aerial-Combat-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --fb-header ^^cname^^ has finished Aerial Combat --fb-content Loses bonuses to to-hit and damage --strengthhit||-1 --strengthdmg||-4'},
+ {name:'AE-Aerial-Combat-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --fb-header ^^cname^^ is undertaking Aerial Combat --fb-content Gains +1 bonus to-hit and +4 bonus to damage --strengthhit||+1 --strengthdmg||+4'},
+ {name:'Affected-by-Chill-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --charid ^^cid^^ --strengthhit||+0.5\n!magic --message ^^tid^^|Chill Touch|^^tname^^ will regain their attack effectiveness'},
+ {name:'Affected-by-Chill-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --charid ^^cid^^ --strengthhit||-0.5\n!magic --message ^^tid^^|Chill Touch|^^tname^^ will suffer an impact to their attack success for *every other* chill touch'},
+ {name:'Aid-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^cname^^\'s *Aid* has come to an end, and Thac0, saves \\amp HP return to normal\n!token-mod --api-as ^^pid^^ --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1 --set ^^token_hp^^|[[{ {^^hp^^},{@{^^cname^^|aid^^tid^^} } }kl1]]\n!attk --set-savemod ^^tid^^|delspell|Aid|Aid'},
+ {name:'Aid-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^cname^^ gains *Aid* from a Priest\'s god, improving Thac0, saves and HP\n!setattr --silent --name ^^cname^^ --aid^^tid^^|^^hp^^\n!token-mod --api-as ^^pid^^ --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1 --set ^^token_hp^^|+[[1d8]]\n!attk --set-savemod ^^tid^^|add|Aid|Aid|svsav:+1||^^duration^^'},
+ {name:'Armour-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --ac|@{^^cname^^|armour-ac}\n/w "^^cname^^" ^^tname^^\'s AC has returned to normal as the Armour spell has ended.'},
+ {name:'Armour-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --armour-ac|^^ac^^ --ac|[[6+@{^^cname^^|dexdefense}]]\n/w "^^cname^^" ^^tname^^\'s AC has been made AC6 (adjusted by deterity to AC[[6+@{^^cname^^|dexdefense}]]) by the Armour spell. Once taken [[8+@{^^cname^^|level-class2}]]HP, end the spell using the [End Armour](!rounds --removetargetstatus ^^tid^^|armour) button'},
+ {name:'Armour-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" Has ^^tname^^ taken [[8+@{^^cname^^|level-class2}]]HP yet? If so, end the spell using the [End Armour](!rounds --removetargetstatus ^^tid^^|armour) button.'},
+ {name:'Attk1-Interval-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --monsterdmg|@{^^cname^^|monsterdmg|max}'},
+ {name:'Attk1-Interval-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --monsterdmg|\'\'|@{^^cname^^|monsterdmg}'},
+ {name:'Attk2-Interval-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --monsterdmg2|@{^^cname^^|monsterdmg2|max}'},
+ {name:'Attk2-Interval-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --monsterdmg2|\'\'|@{^^cname^^|monsterdmg2}'},
+ {name:'Attk3-Interval-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --monsterdmg3|@{^^cname^^|monsterdmg3|max}'},
+ {name:'Attk3-Interval-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --monsterdmg3|\'\'|@{^^cname^^|monsterdmg3}'},
+ {name:'Bad-Luck-1-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|del|Bad Luck|Bad Luck\n!modattr --charid ^^cid^^ --strengthhit||+1 --fb-public --fb-header ^^cname^^\'s Luck Has Changed --fb-content ^^cname^^ is no longer suffering from bad luck, and attack rolls and saving throws have returned to normal.'},
+ {name:'Bad-Luck-1-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|add|Bad Luck|Bad Luck|svall:-1\n!modattr --charid ^^cid^^ --strengthhit||-1 --fb-public --fb-header ^^cname^^ is Suffering Bad Luck --fb-content ^^cname^^ starts to suffer bad luck on attack rolls and saving throws. An automatic penalty of -1 is applied to both.'},
+ {name:'Barkskin-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|@{^^cname^^|Barkskin^^tid^^}\n!attk --set-savemod ^^tid^^|delspell|not magic|Berkskin\n/w "^^cname^^" ^^cname^^\'s AC and saves return to normal as Barkskin fades'},
+ {name:'Barkskin-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --Barkskin^^tid^^|^^ac^^\n!token-mod --api-as ^^pid^^ --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|[[{ {^^ac^^}, {[[6-floor(@{^^cname^^|casting-level}/4)]]} }kl1]]\n!attk --set-savemod ^^tid^^|not magic|Barkskin|svpar:+1 svpoi:+1 svdea:+1 svrod:+1 svsta:+1 svwan:+1 svpol:+1 svpet:+1 svbre:+1||^^duration^^\n/w "^^cname^^" ^^cname^^\'s saves and AC might have improved as they get Barkskin'},
+ {name:'Bestow-Curse-51-75-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-4\n!attk --set-savemod ^^tid^^|del|Curse 51-75|Bestow Curse\n!magic --message w|^^tid^^|Cursed|^^cname^^ is no longer cursed: penalty of 4 to thac0 \\amp saves has been reversed'},
+ {name:'Bestow-Curse-51-75-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+4\n!attk --set-savemod ^^tid^^|add|Curse 51-75|Bestow Curse|svall:-4||^^duration^^\n!magic --message w|^^tid^^|Cursed|^^cname^^ is cursed: Thac0 and saves suffer a penalty of 4'},
+ {name:'Bigbys-Fist-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --blankweapon ^^tid^^|Bigbys-Clenched-Fist|silent'},
+ {name:'Bless-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ {{\n --ignore-selected\n --ids ^^tid^^\n --set ^^token_thac0^^|+1\n}}\n/w "^^cname^^" ^^cname^^\'s Bless has expired and their Thac0 has returned to normal\n!attk --set-savemod ^^tid^^|delspell|Fear|Bless'},
+ {name:'Bless-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^cname^^ has been blessed and their Thac0 and saves have improved\n!token-mod --api-as ^^pid^^ {{\n --ignore-selected\n --ids ^^tid^^\n --set ^^token_thac0^^|-1\n}}\n!attk --set-savemod ^^tid^^|fea=spe|Fear|Bless|svfea:+1||6'},
+ {name:'Blindness-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-4 --set ^^token_thac0^^|-4\n!init --setmods ^^tid^^|mod|Blindness|-2||silent\n!attk --set-savemod ^^tid^^|del|Blindness|Blindness\n/w "^^cname^^" ^^tname^^ has recovered from blindness and no longer suffers from penalties to attacks, AC and initiative'},
+ {name:'Blindness-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+4 --set ^^token_thac0^^|+4\n!init --setmods ^^tid^^|mod|Blindness|+2||silent\n!attk --set-savemod ^^tid^^|add|Blindness|Blindness|svall:-4\n/w "^^cname^^" ^^tname^^ has been blinded and suffers 4 penalty to attacks, saves \\amp AC, and 2 penalty to initiative'},
+ {name:'Blood-Frenzy-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|both|Blood Frenzy|+2|-2|silent\n/w "^^cname^^" ^^cname^^ is back to normal'},
+ {name:'Blood-Frenzy-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|both|Blood Frenzy|-2|+2|silent\n/w "^^cname^^" Being in a *blood frenzy*, ^^cname^^ moves twice as fast and has twice the number of attacks'},
+ {name:'Blowing-Horn-of-Fog-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target caster|^^tid^^|Horn of Fog|[[2d4]]|-1|Fog persists obscuring all sight inc infravision beyond 2 feet|half-haze'},
+ {name:'Blowing-Horn-of-Fog-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --aoe ^^tid^^|square|feet|0|10|10|black'},
+ {name:'Blowing-Horn-of-Fog-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --aoe ^^tid^^|square|feet|0|10|10|black'},
+ {name:'Bolas-Entanglement-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" Have you made a successful strength check? [Yes](!rounds --removetargetstatus ^^tid^^|Bolas Entanglement)'},
+ {name:'Boots-of-Dancing-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --fb-header Boots of Dancing --fb-content ^^cname^^\'s feet have stopped dancing (for the moment?). AC and Saves penalties are reversed --AC|-4\n!attk --set-savemod ^^tid^^|del|Boots of Dancing|Boots of Dancing'},
+ {name:'Boots-of-Dancing-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --fb-header Boots of Dancing --fb-content ^^cname^^\'s feet have started to dance, but not in a helpful way. AC penalty of 4, and Saving Throws at penalty of 6. --AC|+4\n!attk --set-savemod ^^tid^^|add|Boots of Dancing|Boots of Dancing|svall:-6'},
+ {name:'Boots-of-Flying-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --mi-charges ^^tid^^|-1|Boots-of-Flying||recharging'},
+ {name:'Bravery-1-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|fea=spe|Fear|Bravery|svfea:+4|1|480'},
+ {name:'Bravery-2-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|fea=spe|Fear|Bravery|svfea:+3|1|480'},
+ {name:'Bravery-3-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|fea=spe|Fear|Bravery|svfea:+2|1|480'},
+ {name:'Bravery-4-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|fea=spe|Fear|Bravery|svfea:+1|1|480'},
+ {name:'CO-Heat-vs-Creature-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignoreselected --ids ^^tid^^ --set ^^token_ac^^|-1 ^^token_thac0^^|-1 --report character|"^^tname^^ is no longer hot and their Thac0 and AC return to normal"'},
+ {name:'CO-Heat-vs-Creature-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignoreselected --ids ^^tid^^ --set ^^token_ac^^|+1 ^^token_thac0^^|+1 --report character|"^^tname^^ is weakened by heat and suffers a penalty of 1 to Thac0 and AC"'},
+ {name:'CO-Heat-vs-PC-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|+1|strength --change-attr ^^tid^^|+1|dexterity\n/w "^^cname^^" \\amp{template:default}{{name=Suffering from Heat}}{{^^cname^^ is no longer hot, and strength and dexterity no longer suffer a heat penalty}}'},
+ {name:'CO-Heat-vs-PC-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|-1|strength --change-attr ^^tid^^|-1|dexterity\n/w "^^cname^^" \\amp{template:default}{{name=Suffering from Heat}}{{^^cname^^ is suffering from overheating, and strength and dexterity are both impacted by a penalty}}'},
+ {name:'Candle-of-Invocation-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-2\n/w "^^cname^^" ^^tname^^ is no longer benefiting from the patronage of the gods of his alignment, and loses the temporarily 2 levels.'},
+ {name:'Candle-of-Invocation-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|2\n/w "^^cname^^" ^^tname^^ is benefiting from the patronage of the gods of his alignment, and is temporarily 2 levels higher.'},
+ {name:'Candle-of-Invocation-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --mi-charges ^^tid^^|-1|candle-of-invocation'},
+ {name:'Chant-ally-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --name ^^cname^^ --strengthdmg||-1 --strengthhit||-1\n!attk --set-savemod ^^tid^^|del|Ally|Chant\n/w "^^cname^^" The saves, attacks \\amp damage done by ^^tname^^ returns to normal as *Chant* ends'},
+ {name:'Chant-ally-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --name ^^cname^^ --strengthdmg||+1 --strengthhit||+1\n!attk --set-savemod ^^tid^^|add|Ally|Chant|svsav:+1|||\n/w "^^cname^^" The saves, attacks \\amp damage done by ^^tname^^ are improved by *Chant*'},
+ {name:'Chant-foe-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --name ^^cname^^ --strengthdmg||+1 --strengthhit||+1\n!attk --set-savemod ^^tid^^|del|Foe|Chant\n/w "^^cname^^" The saves, attacks \\amp damage done by ^^tname^^ returns to normal as *Chant* ends'},
+ {name:'Chant-foe-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --name ^^cname^^ --strengthdmg||-1 --strengthhit||-1\n!attk --set-savemod ^^tid^^|add|Foe|Chant|svsav:-1|||\n/w "^^cname^^" The saves, attacks \\amp damage done by ^^tname^^ are hindered by *Chant*'},
+ {name:'Chill-Touch-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --blank-weapon ^^tid^^|Chill-Touch|silent'},
+ {name:'Cloud-Giant-Strength-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!resetattr --silent --name ^^cname^^ --strength\n/w "^^cname^^" ^^cname^^ returns to their normal strength'},
+ {name:'Cloud-Giant-Strength-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|23|@{^^cname^^|strength}\n/w "^^cname^^" ^^cname^^ gains enormous strength'},
+ {name:'Colossal-Excavation-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target caster|^^tid^^|Resting after excavation|5|-1|Exhaused from excavation so resting|sleepy\n!magic --mi-charges ^^tid^^|-1|Spade-of-Colossal-Excavation||recharging'},
+ {name:'Constrict-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Constriction Damage}}{{Free=Once ^^tname^^ [breaks free](!rounds --removetargetstatus ^^tid^^|Giant Constrict) click here}}\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_hp^^|-[[1d3]] --report all|"{name} takes {^^token_hp^^:abschange} more damage from contriction"'},
+ {name:'Cube-of-Force-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid @{^^cname^^|Cube-user} --repeating_potions_$@{^^cname^^|Cube-row}_potionqty|@{^^cname^^|hp}\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set layer|gmlayer'},
+ {name:'Cube-of-Force-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modbattr --silent --charid @{^^cname^^|Cube-user} --repeating_potions_$@{^^cname^^|Cube-row}_potionqty|[[1-@{^^cname^^|Cube-charges}]]\n!modbattr --silent --charid ^^cid^^ --hp|[[1-@{^^cname^^|Cube-charges}]] \n!rounds --edit_status change %% ^^tid^^ %% cube-of-force %% duration %% [[{{[[@{^^cname^^|hp}-@{^^cname^^|Cube-charges}]]},{1}}kh1]] --edit_status change %% ^^tid^^ %% cube-of-force %% direction %% [[([[{{[[{{[[@{^^cname^^|hp}-@{^^cname^^|Cube-charges}]]},{1}}kl1]]},{0}}kh1]])-1]]'},
+ {name:'Cube-of-Force-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid @{^^cname^^|Cube-user} --repeating_potions_$@{^^cname^^|Cube-row}_potionqty|[[{{[[@{^^cname^^|hp}-([[(1-([[{{[[@{Initiative|round-counter}%10]]},{1}}kl1]] )) *@{^^cname^^|Cube-charges}]])]]},{0}}kh1]]\n!modbattr --silent --charid ^^cid^^ --hp|[[(([[{{[[@{Initiative|round-counter}%10]]},{1}}kl1]])-1)*@{^^cname^^|Cube-charges}]]\n!rounds --edit_status change %% ^^tid^^ %% cube-of-force %% duration %% [[{{@{^^cname^^|hp}},{1}}kh1]] --edit_status change %% ^^tid^^ %% cube-of-force %% direction %% [[([[{{[[{{@{^^cname^^|hp}},{1}}kl1]]},{0}}kh1]])-1]]'},
+ {name:'Curse-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1\n/w "^^cname^^" ^^tname^^ has recovered from being *Cursed*'},
+ {name:'Curse-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1\n/w "^^cname^^" ^^tname^^ has been *Cursed*, which affects their attacks and morale'},
+ {name:'Dancer-dancing',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|^^weapon^^-dancing|^^duration^^|-1|The ^^weapon^^ is Dancing by itself. Use this time wisely!|all-for-one\n!attk --quiet-modweap ^^tid^^|^^weapon^^|^^weaptype^^|sb:0,db:0,+:^^plusChange^^ --attk-hit ^^tid^^|Take an attack with the newly dancing weapon'},
+ {name:'Dancer-dancing-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|^^weapon^^|stop'},
+ {name:'Dancer-dancing-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|^^weapon^^|^^weaptype^^|+:^^plusChange^^'},
+ {name:'Dancer-inhand',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|^^weapon^^-inhand|[[^^duration^^+1]]|-1|^^weapon^^ not yet dancing so keep using it|stopwatch'},
+ {name:'Dancer-inhand-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|^^weapon^^'},
+ {name:'Dancer-inhand-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|^^weapon^^|^^weaptype^^|+:^^plusChange^^'},
+ {name:'Dancer-sheath',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --deltargetstatus ^^tid^^|^^weapon^^-inhand'},
+ {name:'Dancing-Longbow-dancing',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|Longbow-is-Dancing|4|-1|The Longbow is Dancing by itself. Use this time wisely!|all-for-one\n!attk --quiet-modweap ^^tid^^|Dancing-Longbow|ranged|sb:0,db:0'},
+ {name:'Dancing-Longbow-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|Dancing-Longbow'},
+ {name:'Dancing-Longbow-inhand',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|Dancing-Longbow|4|-1|Longbow not yet dancing so keep using it|stopwatch'},
+ {name:'Dancing-Longbow-sheath',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --deltargetstatus ^^tid^^|Dancing-Longbow'},
+ {name:'Dancing-Longbow-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|Dancing-Longbow|ranged|+:+1'},
+ {name:'Dancing-Quarterstaff-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|Quarterstaff-of-Dancing|stop'},
+ {name:'Dancing-Quarterstaff-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|quarterstaff-of-dancing|melee|+:+1 --quiet-modweap ^^tid^^|quarterstaff-of-dancing|dmg|+:+1'},
+ {name:'Deafness-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|mod|Deafness|-1||silent\n/w "^^cname^^" ^^tname^^ has recovered from deafness and no longer suffers an initiative penalty'},
+ {name:'Deafness-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|mod|Deafness|+1||silent\n/w "^^cname^^" ^^tname^^ has been deafened and suffers an initiative penalty, as well as other effects'},
+ {name:'Delayed-blast-fireball-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Delayed Blast Fireball}}{{The fireball finally explodes. Click [Explode](!rounds --aoe \\amp#64;{target|Select Fireball Seed|token_id}|circle|feet|0|40||fire|true) and select the fireball seed you placed earlier, then click [Damage](!\\amp#13;\\amp#47;r [[([[({10, @{^^cname^^|mu-casting-level} }kl1)]] + [[([[({10, @{^^cname^^|mu-casting-level} }kl1)]]d6)]])]] damage from delayed-blast fireball) to see how much damage it does}}'},
+ {name:'Divine-Favour-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^cname^^\'s Divine Favour has run its course, and their Thac0 returns to normal\n!token-mod --api-as ^^pid^^ {{\n --ignore-selected\n --ids ^^tid^^\n --set ^^token_thac0^^|+4\n}}'},
+ {name:'Divine-Favour-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^cname^^ has been granted a Divine Favour and their Thac0 has improved by 4!\n!token-mod --api-as ^^pid^^ {{\n --ignore-selected\n --ids ^^tid^^\n --set ^^token_thac0^^|-4\n}}'},
+ {name:'Djinni-Whirlwind-building-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target caster|^^tid^^|Djinni-Whirlwind|99|0|Whirlwind now usable as transport or as a weapon|lightning-helix\n!magic --message ^^tid^^|Djinni Whirlwind|The whirlwind has now built to full speed and is usable as transport or as a weapon'},
+ {name:'Dragon-Fear-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --strengthdmg||+2 --strengthhit||+2 --fb-header ^^cname^^ is no longer afraid --fb-content ^^cname^^ has overcome their fear. Their attack and damage rolls are no longer affected by it.'},
+ {name:'Dragon-Fear-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --strengthdmg||-2 --strengthhit||-2 --fb-header ^^cname^^ is afraid! --fb-content ^^cname^^ has seen the dragon and is afraid! ^^cname^^ suffers -2 penalty to both attack and damage rolls'},
+ {name:'Earache-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --strengthhit||+2 --fb-header Pipes of Pain --fb-content ^^cname^^ is no longer suffering from the sound of the Pipes of Pain\n!attk --set-savemod ^^tid^^|delspell|earache|Pipes of Pain'},
+ {name:'Eating-Heroes-Feast-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|Heroes Feast|720|-1|Blessed, +1 to attk, immune to poison, fear, hopelessness, panic|angel-outfit'},
+ {name:'Enchanted-by-Scabbard-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|@{^^cname^^|Scabbard-Weapon}|Melee|+:-1 --quiet-modweap ^^tid^^|@{^^cname^^|Scabbard-Weapon}|Dmg|+:-1 \n/w "^^cname^^" \\amp{template:default}{{name=Scabbard of Enchanting}}{{=^^tname^^, @{^^cname^^|Scabbard-Weapon} has now lost its additional enchantment from the Scabbard. [Sheath it again](!rounds --target caster|^^tid^^|Scabbard-of-Enchanting|10|-1|Enchanting a Sheathed weapon|stopwatch\\amp#13;!attk --weapon ^^tid^^|Sheath weapon in Scabbard of Enchanting - take new one in hand)}}'},
+ {name:'Enfeeble-monster-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-2 ^^token_thac0_max^^|+2\nThe monster has recovered from being enfeebled'},
+ {name:'Enfeeble-monster-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+2 ^^token_thac0_max^^|-2\nThe monster has been enfeebled'},
+ {name:'Enraged-by-Scarab-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1 ^^token_ac^^|+3\n!modattr --silent --charid ^^cid^^ --strengthdmg||-2\n/w gm ^^tname^^ thac0, damage \\amp AC return to normal'},
+ {name:'Enraged-by-Scarab-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1 ^^token_ac^^|-3\n!modattr --silent --charid ^^cid^^ --strengthdmg||2\n/w gm ^^tname^^ thac0 +1, dmg +2, AC -3'},
+ {name:'Exhausted-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --fb-public --charid ^^cid^^ --fb-from Effects --fb-header ^^cname^^ has recovered from Exhaustion --thac0-base|-2 --ac|-2 --strengthdmg||+2'},
+ {name:'Faerie-fire-darkness-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-2\n^^tname^^ has lost that glow and is now harder to aim at'},
+ {name:'Faerie-fire-darkness-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+2\n^^tname^^ is surrounded by Faerie Fire, and becomes much easier to hit'},
+ {name:'Faerie-fire-twilight-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-1\n^^tname^^ has lost that glow and is now harder to aim at'},
+ {name:'Faerie-fire-twilight-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+1\n^^tname^^ is surrounded by Faerie Fire, and becomes easier to hit'},
+ {name:'Fire-Giant-Strength-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!resetattr --silent --name ^^cname^^ --strength\n/w "^^cname^^" ^^cname^^ returns to their normal strength'},
+ {name:'Fire-Giant-Strength-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|22|@{^^cname^^|strength}\n/w "^^cname^^" ^^cname^^ gains enormous strength'},
+ {name:'Flame-walk-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|delspell|Magic Fire|Flame-walk'},
+ {name:'Flame-walk-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|mfi=spe|Magic Fire|Flame-walk|svmfi:+2||^^duration^^'},
+ {name:'Flaming-oil-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set layer|gmlayer '},
+ {name:'Follow-the-Standard-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1'},
+ {name:'Follow-the-Standard-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1'},
+ {name:'Foresight-caster-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+2\n/w "^^cname^^" ^^tname^^\'s AC has changed as a result of losing *foresight*'},
+ {name:'Foresight-caster-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-2\n/w "^^cname^^" ^^tname^^\'s AC has been improved by *foresight*\n'},
+ {name:'Frost-Giant-Strength-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!resetattr --silent --name ^^cname^^ --strength\n/w "^^cname^^" ^^cname^^ returns to their normal strength'},
+ {name:'Frost-Giant-Strength-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|21|@{^^cname^^|strength}\n/w "^^cname^^" ^^cname^^ gains enormous strength'},
+ {name:'GS-acid-dmg-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^cname^^ takes [[1d10]] HP of acid damage from the burning on their feet!'},
+ {name:'Gem-of-Brightness-Light-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --light ^^tid^^|@{^^cname^^|lightsource}'},
+ {name:'Gem-of-Brightness-Light-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set emits_bright_light|no emits_low_light|yes has_directional_bright_light|no has_directional_dim_light|yes bright_light_distance|0 low_light_distance|10 directional_dim_light_center|0 directional_dim_light_total|15'},
+ {name:'Giant-Constrict-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Constriction Damage}}{{Free=Once ^^tname^^ [breaks free](!rounds --removetargetstatus ^^tid^^|Giant Constrict) click here}}\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_hp^^|-[[2d4]] --report all|"{name} takes {^^token_hp^^:abschange} more damage from contriction"'},
+ {name:'Giant-Sea-Constrict-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Constriction Damage}}{{Free=Once ^^tname^^ [breaks free](!rounds --removetargetstatus ^^tid^^|Giant Sea Constrict) click here}}\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_hp^^|-[[3d6]] --report all|"{name} takes {^^token_hp^^:abschange} more damage from contriction"'},
+ {name:'Giant-Snake-Poison-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Giant Snake Poison}}{{Poison=Save vs. Poison}}{{Succeed=^^tname^^ takes only damage from bite.}}{{Fail=^^tname^^ immediately **dies** from poisoning (and takes the damage from the bite...)}}'},
+ {name:'Glitterdust-glitter-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^tname^^ has recovered from Glitterdust sparkle which fades away'},
+ {name:'Glitterdust-glitter-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target caster|^^tid^^|Blindness|[[1d4+1]]|-1|Blinded by Glitterdust, -4 on attk, save \\amp AC|bleeding-eye\n/w "^^cname^^" As well as being blinded, ^^tname^^ is also covered in glitter until it fades'},
+ {name:'Hairy-Spider-Poison-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-1 ^^token_thac0^^|-1\n!modattr --silent --charid ^^cid^^ --dexterity|+3'},
+ {name:'Hairy-Spider-Poison-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+1 ^^token_thac0^^|+1\n!modattr --silent --charid ^^cid^^ --dexterity|-3'},
+ {name:'Harp-Suggestion-Recharging',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --mi-rest ^^tid^^|Harp-of-Charming|1|Suggestion'},
+ {name:'Haste-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|del|Haste|0|1|silent\n/w "^^cname^^" One year older, ^^cname^^ is back to normal'},
+ {name:'Haste-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|both|Haste|=-2|=2|silent\n/w "^^cname^^" Being *Hasted*, ^^cname^^ moves twice as fast and has twice the number of attacks\n'},
+ {name:'Heroes-Feast-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" The effects of Heroes Feast have worn off, and ^^tname^^ returns to normal\n!attk --set-savemod ^^tid^^|delspell|Immune|Heroes Feast\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1'},
+ {name:'Heroes-Feast-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1\n!attk --set-savemod ^^tid^^|fea=spe|Immune to fear|Heroes Feast|svfea:+20 --set-savemod ^^tid^^|add|Immune to poison|Heroes Feast|svpoi:+20\n/w "^^cname^^" Having eaten a Heroes Feast, ^^tname^^ gains benefits to attacks as well as other bonuses'},
+ {name:'Heway-poison-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Water Poisoned by Heway}}{{Save=Save vs. Poison at +2 bonus}}{{Succeed=Take 15HP damage}}{{Fail=30HP damage \\amp paralysed for 1d6 hours}}\n/w gm \\amp{template:default}{{name=Heway Poison Paralysation}}{{=If creature failed to save, press [Paralysed](!rounds --target-nosave caster|^^tid^^|Paralysis|\\amp#91;[60*1d6]\\amp#93;|-1|Paralysed by water poisoned by a Heway snake|padlock) to add a status marker}}'},
+ {name:'Hill-Giant-Strength-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!resetattr --silent --name ^^cname^^ --strength\n/w "^^cname^^" ^^cname^^ returns to their normal strength'},
+ {name:'Hill-Giant-Strength-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|19|@{^^cname^^|strength}\n/w "^^cname^^" ^^cname^^ gains enormous strength'},
+ {name:'Incendiary-Cloud-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!delattr --silent --charid ^^cid^^ --incendiary-count'},
+ {name:'Incendiary-Cloud-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --incendiary-count|-2|10'},
+ {name:'Incendiary-Cloud-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Incendiary Cloud}}{{This round, the cloud does @{^^cname^^|mu-casting-level}d[[({ [[({ @{^^cname^^|incendiary-count}, @{^^cname^^|incendiary-count|max} }kl1)]], 0}kh1)]]HP [incendiary damage](!\\amp#13;\\amp#47;r @{^^cname^^|mu-casting-level}d[[({ [[({ @{^^cname^^|incendiary-count}, @{^^cname^^|incendiary-count|max} }kl1)]], 0}kh1 )]] incendiary damage). Click button to make the roll}}\n!modattr --silent --charid ^^cid^^ --incendiary-count|+2|-2'},
+ {name:'Increasing-Strength-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|+[[{{18-@{^^cname^^|strength}},{1}}kl1]]|strength|silent --change-attr ^^tid^^|+[[{{18-@{^^cname^^|constitution}},{1}}kl1]]|constitution|silent\n!rounds --target-nosave caster|^^tid^^|Increasing-Strength_Ring-Effect|100|-10|The ring is having an unknown effect|spanner'},
+ {name:'Increasing-Weakness-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|[[{{3-@{^^cname^^|strength}},{-1}}kh1]]|strength|silent --change-attr ^^tid^^|[[{{3-@{^^cname^^|constitution}},{-1}}kh1]]|constitution|silent\n!rounds --target-nosave caster|^^tid^^|Increasing-Weakness_Ring-Effect|100|-10|The ring is having an unknown effect|spanner'},
+ {name:'Infested-with-Vermin-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --set-mods ^^tid^^|del|Infested by Vermin|||silent\n/w "^^cname^^" \\amp{template:default}{{name=Itching \\amp Scratching}}{{Oooo... That\'s better! The itching and scratching seem to have stopped}}'},
+ {name:'Infested-with-Vermin-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --set-mods ^^tid^^|fix|Infested by Vermin|+51||silent\n/w "^^cname^^" \\amp{template:default}{{name=Itching \\amp Scratching}}{{Your robe seems to be infested with biting insects - fleas, mosquitos, ants, and other sorts. You can\'t stop itching \\amp scratching, to the extent that you are only 50% effective!}}'},
+ {name:'Infravision-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off has_night_vision\n/w "^^cname^^" "Who turned out the lights?" ^^tname^^ no longer has night vision.'},
+ {name:'Infravision-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --on has_night_vision --set night_distance|60\n/w "^^cname^^" ^^tname^^ has gained 60ft infravision, which brightens up their night!'},
+ {name:'Invisibility-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+4\n!attk --set-savemod @{selected|token_id}|del|Invisibility|Invisibility\n/w "^^cname^^" Becoming visible means ^^cname^^\'s AC and saves return to normal'},
+ {name:'Invisibility-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-4\n!attk --set-savemod @{selected|token_id}|add|Invisibility|Invisibility|svsav:+4\n/w "^^cname^^" Being invisible improves ^^cname^^\'s AC and saves by 4'},
+ {name:'Invulnerability-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+2\n!attk --set-savemod ^^tid^^|del|Invulnerability|Potion of Invulnerability\n/w "^^cname^^" ^^tname^^ is no longer invulnerable-ish'},
+ {name:'Invulnerability-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-2\n!attk --set-savemod ^^tid^^|add|Invulnerability|Potion of Invulnerability|svsav:+2\n/w "^^cname^^" \\amp{template:default}{{name=Potion of Invulnerability}}{{^^tname^^ becomes invulnerable to normal attacks from many creatures (but not all, and not magical attacks), and in any case gains a benefit of 2 on AC and saves}}'},
+ {name:'Irritate-Rash-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target single|^^tid^^|^^tid^^|Rash|99|0|Broken out in Rash all over, Charisma \\amp Dexterity reducing|radioactive'},
+ {name:'Irritation-Itch-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target-nosave caster|^^tid^^|Irritation-Squirm|3|-1|Twisting \\amp squirming penalties attk 2 \\amp AC 4|screaming\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+2 ^^token_ac^^|+2\n/w "^^cname^^" As a consequence of not spending a round itching, ^^tname^^ squirms and twists in discomfort with consequential impact on AC and Thac0'},
+ {name:'Irritation-Itch-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w gm \\amp{template:default}{{name=Irritation Itch}}{{Has ^^tname^^ stopped to scratch the itch? [Yes](!rounds --deltargetstatus ^^tid^^|Irritation-Itch)}}'},
+ {name:'Irritation-Rash-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target-nosave caster|^^tid^^|Irritation Disease|99|-1|Covered in an ugly rash: Chr -1pd for 4 days; Dex -1 after 1wk|radioactive\n/w ^^cname^^ ^^tname^^ has caught some type of disease which affects Charisma (-1 per day for 4 days) and potentially Dexterity (-1 after 1 week) - apply all effects manually'},
+ {name:'Irritation-Squirm-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-2 ^^token_ac^^|-2\n/w "^^cname^^" The itch receeds, and ^^tname^^\'s AC and Thac0 return to normal'},
+ {name:'Light-duration-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w gm **Delete the light spell token** - the light spell has ended'},
+ {name:'Light-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-4 --set ^^token_thac0^^|-4\n!attk --set-savemod ^^tid^^|delspell|Blinded|Light\n/w "^^cname^^" ^^tname^^ has recovered from blindness and no longer suffers from penalties to attacks, saves and AC'},
+ {name:'Light-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+4 --set ^^token_thac0^^|+4\n!attk --set-savemod ^^tid^^|add|Blinded|Light|svall:-4||^^duration^^\n/w "^^cname^^" ^^tname^^ has been blinded by light and suffers 4 penalty to attacks \\amp AC \\amp saves'},
+ {name:'Lightbringer-mace-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off emits_bright_light emits_low_light\n/w "^^cname^^" ^^cname^^ has commanded his mace to go dark'},
+ {name:'Lightbringer-mace-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --on emits_bright_light emits_low_light --set bright_light_distance|15 low_light_distance|15\n/w "^^cname^^" ^^cname^^\'s mace now shines as bright as a torch.'},
+ {name:'Longbow-is-Dancing-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|Dancing-Longbow|stop'},
+ {name:'Longbow-is-Dancing-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|Dancing-Longbow|ranged|+:+1'},
+ {name:'Luck-bonus-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|del|Luck bonus|Luck bonus'},
+ {name:'Luck-bonus-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|add|Luck Bonus|Luck Bonus|svall:+1\n/w "^^cname^^" \\amp{template:default}{{name=Luck Bonus}}{{Has a luck bonus on saves and checks of +1 while in the area of effect.}}'},
+ {name:'Melfs-Acid-Arrow-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message w|^^tid^^|Melfs Acid Arrow|^^cname^^ takes a final [2d4](!token-mod --api-as ^^pid^^ ~~ignore-selected ~~ids ^^tid^^ ~~set ^^token_hp^^¦-\\amp#91;[\\amp#63;{How much acid damage is done?¦2d4}]\\amp#93; ~~report character:gm¦\\amp#34;^^cname^^ takes a final {^^token_hp^^:abschange} hp of acid damage\\amp#34; all¦\\amp#34;The acid eats away at ^^cname^^ for the last time\\amp#34;) hp acid damage'},
+ {name:'Melfs-Acid-Arrow-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message c|^^tid^^|Melfs Acid Arrow|^^cname^^ takes [2d4](!token-mod --api-as ^^pid^^ ~~ignore-selected ~~ids ^^tid^^ ~~set ^^token_hp^^|-\\amp#91;[\\amp#63;{How much acid damage is done?¦2d4}]\\amp#93; ~~report character:gm¦\\amp#34;^^cname^^ takes an additional {^^token_hp^^:abschange} hp of acid damage\\amp#34; all¦\\amp#34;The acid eats away at ^^cname^^\\amp#34;) hp additional acid damage'},
+ {name:'Mesmerized-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target-nosave single|^^tid^^|Mesmerised still|[[10*2d6]]|-10|Still mesmerized even though the snake has looked elsewhere|chained-heart'},
+ {name:'Nauseous-2-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --charid ^^cid^^ --strengthhit||+2\n/w "^^cname^^" ^^tname^^ is no longer feeling nauseous, so is no longer subject to a penalty of 2 on attacks'},
+ {name:'Nauseous-2-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --charid ^^cid^^ --strengthhit||-2\n/w "^^cname^^" ^^tname^^ is feeling very nauseous and is now at a -2 penalty to hit on attacks'},
+ {name:'Numbed-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|mult|Numbed by cold||1|silent\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-2\n/w ^^cname^^ ^^tname^^ has warmed up somewhat and can now move and attack more like normal'},
+ {name:'Numbed-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!init --setmods ^^tid^^|mult|Numbed by cold||=0.5|silent\n!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+2\n/w ^^cname^^ ^^tname^^ has been numbed by cold and is moving slower and finding it more difficult to hit things...'},
+ {name:'Oil-fire-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Oil Fire Damage Round 2}}{{Damage=^^tname^^ takes another [1d6](!\\amp#13;\\amp#47;roll 1d6)HP of fire damage from the burning oil}}'},
+ {name:'Oil-of-Fumbling-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Oil of Fumbling}}{{desc=Anything ^^tname^^ holds seems incredibly slippery! ^^tname^^ has a 50% chance of dropping anything held, including weapons, spell components, scroll being read, the sandwich they are about to take a bite out of...}}{{desc1=Roll [d6](!\\amp#13;\\amp#47;r 1d6cf\\lt3cs\\gt4) to check if ^^tname^^ drops what they are holding}}'},
+ {name:'Ottos-irresistable-dance-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-4\n!attk --set-savemod ^^tid^^|delspell|dancing|Ottos Irresistable Dance\n/w ^^cname^^ \\amp{template:default}{{name=Otto\'s Irresistable Dance}}{{^^tname^^ has enjoyed dancing, but it\'s now time to stop. You can take a shield in-hand again if you want}}'},
+ {name:'Ottos-irresistable-dance-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+4\n!attk --set-savemod ^^tid^^|add|dancing|Ottos Irresistable Dance|svall:=-20\n/w ^^cname^^ \\amp{template:default}{{name=Otto\'s Irresistable Dance}}{{^^tname^^ has an irresistable urge to dance! If you have any shields in-hand, drop them now as they have no effect while dancing}}'},
+ {name:'Philter-of-Persuasiveness-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --fb-public --fb-header ^^tname^^ Returns to Normal --fb-content ^^tname^^\'s persuasiveness has returned to _CUR0_ --chareact|-5'},
+ {name:'Philter-of-Persuasiveness-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --fb-public --fb-header ^^tname^^ Becomes More Persuasive --fb-content ^^tname^^\'s persuasiveness has improved by 5 to be _CUR0_ --chareact|+5'},
+ {name:'Philter-of-Stammering-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --fb-public --fb-header ^^tname^^ Returns to Normal --fb-content ^^tname^^\'s persuasiveness has returned to _CUR0_ --chareact|+5'},
+ {name:'Philter-of-Stammering-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --fb-public --fb-header ^^tname^^ Stammers \\amp Stutters --fb-content ^^tname^^ can\'t get their words straight and their persuasiveness has dropped to _CUR0_ --chareact|-5'},
+ {name:'Pipes-of-Pain-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|add|Pain|Pipes of Pain|svall:-2\n!modattr --charid ^^cid^^ --strengthhit||-2 --fb-header Pipes of Pain --fb-content ^^cname^^ is still suffering from the sound of the Pipes of Pain, with -2 on attack and saving throw rolls. A *forget* or *remove curse* is required to end this effect.\n!rounds --target caster|^^tid^^|Earache|99|0|Continuing effect of Pipes of Pain automatically applied|pummeled'},
+ {name:'Poison-A-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type A}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type A|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[15]]HP of damage from the poison --save ^^tid^^|||poison) or ^^tname^^ takes **[[15]]HP** of damage from poison. No damage taken if succeed}}'},
+ {name:'Poison-B-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type B}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Tyep B|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[20]]HP of damage from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[1d3]]HP** damage. If fail ^^tname^^ takes **[[20]]HP** of damage from poison}}'},
+ {name:'Poison-C-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type C}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type C|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[25]]HP of damage from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[2d4]]HP** damage. If *fail* ^^tname^^ takes **[[25]]HP** of damage from poison}}'},
+ {name:'Poison-D-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type D}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type D|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[30]]HP of damage from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[2d6]]HP** damage. If *fail* ^^tname^^ takes **[[30]]HP** of damage from poison}}'},
+ {name:'Poison-G-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type G}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type G|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[20]]HP of damage from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[10]]HP** damage. If *fail* ^^tname^^ takes **[[20]]HP** of damage from poison}}'},
+ {name:'Poison-H-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type H}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type H|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[20]]HP of damage from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[10]]HP** damage. If *fail* ^^tname^^ takes **[[20]]HP** of damage from poison}}'},
+ {name:'Poison-I-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type I}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type I|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[30]]HP of damage from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[15]]HP** damage. If *fail* ^^tname^^ takes **[[30]]HP** of damage from poison}}'},
+ {name:'Poison-J-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type J}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type J|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and immediately ***dies*** from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[20]]HP** damage. If *fail* ^^tname^^ immediately **dies** from poisoning}}'},
+ {name:'Poison-K-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type K}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type K|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[5]]HP of damage from the poison --save ^^tid^^|||poison) or ^^tname^^ takes **[[5HP]]** of damage from poison. If save no damage is taken}}'},
+ {name:'Poison-L-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type L}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type L|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[10]]HP of damage from the poison --save ^^tid^^|||poison) or ^^tname^^ takes **[[10]]HP** of damage from poison. No damage taken if succeed}}'},
+ {name:'Poison-M-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type M}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type M|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[20]]HP of damage from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[5]]HP** damage. If *fail* ^^tname^^ takes **[[20]]HP** of damage from poison}}'},
+ {name:'Poison-N-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type N}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Type N|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and immediately ***dies*** from the poison --save ^^tid^^|||poison). If *succeed*, ^^tname^^ takes **[[25]]HP** damage. If *fail* ^^tname^^ immediately **dies** from poisoning}}'},
+ {name:'Poison-O-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type O}}{{Poison=[Save vs. Poison](!rounds --target caster|^^tid^^|Paralysed|99|0|Paralysed by poison type O for [[2d6]] hours|padlock|svpoi\\clon;+0) or ^^tname^^ becomes *paralysed* by poison. No damage taken if succeed}}'},
+ {name:'Poison-P-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type P}}{{Poison=[Save vs. Poison](!rounds --target caster|^^tid^^|Debilitated|99|0|Debilitated by poison type P for [[1d3]] days|back-pain|svpoi\\clon;+0) or ^^tname^^ becomes *debilitated* by poison}}{{Effect=Debilitating poisons weaken the character for 1d3 days. All of the character\'s ability scores are reduced by half during this time. All appropriate adjustments to attack rolls, damage, Armor Class, etc., from the lowered ability scores are applied during the course of the illness. In addition, the character moves at one-half his normal movement rate. Finally, the character cannot heal by normal or magical means until the poison is neutralized or the duration of the debilitation is elapsed.}}{{Saved=No damage taken if save}}'},
+ {name:'Poison-Snake-1-4-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Snake Poison 1-4}}{{Poison=[Save vs. Poison](!rounds --target caster|^^tid^^|Paralysed|99|0|Incapacitatedby poison type P for [[2d4]] days|back-pain|svpoi\\clon;+3) at +3 bonus or ^^tname^^ becomes *incapacitated* by poison}}{{Effect=Incapacitating poisons weaken the character for 2 to 8 days. All of the character\'s ability scores are reduced by half during this time. All appropriate adjustments to attack rolls, damage, Armor Class, etc., from the lowered ability scores are applied during the course of the illness. In addition, the character moves at one-half his normal movement rate. Finally, the character cannot heal by normal or magical means until the poison is neutralized or the duration of the debilitation is elapsed.}}{{Saved=Only takes the damage from the bite}}'},
+ {name:'Poison-Snake-12-14-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Snake Poison 12-14}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Snake|Poison|svpoi\\clon;+0|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[3d4]]HP of damage from the poison --save ^^tid^^|||poison) or ^^tname^^ takes **3d4 HP** of damage from poison. If succeed only takes the damage from the bite}}'},
+ {name:'Poison-Snake-15-17-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Snake Poison 15-17}}{{Poison=[Save vs. Poison](!rounds --target caster|^^tid^^|Paralysed|99|0|Incapacitatedby poison type P for [[1d4]] days|back-pain|svpoi\\clon;-1) at -1 penalty or ^^tname^^ becomes *incapacitated* by poison}}{{Effect=Incapacitating poisons weaken the character for 1 to 4 days. All of the character\'s ability scores are reduced by half during this time. All appropriate adjustments to attack rolls, damage, Armor Class, etc., from the lowered ability scores are applied during the course of the illness. In addition, the character moves at one-half his normal movement rate. Finally, the character cannot heal by normal or magical means until the poison is neutralized or the duration of the debilitation is elapsed.}}{{Saved=Only takes the damage from the bite}}'},
+ {name:'Poison-Snake-18-19-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Snake Poison 18-19}}{{Poison=[Save vs. Poison](!rounds --target caster|^^tid^^|Paralysed|99|0|Incapacitatedby poison type P for [[1d12]] days|back-pain|svpoi\\clon;-2) at -2 penalty or ^^tname^^ becomes *incapacitated* by poison}}{{Effect=Incapacitating poisons weaken the character for 1 to 12 days. All of the character\'s ability scores are reduced by half during this time. All appropriate adjustments to attack rolls, damage, Armor Class, etc., from the lowered ability scores are applied during the course of the illness. In addition, the character moves at one-half his normal movement rate. Finally, the character cannot heal by normal or magical means until the poison is neutralized or the duration of the debilitation is elapsed.}}{{Saved=Only takes the damage from the bite}}'},
+ {name:'Poison-Snake-20-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Snake Poison 20}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Snake|Poison|svpoi\\clon;-3|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ seems to be dead --save ^^tid^^|||poison) at -3 penalty}}{{Succeed=^^tname^^ only takes the damage from the bite}}{{Fail=^^tname^^ immediately **dies** from poisoning (and takes damage from the bite...)}}'},
+ {name:'Poison-Snake-5-6-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Poison Type N}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Snake|Poison|svpoi\\clon;+2|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ seems to be dead --save ^^tid^^|||poison) at +2 bonus}}{{Succeed=^^tname^^ only takes the damage from the bite}}{{Fail=^^tname^^ immediately **dies** from poisoning (and takes damage from the bite...)}}'},
+ {name:'Poison-Snake-7-11-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Snake Poison 7-11}}{{Poison=[Save vs. Poison](!attk --set-savemod ^^tid^^|add|Snake|Poison|svpoi\\clon;+1|1||!magic ~~message ^^tid^^|Poison|Oh dear.. ^^tname^^ failed and takes [[2d4]]HP of damage from the poison --save ^^tid^^|||poison) at +1 bonus or ^^tname^^ takes **2d4 HP** of damage from poison. If succeed only takes the damage from the bite}}'},
+ {name:'Potion-of-Arms-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --change-hands ^^tid^^|-[[2*@{^^cname^^|Potion-of-Arms-doses}]]\n!delattr --charid ^^cid^^ --silent --Potion-of-Arms-doses'},
+ {name:'Potion-of-Heroism-1-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-1|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Potion-of-Heroism-1-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|1|fighter||[[1d10+3]]||fighter --message public|^^tid^^|Potion of Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+1]] Fighter'},
+ {name:'Potion-of-Heroism-2-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-2|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Potion-of-Heroism-2-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|2|fighter||[[2d10+2]]||fighter --message public|^^tid^^|Potion of Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+2]] Fighter'},
+ {name:'Potion-of-Heroism-3-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-3|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Potion-of-Heroism-3-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|3|fighter||[[3d10+1]]||fighter --message public|^^tid^^|Potion of Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+3]] Fighter'},
+ {name:'Potion-of-Heroism-4-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-4|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Potion-of-Heroism-4-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|4|fighter||[[4d10]]||fighter --message public|^^tid^^|Potion of Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+4]] Fighter'},
+ {name:'Prayer-ally-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1\n!modattr --silent --name ^^cname^^ --strengthdmg||-1\n!attk --set-savemod ^^tid^^|delspell|Ally|Prayer\n/w "^^cname^^" ^^tname^^ loses the benefit of *Prayer*'},
+ {name:'Prayer-ally-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1\n!modattr --silent --name ^^cname^^ --strengthdmg||+1\n!attk --set-savemod ^^tid^^|add|Ally|Prayer|svsav:+1||^duration^^\n/w "^^cname^^" ^^tname^^ gains the benefit of *Prayer*, with improved saves, attacks and damage'},
+ {name:'Prayer-foe-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1\n!modattr --silent --name ^^cname^^ --strengthdmg||+1\n!attk --set-savemod ^^tid^^|delspell|Foe|Prayer\n/w "^^cname^^" ^^tname^^ loses the impact of *Prayer*'},
+ {name:'Prayer-foe-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1\n!modattr --silent --name ^^cname^^ --strengthdmg||-1\n!attk --set-savemod ^^tid^^|add|Foe|Prayer|svsav:-1||^^duration^^\n/w "^^cname^^" ^^tname^^ bears the penalties of *Prayer*, with worse saves, attacks and damage'},
+ {name:'Produce-Flame-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --blank-weapon ^^tid^^|Produce Flame|silent'},
+ {name:'Prot-from-Evil-10ft-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Prot-from-Evil-10ft-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius|10ft aura1_color|0ff'},
+ {name:'Prot-from-Good-10ft-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Prot-from-Good-10ft-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius|10ft aura1_color|0ff'},
+ {name:'Prot-vs-Lightning-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|delspell|Electricity|Prot-vs-Lightning'},
+ {name:'Prot-vs-Lightning-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|elc=spe|Electricity|Prot-vs-Lightning|svelc:+4||^^duration^^\n!attk --set-savemod ^^tid^^|elb=bre|Elec Breath|Prot-vs-Lightning|svelb:+4||^^duration^^'},
+ {name:'Protection-vs-Acid-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Protection vs Acid}}{{desc=If taken 20 Hit Dice of damage, [End Protection](!rounds --removetargetstatus ^^tid^^|Protection-vs-Acid)}}'},
+ {name:'Protection-vs-Cold-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Cold-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|10ft aura1_color|b4d8fc'},
+ {name:'Protection-vs-Electricity-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Electricity-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|10ft aura1_color|b4d8fc'},
+ {name:'Protection-vs-Elementals-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Elementals-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|10ft aura1_color|faf214'},
+ {name:'Protection-vs-Fiends-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Fiends-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|8ft aura1_color|0ff'},
+ {name:'Protection-vs-Fire-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Fire-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|15ft aura1_color|3f7fbf'},
+ {name:'Protection-vs-Gas-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Gas-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|4ft aura1_color|f214fa'},
+ {name:'Protection-vs-Lycanthropes-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Lycanthropes-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|8ft aura1_color|a40316'},
+ {name:'Protection-vs-Magic-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Magic-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|4ft aura1_color|5beaf9'},
+ {name:'Protection-vs-Petrification-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Petrification-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|4ft aura1_color|e7f95b'},
+ {name:'Protection-vs-Plants-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Plants-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|4ft aura1_color|0ff'},
+ {name:'Protection-vs-Shape-Changers-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Protection-vs-Shape-Changers-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --off aura1_square --set aura1_radius|8ft aura1_color|a40316'},
+ {name:'Qstaff-Dancing-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --name ^^cname^^ --dancing-round|[[(([[@{^^cname^^|dancing-round}]])%4)+1]]'},
+ {name:'Quaals-Feather-Whip-Token-sheathed',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --removetargetstatus ^^tid^^|Quaals-Feather-Whip'},
+ {name:'Quaals-Feather-Whip-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|Quaals-Feather-Whip-Token|stop --blank-weapon ^^tid^^|Quaals-Feather-Whip-Token|silent\n!magic --mi-charges ^^tid^^|-1|Quaals-Feather-Whip-Token||charged'},
+ {name:'Quaals-Feather-Whip-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|Quaals-Feather-Whip-Token\n'},
+ {name:'Quarterstaff-of-Dancing-dancing',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|Dancing-Quarterstaff|4|-1|The Quarterstaff is Dancing by itself. Use this time wisely!|all-for-one\n!attk --quiet-modweap ^^tid^^|quarterstaff-of-dancing|melee|sb:0 --quiet-modweap ^^tid^^|quarterstaff-of-dancing|dmg|sb:0'},
+ {name:'Quarterstaff-of-Dancing-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --dance ^^tid^^|Quarterstaff-of-Dancing'},
+ {name:'Quarterstaff-of-Dancing-inhand',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|Quarterstaff-of-Dancing|4|-1|Quarterstaff not yet dancing so keep using it|stopwatch'},
+ {name:'Quarterstaff-of-Dancing-sheath',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --deltargetstatus ^^tid^^|Quarterstaff-of-Dancing'},
+ {name:'Quarterstaff-of-Dancing-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|quarterstaff-of-dancing|melee|+:+1 --quiet-modweap ^^tid^^|quarterstaff-of-dancing|dmg|+:+1\nUpdating the quarterstaff +1 to attk \\amp dmg'},
+ {name:'Rage-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --fb-public --charid ^^cid^^ --fb-from Effects --fb-header ^^cname^^ is now Exhausted --thac0-base|+4 --ac|+4 --strengthdmg||[[-4]] --hp|-15\n!rounds --addtargetstatus ^^tid^^|Exhausted|10|-1|Exhausted - 2 worse on attk,dmg,ac|radioactive'},
+ {name:'Rage-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --fb-public --charid ^^cid^^ --fb-from Effects --fb-header ^^cname^^ is Raging! --thac0-base|-2 --ac|-2 --strengthdmg||2 --hp|+15'},
+ {name:'Ray-of-Enfeeblement-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|@{^^cname^^|strength|max}\n/w "^^cname^^" ^^tname^^ has recovered from enfeeblement'},
+ {name:'Ray-of-Enfeeblement-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|5|@{^^cname^^|strength}\n/w "^^cname^^" ^^tname^^ has been enfeebled, with impact on strength affecting hits and damage!\n'},
+ {name:'Regeneration-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_hp^^|+[[@{^^cname^^|conregen}]]! --report control|"{name} regenerates {^^token_hp^^:change} HP"'},
+ {name:'Repel-Insects-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius| '},
+ {name:'Repel-Insects-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set aura1_radius|10ft aura1_color|0ff'},
+ {name:'Resting-after-excavation-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --mi-charges ^^tid^^|01|Spade-of-Colossal-Excavation --message ^^tid^^|Spade of Colossal Excavation|You feel rested and can now do up to another 10 roundsof excavation work if you need to'},
+ {name:'Ring-Shocking-Grasp-Recharge-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --mi-charges ^^tid^^|0|Ring-of-Shocking-Grasp|3 --message ^^tid^^|Ring of Shocking Grasp|The *Ring of Shocking Grasp* has recharged and can now be taken in-hand again to attack with'},
+ {name:'Ring-of-Blinking-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^tname^^ has stopped bkinking and their ring needs to recharge for 1 hour before it can be used again\n!rounds --target caster|^^tid^^|Ring-of-Blinking-recharge|60|-1|Ring of Blinking is recharging|stopwatch'},
+ {name:'Ring-of-Blinking-recharge-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^tname^^\'s Ring of Blinking has recharged and can now be used again\n!magic --mi-charges ^^tid^^|0|Ring-of-Blinking|1\n'},
+ {name:'RoSC-Hypnotized-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target caster|^^tid^^|RoSC-Save2Attk|99|0|Make a saving throw vs. spell to attack or be hypnotized again|interdiction'},
+ {name:'Rod-of-Flailing-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --ac|+4 --fb-header ^^tid^^\'s Rod of Flailing charge is expended --fb-content ^^tid^^ looses their +4 bonus to AC and saving throws\n!attk --set-savemod ^^tid^^|del|Rod of Flailing|Rod of Flailing'},
+ {name:'Rod-of-Flailing-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --ac|-4 --fb-header ^^tid^^ uses Rod of Flailing charge --fb-content ^^tid^^ gains a +4 bonus to AC and saving throws\n!attk --set-savemod ^^tid^^|del|Rod of Flailing|Rod of Flailing|svsav:+4'},
+ {name:'Rope-of-Constriction-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --hp|-[[2d6]] --fb-header Rope of Constriction --fb-content The rope tightens around ^^tname^^\'s neck! ^^tname^^ has taken _TCUR0_ points of constriction damage.'},
+ {name:'Rope-of-Constriction-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --hp|-[[2d6]] --fb-header Rope of Constriction --fb-content The rope tightens even more around ^^tname^^\'s neck! ^^tname^^ has taken another _TCUR0_ points of constriction damage.'},
+ {name:'RoperAttack-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message w|^^tid^^|Roper Poison!|^^cname^^ must [**save vs. poison**](!rounds ~~target caster¦^^tid^^¦RoperPoison¦#[[20*2d4]]¦-2¦Feeling weak - lost half strength for each poisoning¦back-pain¦svpoi\\clon;+0) or immediately lose strength'},
+ {name:'RoperAttack-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message w|^^tid^^|^^cname^^ is Entangled!|^^cname^^ has been ensnared in the tenticle that has attacked them and is being pulled towards the creature...'},
+ {name:'RoperBite-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message w|^^tid^^|Roper Bite|^^cname^^ takes damage this round from the bite of the Roper who has reeled you in unless the GM agrees that you break free --message gm|^^tid^^|Roper Bite|^^cname^^ takes [[5d4]] points of damage from the Roper bite. If they [break free](!rounds ~~removetargetstatus ^^tid^^¦RoperBite) click the button\n'},
+ {name:'RoperPoison-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|*2|Strength|false'},
+ {name:'RoperPoison-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|/2|Strength|false'},
+ {name:'RoperStrand-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message w|^^tid^^|Roper Bite|^^cname^^ takes damage this round from the bite of the Roper who has reeled you in unless the GM agrees that you break free --message gm|^^tid^^|Roper Bite|^^cname^^ takes [[5d4]] points of damage from the Roper bite. If they [break free](!rounds ~~removetargetstatus ^^tid^^¦RoperBite) click the button \n!rounds --target-nosave caster|^^tid^^|RoperBite|#99|0|You are still trapped by the roper and taking damage|arrowed'},
+ {name:'RoperStrand-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message w|^^tid^^|Being Dragged|^^cname^^ is being dragged towards the creature. Have you broken free yet? [Yes](!rounds ~~deltargetstatus ^^tid^^¦RoperStrand)'},
+ {name:'Scabbard-Enchanting-draw-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --quiet-modweap ^^tid^^|@{^^cname^^|Equip-InHand}|Melee|+:+1 --quiet-modweap ^^tid^^|@{^^cname^^|Equip-InHand}|Dmg|+:+1\n!setattr --silent --charid ^^cid^^ --Scabbard-Weapon|@{^^cname^^|Equip-InHand}\n!rounds --target-nosave caster|^^tid^^|Enchanted-by-Scabbard|10|-1|Your blade has been improved by +1 by the Scabbard of Enchantment|all-for-one\n/w "^^cname^^" \\amp{template:default}{{name=Scabbard of Enchanting}}{{=^^tname^^, @{^^cname^^|Equip-InHand} is now an additional +1. [Sheath another blade](!rounds --target-nosave caster|^^tid^^|Scabbard-of-Enchanting|10|-1|Enchanting a Sheathed weapon|stopwatch)}}\n'},
+ {name:'Scabbard-Enchanting-draw-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --weapon ^^tid^^|Draw your blade from the Scabbard of Enchanting, from next round it will be an additional +1. This round\'s action is now ***Change Weapon*** and you should not do anything else!'},
+ {name:'Scabbard-of-Enchanting-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Scabbard of Enchantment}}{{=The weapon in the *Scabbard of Enchantment* is now improved by +1. [Draw from Scabbard](!rounds --target caster|^^tid^^|Scabbard-Enchanting-draw|1|-1|The weapon from the Scabbard of Enchanting is being enchanted|all-for-one) or leave until the next melee \\amp use the *Scabbard* then to draw it.}}'},
+ {name:'Scabbard-of-Enchanting-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --weapon ^^tid^^|Sheath a blade into the Scabbard of Enchanting, and keep it sheathed for 10 rounds. This round\'s action is now ***Change Weapon*** and you should not do anything else!'},
+ {name:'Scarab-Insanity-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w gm \\amp{template:default}{{name=Scarab of Insanity}}{{desc=Affected by a *scarab of insanity*. Do [[1d10]] of:\n[[1]] Wander away for duration of spell unless prevented\n[[2-6]] Stand confused for 1 round then roll again\n[[7-10]] Attack nearest creature for 1 round then roll again}}'},
+ {name:'Scintillating-Colours-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --Scintillating-AC|0'},
+ {name:'Scintillating-Robe-AC-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!round --target-nosave caster|^^tid^^|Scintillating-Robe|99|0|Robe is still scintillating|bolt-shield'},
+ {name:'Scintillating-Robe-AC-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --charid ^^cid^^ --AC|-1 --Scintillating-AC|+1'},
+ {name:'Scintillating-Robe-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --AC|%Scintillating-AC% --fb-header Robe of Scintillating Colours --fb-content The robe stops scintillating and ^^cname^^\'s armour class reduces'},
+ {name:'Scroll-of-Weakness-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|*2|Strength|false'},
+ {name:'Scroll-of-Weakness-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|/2|Strength|false'},
+ {name:'Sertens-Immunity-casting-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Serten\'s Spell Immunity}}{{Another creature can now recieve [Serten\'s Spell Immunity](!rounds --target-nosave single|^^tid^^|\\amp#64;{target|Who to give immunity to?|token_id}|Sertens-Immunity|@{^^cname^^|spell-duration}|-1|Better saves against many spells - see PHB p192|white-tower). Click the button and target them}}'},
+ {name:'Sertens-Immunity-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|delspell||Sertens Immunity'},
+ {name:'Sertens-Immunity-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --set-savemod ^^tid^^|w13=spe|wiz 1-3|Sertens Immunity|svw13:+9| --set-savemod ^^tid^^|w46=spe|wiz 4-6|Sertens Immunity|svw46:+7| --set-savemod ^^tid^^|w78=spe|wiz 7-8|Sertens Immunity|svw78:+5| --set-savemod ^^tid^^|p13=spe|prist 1-3|Sertens Immunity|svp13:+7| --set-savemod ^^tid^^|p46=spe|priest 4-6|Sertens Immunity|svp46:+5| --set-savemod ^^tid^^|p78=spe|priest 7|Sertens Immunity|svp78:+3|'},
+ {name:'Shield-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --^^token_ac^^|@{^^cname^^|Temp-AC}\n/w "^^cname^^" ^^cname^^ loses his magic shield'},
+ {name:'Shield-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --Temp-AC|@{^^ac^^} --^^token_ac^^|3\n/w "^^cname^^" ^^cname^^ is shielded by magic.'},
+ {name:'Slow-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-[[4+[[abs([[{{@{^^cname^^|norm_dexdefense}},{0}}kl1]])]]]] ^^token_thac0^^|-4\n!setattr --silent --name ^^cname^^ --dexreact|@{^^cname^^|norm_dexreact} --dexmissile|@{^^cname^^|norm_dexmissile} --dexdefense|@{^^cname^^|norm_dexdefense}\n!init --setmods ^^tid^^|del|Slow|0|1|silent\n/w "^^cname^^" ^^tname^^ is moving at their normal speed again, and their AC and attacks have returned to normal'},
+ {name:'Slow-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+[[4+[[abs([[{{@{^^cname^^|dexdefense}},{0}}kl1]])]]]] ^^token_thac0^^|+4\n!setattr --silent --name ^^cname^^ --norm_dexreact|@{^^cname^^|dexreact} --norm_dexmissile|@{^^cname^^|dexmissile} --norm_dexdefense|@{^^cname^^|dexdefense} --dexreact|[[{{@{^^cname^^|dexreact}},{0}}kl1]] --dexmissile|[[{{@{^^cname^^|dexmissile}},{0}}kl1]] --dexdefense|[[{{@{^^cname^^|dexdefense}},{0}}kh1]]\n!init --setmods ^^tid^^|both|Slow|=2|=0.5|silent\n/w "^^cname^^" ^^tname^^ is moving in slow motion, with worse AC and attacks '},
+ {name:'Snake-Poison-3-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" ^^cname^^ takes [[2d4]]hp of damage from the poison injected by the snake that bit them.\n/w gm ^^cname^^ takes [[2d4]]hp of damage from the poison injected by the snake that bit them.'},
+ {name:'Something-wrong-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|GS-Acid-dmg|99|0|Take acid damage to feet|tread'},
+ {name:'Spectral-hand-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --fb-from Effects --fb-header ^^tname^^\'s Spectral Hand fades away --fb-content They can no longer cast L1-4 touch spells at a distance, and Thac0 returns to _CUR0_ --thac0|+2'},
+ {name:'Spectral-hand-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --fb-from Effects --fb-header ^^tname^^ uses Spectral Hand --fb-content By doing so, they can cast L1-4 touch spells at a distance at +2, so Thac0 is now _CUR0_ --thac0|-2'},
+ {name:'Spiritual-Hammer-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!attk --blank-weapon ^^tid^^|Spiritual-Hammer|silent'},
+ {name:'Stone-Giant-Strength-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!resetattr --silent --name ^^cname^^ --strength\n^^cname^^ returns to their normal strength'},
+ {name:'Stone-Giant-Strength-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|20|@{^^cname^^|strength}\n^^cname^^ gains enormous strength'},
+ {name:'Stoneskin-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --duration|^^duration^^\n/w "^^cname^^" set number of attacks that can be absorbed to ^^duration^^'},
+ {name:'Stoneskin-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" Click [Absorb Attack](!modattr --charid ^^cid^^ --duration|-1 --fb-header Stoneskin --fb-content Another attack absorbed. Can take _CUR0_ more attacks) and don\'t take any damage, but only if the attack is physical. Once reports zero attacks left, click [End Stoneskin](!rounds --removetargetstatus ^^tid^^|stoneskin) button.'},
+ {name:'Storm-Giant-Strength-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!resetattr --silent --name ^^cname^^ --strength\n/w "^^cname^^" ^^cname^^ returns to their normal strength'},
+ {name:'Storm-Giant-Strength-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --name ^^cname^^ --strength|24|@{^^cname^^|strength}\n/w "^^cname^^" ^^cname^^ gains enormous strength'},
+ {name:'Strength-Drain-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --strength|@{^^cname^^|strength|max}\n/w "^^cname^^" ^^tname^^ is feeling somewhat stronger, back to their normal self... perhaps...'},
+ {name:'Stun-Dart-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target caster|^^tid^^|slow|4|-1|Slowly recovering from the effects of the Stun Dart gas, penalty of 4 to attks \\amp AC, slower initiative \\amp no dex bonuses|snail'},
+ {name:'Suffocating-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'\\amp{template:default}{{title=^^cname^^ Suffocates}}{{desc="argh! i can\'t breathe..." ^^cname^^ dies of suffocation by the Rug of Smothering}}'},
+ {name:'Sunlight-1-toHit-Penalty-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-1'},
+ {name:'Sunlight-1-toHit-Penalty-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+1'},
+ {name:'Super-heroism-2-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-2|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Super Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Super-heroism-2-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|2|fighter||[[1d10+4]]||fighter --message public|^^tid^^|Potion of Super Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+2]] Fighter'},
+ {name:'Super-heroism-3-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-3|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Super Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Super-heroism-3-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|3|fighter||[[2d10+3]]||fighter --message public|^^tid^^|Potion of Super Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+3]] Fighter'},
+ {name:'Super-heroism-4-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-4|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Super Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Super-heroism-4-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|4|fighter||[[3d10+2]]||fighter --message public|^^tid^^|Potion of Super Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+4]] Fighter'},
+ {name:'Super-heroism-5-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-5|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Super Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Super-heroism-5-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|5|fighter||[[4d10+1]]||fighter --message public|^^tid^^|Potion of Super Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+5]] Fighter'},
+ {name:'Super-heroism-6-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --level-change ^^tid^^|-6|fighter||[[{ {(@{^^cname^^|hp}-@{^^cname^^|pot-heroism-hp})},{0}}kh1]]||fighter --message public|^^tid^^|Potion of Super Heroism|^^tname^^ loses their improved abilities as a fighter, and returns to their normal self\n!delattr --silent --charid ^^cid^^ --pot-heroism-hp'},
+ {name:'Super-heroism-6-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --charid ^^cid^^ --silent --pot-heroism-hp|@{^^cname^^|hp}\n!magic --level-change ^^tid^^|6|fighter||[[5d10]]||fighter --message public|^^tid^^|Potion of Super Heroism|With a masterful fighting career, ^^tname^^ drinks a potion and is now suddenly a Level [[@{^^cname^^|level-class1}+6]] Fighter'},
+ {name:'Symbol-Pain-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-4\n!magic --change-attr ^^tid^^|+2|dexterity|silent\n/w "^^cname^^" ^^tname^^ is no longer wracked with pain and the penalties to attacks and dexterity are lifted'},
+ {name:'Symbol-Pain-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+4\n!magic --change-attr ^^tid^^|-2|dexterity|silent\n/w "^^cname^^" ^^tname^^ is wracked with pain and suffers a -4 penalty to attacks and -2 penalty to dexterity'},
+ {name:'Tashas-UHL-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --name ^^cname^^ --strength|2\n/w "^^cname^^" ^^cname^^ stops laughing and regains strength'},
+ {name:'Tashas-UHL-monster-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|-2 ^^token_thac0_max^^|+2\n/w "^^cname^^" The monster regains strength as they stop laughing'},
+ {name:'Tashas-UHL-monster-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set ^^token_thac0^^|+2 ^^token_thac0_max^^|-2\n/w "^^cname^^" The monster loses strength as they laugh so hard!'},
+ {name:'Tashas-UHL-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --name ^^cname^^ --strength|-2\n/w "^^cname^^" ^^cname^^ loses strength as they laugh so hard!'},
+ {name:'Thunderclap-stun-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --addtargetstatus ^^tid^^|Deafness|[[1d2]]|-1|No longer stunned, but still deafened by the thunderclap|interdiction\n/w gm ^^tname^^ is no longer stunned by the thunderclap, but is still deafened from it'},
+ {name:'Underwater-infravision-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set night_distance|-60'},
+ {name:'Underwater-infravision-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!token-mod --api-as ^^pid^^ --ignore-selected --ids ^^tid^^ --set night_vision|yes night_distance|+60'},
+ {name:'VT-bonus-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --fb-public --fb-from Effects --fb-header ^^tname^^ looses their vampiric hit point bonus --fb-content ^^tname^^\'s HP return to _CUR0_ as the effects of the Vampiric Touch spell fade away --charid ^^cid^^ --hp|[[{{@{^^cname^^|hp|max}},{@{^^cname^^|hp}}}kl1]]'},
+ {name:'Vampiric-touch-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!rounds --target caster|^^tid^^|VT-bonus|60|-1|Benefiting from stolen health|strong'},
+ {name:'Water-trap-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!roll20AM --audio,play|Glasses breaking\n!roll20AM --audio,play|breaking-window\n!token-mod --api-as ^^pid^^ --ignore-selected --ids @{^^cname^^|water-id} --set layer|objects\n/w gm Read Rm26 notes on Breaking Glass for full description of effects'},
+ {name:'Weak-Ring-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!setattr --silent --charid ^^cid^^ --strength|[[{{[[@{^^cname^^|strength}-1]]},{3}}kh1]] --constitution|[[{{[[@{^^cname^^|constitution}-1]]},{3}}kh1]]\n!rounds --target caster|^^tid^^|^^tid^^|Weak Ring|100|-10||blank'},
+ {name:'Weakened-by-Chill-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|+1|Strength --message ^^tid^^|Chill Touch|^^tname^^ has regained a point of Strength or increased exceptional percentage'},
+ {name:'Weakened-by-Chill-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --change-attr ^^tid^^|-1|Strength --message ^^tid^^|Chill Touch|^^tname^^ has lost a point of strength (or a decrease in exceptional percentage strength)'},
+ {name:'Wings-Flying-12-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Wings of Flying}}{{=Cannot fly anymore - ^^cname^^ is totally exhausted and now needs to rest}}\n!rounds --target-nosave caster|^^tid^^|Wings-Flying-resting|60|-1|Must now rest by sleeping, lying down or sitting|sleepy)}}'},
+ {name:'Wings-Flying-15-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Wings of Flying}}{{=Continue Flying? [Yes](!rounds --target-nosave caster|^^tid^^|Wings-Flying-12|20|-1|Flying at a speed of up to 12|fluffy-wing) or [No](!rounds --target-nosave caster|^^tid^^|Wings-Flying-resting|60|-1|Must now rest by sleeping, lying down or sitting|sleepy)}}'},
+ {name:'Wings-Flying-18-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Wings of Flying}}{{=Continue Flying? [Yes](!rounds --target-nosave caster|^^tid^^|Wings-Flying-15|20|-1|Flying at a speed of up to 15|fluffy-wing) or [No](!rounds --target-nosave caster|^^tid^^|Wings-Flying-resting|60|-1|Must now rest by sleeping, lying down or sitting|sleepy)}}'},
+ {name:'Wings-Flying-25-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Wings of Flying}}{{=Continue Flying? [Yes](!rounds --target-nosave caster|^^tid^^|Wings-Flying-18|10|-1|Flying at a speed of up to 18|fluffy-wing) or [No](!rounds --target-nosave caster|^^tid^^|Wings-Flying-resting|60|-1|Must now rest by sleeping, lying down or sitting|sleepy)}}'},
+ {name:'Wings-Flying-32-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Wings of Flying}}{{=Continue Flying? [Yes](!rounds --target-nosave caster|^^tid^^|Wings-Flying-25|10|-1|Flying at a speed of up to 25|fluffy-wing) or [No](!rounds --target-nosave caster|^^tid^^|Wings-Flying-resting|60|-1|Must now rest by sleeping, lying down or sitting|sleepy)}}'},
+ {name:'Wings-Flying-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Wings of Flying}}{{=Continue Flying? [Yes](!rounds --target-nosave caster|^^tid^^|Wings-Flying-32|10|-1|Flying at a speed of up to 32|fluffy-wing) or [No](!rounds --target-nosave caster|^^tid^^|Wings-Flying-quiet-walking|60|-1|Not resting but can only walk slowly and take it easy|snail)}}'},
+ {name:'Wings-Flying-resting-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'/w "^^cname^^" \\amp{template:default}{{name=Wings of Flying}}{{=^^cname^^ has finished resting and can now participate in normal activities}}'},
+ {name:'WoI-Audible-Illusion-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message ^^tid^^|Wand of Illusion|Would you like to continue concentrating on the illusion? If so [click here](!magic ~~mi-charges ^^tid^^¦-1¦Wand-of-Illusion\\amp#13;!rounds ~~target-nosave caster¦^^tid^^¦WoI Audible Illusion¦10¦-10¦An audible illusion with no visual component cast from a Wand of Illusion¦half-haze)'},
+ {name:'WoI-Audio-Visual-Illusion-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message ^^tid^^|Wand of Illusion|Would you like to continue concentrating on the illusion? If so [click here](!magic ~~mi-charges ^^tid^^¦-2¦Wand-of-Illusion\\amp#13;!rounds ~~target-nosave caster¦^^tid^^¦WoI Audio-Visual Illusion¦10¦-10¦An illusion with both audible and visual components cast from a Wand of Illusion¦lightning-helix)'},
+ {name:'WoI-Visual-Illusion-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!magic --message ^^tid^^|Wand of Illusion|Would you like to continue concentrating on the illusion? If so [click here](!magic ~~mi-charges ^^tid^^¦-1¦Wand-of-Illusion\\amp#13;!rounds ~~target-nosave caster¦^^tid^^¦WoI Visual Illusion¦10¦-10¦A visual illusion with no audible component cast from a Wand of Illusion¦ninja-mask)'},
+ {name:'Wounding-end',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --charid ^^cid^^ --Wound-count|-1 --HP|-1'},
+ {name:'Wounding-start',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --charid ^^cid^^ --Wound-count|+1 --fb-header Sword of Wounding --fb-content Another wound means that ^^cname^^ is now taking _CUR0_ additional points of wound damage per round'},
+ {name:'Wounding-turn',type:'',ct:'0',charge:'uncharged',cost:'0',body:'!modattr --silent --charid ^^cid^^ --HP|-1'},
+ ]},
+ });
+
+ const dbTypeLists = {
+ Miscellaneous: {type:'Miscellaneous',field:fields.ItemMiscList},
+ Light: {type:'Miscellaneous',field:fields.ItemMiscList},
+ Weapon: {type:'Weapon',field:fields.ItemWeaponList},
+ Melee: {type:'Weapon',field:fields.ItemWeaponList},
+ Ranged: {type:'Weapon',field:fields.ItemWeaponList},
+ Ammo: {type:'Weapon',field:fields.ItemWeaponList},
+ Armour: {type:'Armour',field:fields.ItemArmourList},
+ Ring: {type:'Ring',field:fields.ItemRingList},
+ Potion: {type:'Potion',field:fields.ItemPotionList},
+ Scroll: {type:'Scroll',field:fields.ItemScrollList},
+ Rod: {type:'Rod',field:fields.ItemWandsList},
+ Staff: {type:'Rod',field:fields.ItemWandsList},
+ Wand: {type:'Rod',field:fields.ItemWandsList},
+ MUspellL1: {type:'MUspellL1',field:['spellmem','current']},
+ MUspellL2: {type:'MUspellL2',field:['spellmem2','current']},
+ MUspellL3: {type:'MUspellL3',field:['spellmem3','current']},
+ MUspellL4: {type:'MUspellL4',field:['spellmem4','current']},
+ MUspellL5: {type:'MUspellL5',field:['spellmem30','current']},
+ MUspellL6: {type:'MUspellL6',field:['spellmem5','current']},
+ MUspellL7: {type:'MUspellL7',field:['spellmem6','current']},
+ MUspellL8: {type:'MUspellL8',field:['spellmem7','current']},
+ MUspellL9: {type:'MUspellL9',field:['spellmem8','current']},
+ MUspellL0: {type:'MUspellL0',field:['spellmem20','current']},
+ PRspellL1: {type:'PRspellL1',field:['spellmem10','current']},
+ PRspellL2: {type:'PRspellL2',field:['spellmem11','current']},
+ PRspellL3: {type:'PRspellL3',field:['spellmem12','current']},
+ PRspellL4: {type:'PRspellL4',field:['spellmem13','current']},
+ PRspellL5: {type:'PRspellL5',field:['spellmem14','current']},
+ PRspellL6: {type:'PRspellL6',field:['spellmem15','current']},
+ PRspellL7: {type:'PRspellL7',field:['spellmem16','current']},
+ PRspellL0: {type:'PRspellL0',field:['spellmem17','current']},
+ Power: {type:'Power',field:['spellmem23','current']},
+ };
+
+ var flags = {
+ rw_state: RW_StateEnum.STOPPED,
+ image: true,
+ animating: true,
+ archive: false,
+ clearonclose: false,
+ // RED: clear turnOrder on new round if true
+ clearonnewround: true,
+ // RED: determine turnorder sort order on new round
+ newRoundSort: TO_SortEnum.NUMASCEND,
+ // RED: v1.207 determine if ChatSetAttr is present
+ canSetAttr: true,
+ // RED: v1.207 determine if Initiative character sheet is present
+ canSetRoundCounter: true,
+ // RED: v2.007 added to allow roundMaster to work with initMaster
+ canUseInitMaster: true,
+ // RED: v1.301 determine if all markers must be unique
+ uniqueMarkers: false,
+ // RED: v4.032 determine if missing libraries should be notified
+ notifyLibErr: true,
+ // RED: v5.055 flag if doing a --target multi cmd to trap only caster chosen
+ multiCaster: false,
+ };
+
+ var design = {
+ turncolor: '#D8F9FF',
+ roundcolor: '#363574',
+// statuscolor: '#F0D6FF',
+ statuscolor: '#9400D3',
+// statusbgcolor: '#897A87',
+ statusbgcolor: '#D3D3D3',
+ statusbordercolor: '#430D3D',
+ edit_icon: 'https://s3.amazonaws.com/files.d20.io/images/11380920/W_Gy4BYGgzb7jGfclk0zVA/thumb.png?1439049597',
+ delete_icon: 'https://s3.amazonaws.com/files.d20.io/images/11381509/YcG-o2Q1-CrwKD_nXh5yAA/thumb.png?1439051579',
+ settings_icon: 'https://s3.amazonaws.com/files.d20.io/images/11920672/7a2wOvU1xjO-gK5kq5whgQ/thumb.png?1440940765',
+ apply_icon: 'https://s3.amazonaws.com/files.d20.io/images/11407460/cmCi3B1N0s9jU6ul079JeA/thumb.png?1439137300',
+ selected_button: '"display: inline-block; background-color: white; border: 1px solid red; padding: 4px; color: red; font-weight: bold;"',
+ };
+
+ var aoeImages = Object.freeze ({
+ ACID: {ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331456697/fK6w6GuAWAi-iqIuOQYGJw/thumb.png?1678211835',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331470528/Fx2pfAZGUG9bUrWifIsdjA/thumb.png?1678218097',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250364885/1iJyxTjkOYhLc5l-b9k5YQ/thumb.png?1634238992',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250364901/UMOTJRtZBfs-2kIMQdHmkQ/thumb.png?1634238999',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250364917/jidptAhe0zyUj3GERQj3CA/thumb.png?1634239006',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250364901/UMOTJRtZBfs-2kIMQdHmkQ/thumb.png?1634238999',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250365029/dey5IsSH-Ndzzv6RYxqVJQ/thumb.png?1634239057',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250365029/dey5IsSH-Ndzzv6RYxqVJQ/thumb.png?1634239057'},
+ COLD: {ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331458150/rLhZcyN5uLqbgbSDzKnzGQ/thumb.png?1678212486',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331470565/l25HNGIVmshY7fhYFx8L3w/thumb.png?1678218110',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250365049/6jB7qYbNL0SXpGj2_b3hiw/thumb.png?1634239066',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250365063/1s-17jF8nj5ceyI6CVdl9Q/thumb.png?1634239072',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250334846/UTt7fuOj_v-PxiZP-2YwpQ/thumb.png?1634226450',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250365063/1s-17jF8nj5ceyI6CVdl9Q/thumb.png?1634239072',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250365292/gftCVVIY-it7-rUDj60Fig/thumb.png?1634239180',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250365292/gftCVVIY-it7-rUDj60Fig/thumb.png?1634239180'},
+ DARK: {ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331465372/4xhQw7dl3WJwnI5EmbMTug/thumb.png?1678215808',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331470590/8GdV9UP4OYdit3eLm79tew/thumb.png?1678218122',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250365316/jtYu-J2HDivQ7l4nuMq3dQ/thumb.png?1634239190',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250365330/90rdp0d39Nx3-C8bf4u_Hg/thumb.png?1634239196',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250365553/Pj1CQ1D2yPooTYEhrFjuXw/thumb.png?1634239309',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250365330/90rdp0d39Nx3-C8bf4u_Hg/thumb.png?1634239196',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250365570/Gh-4SRf-jrguKzn23L0G6g/thumb.png?1634239314',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250365570/Gh-4SRf-jrguKzn23L0G6g/thumb.png?1634239314'},
+ FIRE: {ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331465405/jD3yp7JT9I5BZ66mu9Qkfg/thumb.png?1678215824',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331470617/CM7PFFy_3Tc1NejNlVYRbw/thumb.png?1678218132',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250365584/SvQAEtcyM-TdxRlc6bbJiw/thumb.png?1634239320',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250365798/9zHnGGJsw0rhpEx0llfqgw/thumb.png?1634239400',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250333443/ZPyK7EPeiLp3y0V40SDaQw/thumb.png?1634225721',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250365798/9zHnGGJsw0rhpEx0llfqgw/thumb.png?1634239400',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250365814/HB7bJNTar3xasqz7X9W5bg/thumb.png?1634239406',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250365814/HB7bJNTar3xasqz7X9W5bg/thumb.png?1634239406'},
+ LIGHT: {ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331465433/FFt07jFHTw4m1vH_NfqJ0w/thumb.png?1678215837',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331470649/zpWxO41lFZtcby_iNn-Qug/thumb.png?1678218142',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250365820/DI-LYWLxPj0GP5wwRJtwag/thumb.png?1634239412',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250365961/PkFip9NS6_O6hs6pnR8aBg/thumb.png?1634239466',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250365973/HfejMDi_2_MkcgJUYpqUYw/thumb.png?1634239471',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250365961/PkFip9NS6_O6hs6pnR8aBg/thumb.png?1634239466',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250365985/WFrMhE6VZE1VCAOjx3LnkA/thumb.png?1634239477',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250365985/WFrMhE6VZE1VCAOjx3LnkA/thumb.png?1634239477'},
+ LIGHTNING:{ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331465538/9CSJ8IhZFQOMcvNrbOI9Aw/thumb.png?1678215891',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331470678/yFu8pdegShj_x5Wb4tDLOQ/thumb.png?1678218153',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250366001/tkb8HFMptLHL2vqjuf840g/thumb.png?1634239484',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250366246/TVN6nx3g5mPDJzZeN-O8Rw/thumb.png?1634239596',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250366391/HYkDYIx_aNGmTxl3T9iyEQ/thumb.png?1634239664',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250366246/TVN6nx3g5mPDJzZeN-O8Rw/thumb.png?1634239596',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250366617/6YX4WunRuiQ1C4B65RHY5A/thumb.png?1634239765',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250366617/6YX4WunRuiQ1C4B65RHY5A/thumb.png?1634239765'},
+ MAGIC: {ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331465555/3MnHN1bNCW8xdLjj-nN9cQ/thumb.png?1678215901',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331470703/hCUwxBEdR98Md2wTyQuWjw/thumb.png?1678218164',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250366823/oX0JRhH3wLUk-3lNMOKrxg/thumb.png?1634239839',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250366882/XDc_tvXiMAcYbCLr_eWKOQ/thumb.png?1634239877',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250367109/enzpndcQDrax2XI_KnXMmA/thumb.png?1634239955',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250366882/XDc_tvXiMAcYbCLr_eWKOQ/thumb.png?1634239877',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250367267/GUGEGqGSoNp6DwprW2NYBg/thumb.png?1634240001',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250367267/GUGEGqGSoNp6DwprW2NYBg/thumb.png?1634240001'},
+ COLOR: {ARC180: 'https://s3.amazonaws.com/files.d20.io/images/331581981/fqQcmnlLdC3PQvU7A4gY7A/thumb.png?1678295001',
+ ARC90: 'https://s3.amazonaws.com/files.d20.io/images/331581976/Rqe8McypnUdxVe4mUBW1-g/thumb.png?1678294994',
+ BOLT: 'https://s3.amazonaws.com/files.d20.io/images/250450699/N-DlZe7PhXIrn2DtS3vk_A/thumb.png?1634281345',
+ CIRCLE: 'https://s3.amazonaws.com/files.d20.io/images/250450680/2SS_5Or7fNrfwpmCHj7n7A/thumb.png?1634281318',
+ CONE: 'https://s3.amazonaws.com/files.d20.io/images/250318958/dFggs3eDRDXntGCEHDUbVw/thumb.png?1634215364',
+ ELIPSE: 'https://s3.amazonaws.com/files.d20.io/images/250450680/2SS_5Or7fNrfwpmCHj7n7A/thumb.png?1634281318',
+ RECTANGLE: 'https://s3.amazonaws.com/files.d20.io/images/250450699/N-DlZe7PhXIrn2DtS3vk_A/thumb.png?1634281345',
+ SQUARE: 'https://s3.amazonaws.com/files.d20.io/images/250450699/N-DlZe7PhXIrn2DtS3vk_A/thumb.png?1634281345'},
+ });
+
+ var reIgnore = /[\s\-\_]*/gi;
+
+ const replacers = [
+ [/\\api;?/g, "!"],
+ [/\\lbrc;?/g, "{"],
+ [/\\rbrc;?/g, "}"],
+ [/\\gt;?/gm, ">"],
+ [/\\lt;?/gm, "<"],
+ [/<<|«/g, "["],
+ [/\\lbrak;?/g, "["],
+ [/>>|»/g, "]"],
+ [/\\rbrak;?/g, "]"],
+ [/\\\^/g, "?"],
+ [/\\ques;?/g, "?"],
+ [/`/g, "@"],
+ [/\\at;?/g, "@"],
+ [/~/g, "-"],
+ [/\\dash;?/g, "-"],
+ [/\\n/g, "\n"],
+ [/¦/g, "|"],
+ [/\\vbar;?/g, "|"],
+ [/\\clon;?/g, ":"],
+ [/\\amp;?/g, "&"],
+ [/\\lpar;?/g, "("],
+ [/\\rpar;?/g, ")"],
+ [/\\cr;?/g, "
"],
+ [/&&/g, "/"],
+ [/%%/g, "%"],
+ [/\\comma;?/g, ","],
+ ];
+ const dbReplacers = [
+ [/\\amp/gm, "&"],
+ [/\\lbrak/gm, "["],
+ [/\\rbrak/gm, "]"],
+ [/\\ques/gm, "?"],
+ [/\\at/gm, "@"],
+ [/\\dash/gm, "-"],
+ [/\\n/gm, "\n"],
+ [/\\vbar/gm, "|"],
+ [/\\clon/gm, ":"],
+ [/\\gt/gm, ">"],
+ [/\\lt/gm, "<"],
+ ];
+
+ var statusMarkers = Object.freeze([
+ {name:"red",img:'https://s3.amazonaws.com/files.d20.io/images/8123890/TkC_M8_6X-UHy8euEymakQ/thumb.png?1425804412'},
+ {name:"blue",img:'https://s3.amazonaws.com/files.d20.io/images/8123884/pV7HJJVqORAhrOftpmVHUw/thumb.png?1425804373'},
+ {name:"green",img:'https://s3.amazonaws.com/files.d20.io/images/8123885/sbim5jTRF3XsuSs01ycKrg/thumb.png?1425804385'},
+ {name:"brown",img:'https://s3.amazonaws.com/files.d20.io/images/8123886/q0axCUI6vBsvDGOwFbsBXw/thumb.png?1425804393'},
+ {name:"purple",img:'https://s3.amazonaws.com/files.d20.io/images/8123889/xEOFbIKegEaFgN0vLnzG0g/thumb.png?1425804406'},
+ {name:"pink",img:'https://s3.amazonaws.com/files.d20.io/images/8123887/iyJDiq2Ngwuh6Si3-FLztQ/thumb.png?1425804400'},
+ {name:"yellow",img:'https://s3.amazonaws.com/files.d20.io/images/8123892/oL21nVVRUpDjGLaHXftstQ/thumb.png?1425804422'},
+ {name:"dead",img:'https://s3.amazonaws.com/files.d20.io/images/8093499/ca_OFvFT0w_MtJKY6c83Ew/thumb.png?1425688175'},
+ {name:"skull",img:'https://s3.amazonaws.com/files.d20.io/images/8074161/wpqmZJQlkzmyee0_lsNv4A/thumb.png?1425598594'},
+ {name:"sleepy",img:'https://s3.amazonaws.com/files.d20.io/images/8074159/PaeQH3jsdmPbUOiODPx5fg/thumb.png?1425598590'},
+ {name:"half-heart",img:'https://s3.amazonaws.com/files.d20.io/images/8074186/k5X_UUMwcuq1LZjEL58mpA/thumb.png?1425598650'},
+ {name:"half-haze",img:'https://s3.amazonaws.com/files.d20.io/images/8074190/YvdObVqX0hT711vcbML7OA/thumb.png?1425598654'},
+ {name:"interdiction",img:'https://s3.amazonaws.com/files.d20.io/images/8074185/cyt6rWIaUiMvq-4CnpskZQ/thumb.png?1425598647'},
+ {name:"snail",img:'https://s3.amazonaws.com/files.d20.io/images/8074158/YDHHfsu8T8wcqbby33fweA/thumb.png?1425598587'},
+ {name:"lightning-helix",img:'https://s3.amazonaws.com/files.d20.io/images/8074184/iUPFB-lXP9ySnktTut-3uA/thumb.png?1425598643'},
+ {name:"spanner",img:'https://s3.amazonaws.com/files.d20.io/images/8074154/2qufcEnyNJqjSN_f9XrgiQ/thumb.png?1425598583'},
+ {name:"chained-heart",img:'https://s3.amazonaws.com/files.d20.io/images/8074213/f6jmFoQWX-7KRsux_HaIqg/thumb.png?1425598699'},
+ {name:"chemical-bolt",img:'https://s3.amazonaws.com/files.d20.io/images/8074212/B-U3tyYf06An3NonHrh1xA/thumb.png?1425598696'},
+ {name:"death-zone",img:'https://s3.amazonaws.com/files.d20.io/images/8074210/CPzQbQ8h-vZnNinShD1L_Q/thumb.png?1425598689'},
+ {name:"drink-me",img:'https://s3.amazonaws.com/files.d20.io/images/8074207/bElenkvmnfe15u6e23_XxQ/thumb.png?1425598686'},
+ {name:"edge-crack",img:'https://s3.amazonaws.com/files.d20.io/images/8074206/7N52ErC13lHDxRwrt-igyQ/thumb.png?1425598682'},
+ {name:"ninja-mask",img:'https://s3.amazonaws.com/files.d20.io/images/8074181/XDbfFm8Ul3Iy7zkiDB321w/thumb.png?1425598638'},
+ {name:"stopwatch",img:'https://s3.amazonaws.com/files.d20.io/images/8074152/UW9235lWLTTryx6zCP2MQA/thumb.png?1425598581'},
+ {name:"fishing-net",img:'https://s3.amazonaws.com/files.d20.io/images/8074205/v83unarpA-nUZqp2HKOr0w/thumb.png?1425598678'},
+ {name:"overdrive",img:'https://s3.amazonaws.com/files.d20.io/images/8074178/CYZFHZzMBdssRjoxWvP7MQ/thumb.png?1425598630'},
+ {name:"strong",img:'https://s3.amazonaws.com/files.d20.io/images/8074151/DHoYUsnyz2AOaTVGR5mV7A/thumb.png?1425598577'},
+ {name:"fist",img:'https://s3.amazonaws.com/files.d20.io/images/8074201/GZ0py5UxO7pFUOfobTKGVw/thumb.png?1425598674'},
+ {name:"padlock",img:'https://s3.amazonaws.com/files.d20.io/images/8074174/euydq4AuqYk_7y0GqObChw/thumb.png?1425598626'},
+ {name:"three-leaves",img:'https://s3.amazonaws.com/files.d20.io/images/8074149/3GodR7irhqJXoQcfm7tkng/thumb.png?1425598573'},
+ {name:"fluffy-wing",img:'https://s3.amazonaws.com/files.d20.io/images/8093436/nozRPKmjhulSuQZO-NV7xw/thumb.png?1425687966'},
+ {name:"pummeled",img:'https://s3.amazonaws.com/files.d20.io/images/8074171/pPhgEmVHP6bHMbcj-wn98g/thumb.png?1425598619'},
+ {name:"tread",img:'https://s3.amazonaws.com/files.d20.io/images/8074145/-hBmfcug0Bhr7nWxXMNd1A/thumb.png?1425598570'},
+ {name:"arrowed",img:'https://s3.amazonaws.com/files.d20.io/images/8074234/Z48uPYYNGR5iD4DEy3RYbA/thumb.png?1425598735'},
+ {name:"aura",img:'https://s3.amazonaws.com/files.d20.io/images/8074231/g6ogG9gDMBsIG_fdx-Hl5w/thumb.png?1425598731'},
+ {name:"back-pain",img:'https://s3.amazonaws.com/files.d20.io/images/8074229/xdGkbAHaELU5HK9rpMUZkg/thumb.png?1425598727'},
+ {name:"black-flag",img:'https://s3.amazonaws.com/files.d20.io/images/8074226/mJgQqm9Hl3ek75xoXcecVg/thumb.png?1425598724'},
+ {name:"bleeding-eye",img:'https://s3.amazonaws.com/files.d20.io/images/8074224/IdGVnqxciFoDI6dXLyoSgA/thumb.png?1425598720'},
+ {name:"bolt-shield",img:'https://s3.amazonaws.com/files.d20.io/images/8074221/8E3S_XJF1rpkYmkQc7iwcw/thumb.png?1425598713'},
+ {name:"broken-heart",img:'https://s3.amazonaws.com/files.d20.io/images/8074218/ylXLOkQFHyAaj6kumKEaOw/thumb.png?1425598709'},
+ {name:"cobweb",img:'https://s3.amazonaws.com/files.d20.io/images/8074211/KNY0AO4fj2md_M2n6Uf4IQ/thumb.png?1425598692'},
+ {name:"broken-shield",img:'https://s3.amazonaws.com/files.d20.io/images/8074217/wV6Cx457yk_jTwjKzWRVXw/thumb.png?1425598706'},
+ {name:"flying-flag",img:'https://s3.amazonaws.com/files.d20.io/images/8074198/n2hH7I_YrEXNYb1jh0Oo5Q/thumb.png?1425598670'},
+ {name:"radioactive",img:'https://s3.amazonaws.com/files.d20.io/images/8074167/4zCBr9YKxZvRuhDo2VWQnQ/thumb.png?1425598611'},
+ {name:"trophy",img:'https://s3.amazonaws.com/files.d20.io/images/8074143/QVNHRiiQ56k6Mn2rro3_bg/thumb.png?1425598567'},
+ {name:"broken-skull",img:'https://s3.amazonaws.com/files.d20.io/images/8074215/rTI3ahu2dE3VKO-W7i3jcw/thumb.png?1425598702'},
+ {name:"frozen-orb",img:'https://s3.amazonaws.com/files.d20.io/images/8074197/K7xZkKvW0GeMvwkm8VfxTg/thumb.png?1425598666'},
+ {name:"rolling-bomb",img:'https://s3.amazonaws.com/files.d20.io/images/8074165/fd9kK4Peiprwr8wyI_pcEQ/thumb.png?1425598604'},
+ {name:"white-tower",img:'https://s3.amazonaws.com/files.d20.io/images/8074141/M5p2-7dryUVxCJjhUcJe5Q/thumb.png?1425598564'},
+ {name:"grab",img:'https://s3.amazonaws.com/files.d20.io/images/8074194/tfeQLEm-AmBi_IMF-h8vEg/thumb.png?1425598663'},
+ {name:"screaming",img:'https://s3.amazonaws.com/files.d20.io/images/8074163/CwKqOWu7ZprFzkkcafs8cQ/thumb.png?1425598601'},
+ {name:"grenade",img:'https://s3.amazonaws.com/files.d20.io/images/8074191/dd_UjtADigCKYzcP4RBCVg/thumb.png?1425598657'},
+ {name:"sentry-gun",img:'https://s3.amazonaws.com/files.d20.io/images/8074162/rlpAA3Eg04Ct8csKCjbcdQ/thumb.png?1425598597'},
+ {name:"all-for-one",img:'https://s3.amazonaws.com/files.d20.io/images/8074239/2VxQwqrsz5BXvXIkraKE1g/thumb.png?1425598746'},
+ {name:"angel-outfit",img:'https://s3.amazonaws.com/files.d20.io/images/8074238/dKSnapoJ7JyGcINc8PIA1Q/thumb.png?1425598742'},
+ {name:"archery-target",img:'https://s3.amazonaws.com/files.d20.io/images/8074237/ei4JHB51P6az3slwgZmTEw/thumb.png?1425598739'},
+ {name:"blank",img:''}
+ ]);
+
+ var handouts = Object.freeze({
+ RoundMaster_Help: {name:'RoundMaster Help',
+ version:1.14,
+ avatar:'https://s3.amazonaws.com/files.d20.io/images/257656656/ckSHhNht7v3u60CRKonRTg/thumb.png?1638050703',
+ bio:' '
+ +'RoundMaster Help v1.14'
+ +' '
+ +' '
+ +' RoundMaster API v'+version+''
+ +' New for v5.056: --state-extract & --state-load support campaign copying '
+ +' New for v5.055: RPGM maths for duration & direction of --target '
+ +' New for v5.055: Saving throw mods & prompts for --target commands '
+ +' New for v5.055: --target multi to target status changes for multiple tokens '
+ +' New for v5.055: --target-nosave and --target-save to deny/force GM confirm '
+ +' New for v5.055: --nosave on/off configures --target-nosave behaviour '
+ +' RoundMaster is an API for the Roll20 RPG-DS. Its purpose is to extend the functionality of the Turn Tracker capability already built in to Roll20. It is one of several other similar APIs available on the platform that support the Turn Tracker and manage token and character statuses related to the passing of time: the USP of this one is the full richness of its functionality and the degree of user testing that has occurred over a 12 month period. '
+ +' RoundMaster is based on the much older TrackerJacker API, and many thanks to Ken L. for creating TrackerJacker. However, roundMaster is a considerable fix and extension to TrackerJacker, suited to many different applications in many different RPG scenarios. RoundMaster is also the first release as part of the wider RPGMaster series of APIs for Roll20, composed of RoundMaster, CommandMaster, InitiativeMaster, AttackMaster, MagicMaster and MoneyMaster - other than RoundMaster (which is generic) these initially support only the AD&D2e RPG. '
+ +' Note: For some aspects of the APIs to work, the ChatSetAttr API and the Tokenmod API, both from the Roll20 Script Library, must be loaded. It is also highly recommended to load all the other RPGMaster series APIs listed above. This will provide the most immersive game-support environment '
+ +' Syntax of RoundMaster calls'
+ +' The roundMaster API is called using !rounds, though it reveals its history in that it can also be called using !tj (the command for the TrackerJacker API roundMaster is based on). '
+ +' !rounds --start '
+ +'!tj --start '
+ +' Commands to be sent to the roundMaster API must be preceeded by two hyphens \'--\' as above for the --start command. Parameters to these commands are separated by vertical bars \'|\', for example: '
+ +' !rounds --addtotracker name|tokenID|3|all|sleeping|sleepy '
+ +' If optional parameters are not to be included, but subsequent parameters are needed, use two vertical bars together with nothing between them, e.g. '
+ +' !rounds --addtotracker name|tokenID|3|all||sleepy '
+ +' Commands can be stacked in the call, for example: '
+ +' !rounds --start --addtotracker name|tokenID|3|all|sleeping|sleepy --sort '
+ +' When specifying the commands in this document, parameters enclosed in square brackets [like this] are optional: the square brackets are not included when calling the command with an optional parameter, they are just for description purposes in this document. Parameters that can be one of a small number of options have those options listed, separated by forward slash \'/\', meaning at least one of those listed must be provided (unless the parameter is also specified in [] as optional): again, the slash \'/\' is not part of the command. Parameters in UPPERCASE are literal, and must be spelt as shown (though their case is actually irrelevant). '
+ +' How to use RoundMaster'
+ +' Who uses RoundMaster calls?'
+ +' The vast majority of RoundMaster calls are designed for the DM/GM to use, or to be called from RPGMaster APIs and database macros, rather than being called by the Player directly. RoundMaster should be hidden from the Players in most circumstances. It is highly recommended that RoundMaster is used with the other RPGMaster APIs, but especially InitiativeMaster API which uses RoundMaster to create and manage entries in the Roll20 Turn Order Tracker. '
+ +' Managing the Turn Order Tracker'
+ +' If the InitiativeMaster API is used, it must be accompanied by RoundMaster - it will not work otherwise. InitiativeMaster provides many menu-driven and data-driven means of controlling RoundMaster, making it far easier for the DM to run their campaign. The InitiativeMaster --maint command supports the necessary calls to RoundMaster for control of the Turn Order Tracker, and its --menu command uses the data on the Character Sheet to create Turn Order initiative entries with the correct speeds and adjustments. See the InitiativeMaster API Handout for more information. '
+ +' Adding and managing Token Statuses'
+ +' The Token status management functions allow the application and management of status markers with durations (measured in rounds) set on tokens. The easiest way to use status markers is to use the MagicMaster API which runs spell and magic item macros the Player can initiate, which in turn apply the right status markers and statuses with the appropriate durations to the relevant tokens. See the MagicMaster API handout for more information. '
+ +' Statuses are marked on a token with icons provided by Roll20 (and extendable by purchases on the Roll20 Marketplace). In addition, for statuses with a duration (except on GM-controlled tokens), the status icons of player-controlled tokens display a number representing the remaining duration of the status (if less than 10 - durations of more than 10 are not shown). In addition, as the turn for each token is announced in the chat window, the user-provided description for any and all statuses on the token are displayed below the turn announcement, with the remaining duration. However, for statuses with an uncertain duration that the player / character should not know (e.g. for the spell fly) the remaining duration can be surpressed on both the status icon and the turn announcement by using a status duration "direction" value of less than -1, and a duration suitably multiplied to achieve the correct outcome. Often using a duration x 10 and a direction of -10 per round will work well. '
+ +' Status Effects'
+ +' RoundMaster comes with a number of status "effects": Roll20 Ability Macros that are automatically run when certain matching statuses are applied to, exist on, and/or removed from a token. These macros can use commands (typically using APIs like ChatSetAttr API and/or Tokenmod API from the Roll20 API Script Library) to temporarily or permanently alter the characteristics of the Token or the represented Character Sheet, thus impacting the state of play. '
+ +' If used with the MagicMaster API, its pre-configured databases of spell and magic item macros work well with the Effect macros supplied in the Effects Database provided with this API. '
+ +' For full information on Status Effects, how to use them, and how to add more of your own, see the separate Effects Database handout. '
+ +' Token Death and Removal'
+ +' If a token is marked as Dead by using the Dead status marker (either via the Roll20 token emoticon menu or any other means), the system will automatically end all statuses, remove all status markers and run all status-end effect macros (if any) for the token. '
+ +' If a token is deleted on a map and not previously marked as Dead, the API will search for any other token in the Campaign (with a preference to one on the current page) with the same name and representing the same character, and if found the API will transfer all statuses and status markers to the first token found (even if not on the current page). If no such token is found, all statuses and status markers are removed from the token being deleted, and any corresponding status-end effect macros are run. '
+ +' Page Change and Adding Tokens'
+ +' If a Player, or all Players, are moved to another Roll20 page in the campaign (i.e. a different map), the API will automatically migrate all current statuses and status markers from the previous page (and all other pages in the Campaign, to support where tokens have come from different pages) to any token on the new page with the same Token Name and representing the same Character. These statuses and their effects will then continue to apply on the new page for their set durations. '
+ +' If a token is added to the current map the Players are on, either by dragging a character onto the map or by dragging on a picture and editing its properties to give it a name and optionally a representation, the API will again search for tokens with the same name and representing the same character, and move statuses and markers to the new token. '
+ +' Of course, either before or after each of these situations, the --edit command can be used to change or remove statuses from any token(s). '
+ +' '
+ +' Command Index'
+ +' 1. Tracker commands'
+ +' --start '
+ +'--stop '
+ +'--pause '
+ +'--reset [number] '
+ +'--sort '
+ +'--clear '
+ +'--clearonround [OFF/ON] '
+ +'--clearonclose [OFF/ON] '
+ +'--sortorder [NOSORT/ATOZ/ZTOA/DESCENDING/ASCENDING] '
+ +'--addToTracker name|tokenID/-1|priority|[qualifier]|[message]|[detail] '
+ +'--removefromtracker name|tokenID/-1|[retain] '
+ +' 2. Token Status Marker commands'
+ +' Update: --addstatus status|duration|direction|[message]|[marker]|[savemod] '
+ +'Update: --addtargetstatus tokenID|status|duration|direction|[message]|[marker]|[savemod] '
+ +'--edit '
+ +'Update: --target[-save/-nosave] CASTER/MULTI|casterID|status|duration|direction|[message]|[marker]|[savemod] '
+ +'Update: --target[-save/-nosave] SINGLE/AREA|casterID|targetID|status|duration|direction|[message]|[marker]|[savemod] '
+ +'New: --nosave (ON/OFF) '
+ +'--gm-target CASTER|casterID|status|duration|direction|[message]|[marker] '
+ +'--gm-target SINGLE/AREA|casterID|targetID|status|duration|direction|[message]|[marker] '
+ +'--aoe tokenID|[shape]|[units]|[range]|[length]|[width]|[image]|[confirmed] '
+ +'--aoe tokenID|[shape]|[units]|[range]|[length]|[width]|[image]|[confirmed]|casterID|cmd|status|duration|direction|[message]|[marker] '
+ +'--movable-aoe tokenID|[shape]|[units]|[range]|[length]|[width]|[image]|[confirmed] '
+ +'--clean '
+ +'--removestatus status(es) / ALL '
+ +'--removetargetstatus tokenID|status(es) / ALL '
+ +'--removeglobalstatus status(es) / ALL '
+ +'--deletestatus status(es) / ALL '
+ +'--deltargetstatus tokenID|status(es) / ALL '
+ +'--delglobalstatus status(es) / ALL '
+ +'--movestatus '
+ +'--state-extract '
+ +'--state-load [RPGM/ALL/API-Name] '
+ +'--disptokenstatus [tokenID] '
+ +'--dancer cmd|tokenID|weapon|[plusChange]|[duration] '
+ +'--listmarkers '
+ +'--listfav '
+ +' 3. Other commands'
+ +' --help '
+ +'--hsq from|[command] '
+ +'--handshake from|[command] '
+ +'--debug (ON/OFF) '
+ +' '
+ +' 1. Tracker Command detail'
+ +' !rounds --start '
+ +' This command alternates between starting the automatic functions of the Turn Tracker, and pausing the Tracker. In its started state, the tracker will follow the current token at the top of the tracker with a highlight graphic, report the token\'s turn to all players, and follow the options selected for "sortorder" and "clearonround". When paused, the Tracker will not highlight the top token, report turns or execute the options. '
+ +' !rounds --stop '
+ +' Stops the tracker and removes all statuses and status markers from tokens currently held by roundMaster. This also dumps the tables held in the campaign status object. It is useful if you want to start a fresh version of a campaign, or if everything goes wrong. '
+ +' !rounds --pause '
+ +' Pauses the Turn Tracker in its current state without deleting any information, and is the same as using the --start command again having already called it once. The Turn Tracker can still be moved on, cleared, sorted, and reset, but the highlight graphic is paused. It can be restarted using --start '
+ +' !rounds --reset [number] '
+ +' Sets the round in the Turn Order to the number, or to 1 if number is not provided. '
+ +' !rounds --sort '
+ +' Sorts the Turn Tracker entries according to the previously set sort order, with the default being ascending numeric priority. '
+ +' !rounds --clear '
+ +' Clears all entries in the Turn Tracker without stopping it. '
+ +' !rounds --clearonround [OFF/ON] '
+ +' Sets the "clear on round" option. If set, this option means that when the Tracker is running and reaches the end of the round, all entries in the Turn Tracker are automatically removed ready for players to do initiative for the next round. Otherwise, the Turn Tracker is not cleared automatically at any point. Any parameter other than "off" turns clearonround on. Default on. '
+ +' !rounds --clearonclose [OFF/ON] '
+ +' Sets the "clear on close" option. If set, this option means that when the Tracker window is closed, the Turn Tracker is cleared. Any parameter other than "on" turns clear on close off. Default off. '
+ +' !rounds --sortorder [NOSORT/ATOZ/ZTOA/DESCENDING/ASCENDING] '
+ +' This command sets the automatic sort order of the entries in the Turn Tracker. The Turn Tracker is automatically sorted at the beginning of each round as the Turn Tracker is moved on to the first entry, based on the order set by this option. Descending and Ascending are numeric sorts based on the Priority number of each entry. AtoZ and ZtoA are alphabetic sorts based on the name of each entry in the Turn Tracker. Nosort will mean that no sorting takes place, and the order remains the order in which the entries were made. The default order is Ascending. '
+ +' !rounds --addToTracker name|tokenID/-1|priority|[qualifier]|[message]|[detail] '
+ +' This command adds an entry to the TurnTracker. tokenID can either be the ID of a valid token, or -1 to create a custom entry. If a custom entry, name is used for the entry in the Turn Tracker with the provided priority, otherwise the token name is used for the entry with the provided priority. The visibility of the token name in the Turn Tracker window will depend on the setting of the "showplayers_name" flag on the token. The qualifier can be one of first/last/smallest/largest/all. '
+ +' - First keeps only the first entry made for that name (for custom entries) or token and removes any others, but leaves all entries for other tokens and names in the Tracker
'
+ +'- Last keeps only the latest entry for that token or name (i.e. the one now being set)
'
+ +'- Smallest keeps only the entry with the lowest numeric priority for that token or name
'
+ +'- Largest keeps only the entry with the highest numeric priority for that token or name
'
+ +'- All keeps all entries in the list and adds this one to those for that token or name, meaning that the Turn Tracker can have multiple entries for one or more tokens or names
'
+ +' If the "showplayers_name" flag on the token is true, the optional message will be displayed on the turn announcement for this turn when it is reached in the Turn Order (otherwise only the DM will see the message). Generally, the message relates what the player (or DM) said the character was doing for their initiative. The optional detail can be the detail of how the initiative priority was calculated or any other additional message you want to show to the Player only when the command is processed. '
+ +' By using the name, tokenID/-1 and qualifier parameters judiciously, group initiative, individual initiative, or any combination of other types can be created. When used with the InitiativeMaster API, Players get menus of actions they can take (based on their weapons, powers, memorised spells, magic items, thieving skills etc) which manage the calls to RoundMaster for the desired initiative type, and the DM gets menus to control all RoundMaster functions, and to set the type of initiative to undertake. '
+ +' !rounds --removefromtracker name|tokenID/-1|[retain] '
+ +' This command removes entries from the Turn Tracker for the specified tokenID or name. However, if the optional retain number is given, it will retain this number of entries for the specified token or name, and only remove any beyond this number. The earliest entries made are kept when the retain parameter is set. '
+ +' !rounds --viewer on/off/all/tokenID '
+ +' This command controls the viewer mode setting for the Player who calls it. Rather than showing what that Player\'s characters can see when Dynamic Lighting is turned on, viewer mode shows that Player what each player-character (even if not theirs) can see as their token reaches the top of the Turn Tracker and it is their turn. Quite often, this can be a Player ID set up just to be a viewer e.g. for a DM view of what players can see, or for a touchscreen playing surface. The current player-character is defined as the token representing a character sheet controlled by any Player at the top of the Turn Tracker. As each new token comes to the top of the Turn Tracker, if it is a player-character the display changes to only what it can see. If it is a token representing an NPC, or when the Turn Order reaches the next round and clears, the map for the Player reverts to showing what all player-characters can see (but not what NPCs can see). '
+ +' The on option turns on viewer mode for the Player, and off turns it off. The all option immediately turns on vision for all player-characters, and passing a tokenID as a parameter shows vision for that token (even if it represents an NPC). Options off, all and tokenID can be used by any Player or the DM to affect the viewer Player\'s screen. '
+ +' '
+ +' 2. Token Status Marker commands'
+ +' Defining statuses'
+ +' First, here is the syntax for defining statuses for status markers, which is shared across commands that set status markers and potentially trigger effects. '
+ +' Effect-name '
+ +'Effect-name_Player-text '
+ +'Effect-name_Player-text_Differentiator '
+ +' Where underscores (\'_\') are shown, they are mandatory. Otherwise, spaces or hyphens can be used and will be ignored in name matches. The above are optional syntaxes - any one can be used. '
+ +' '
+ +'- Effect-name is mandatory, and is the name of the effect in the Effects database or, if there is no associated Effect, the name of the status being applied which can be anything desired.
'
+ +'- Player-text if provided is the text that will be shown to the Player instead of the Effect/status name e.g. for slow acting poisons or delayed effect spells where the player should not be aware of the precise nature.
'
+ +'- Differentiator if provided just makes this Effect/status different from any other with the same Effect-name and Player-text. This will only be needed in very limited circumstances that perhaps requires the same effect to be applied twice due to two different status applications. It is only ever displayed to the DM.'
+ +'
'
+ +' New: Maths for numbers'
+ +' The duration and direction values (as well as any numbers in a save specification) can use Roll20 maths using the [[...]] syntax. However, sometimes it is not possible or desirable to calculate the value using Roll20 maths, especially when using the --target multi command with maths including the number of targeted tokens using the # attribute. RoundMaster provides an alternative maths capability as follows: '
+ +' '
+ +'+-*/ | Standard operators can be used to do maths | '
+ +'(...) | Parentheses can be used to set the evaluation order | '
+ +'^(... ; ... ; ...) | A \'^\' preceeding parentheses with values (which can be formulas) separated by \';\' (or commas) will return the maximum of the values | '
+ +'v(... ; ... ; ...) | A \'v\' preceeding parentheses and \';\' (or comma) separated values will return the minimum of the values | '
+ +'c(...) | A \'c\' preceeding parentheses returns the ceiling of the value (which can itself be a formula) | '
+ +'f(...) | A \'f\' preceeding parentheses returns the floor of the value (which can itself be a formula) | '
+ +'# | A \'#\' in any place other than the 1st character of the duration (see below) will be replaced with the number of selected tokens (only works well with --target multi command) | '
+ +' '
+ +' Specifying durations'
+ +' Next, durations for statuses are normally just an integer number of rounds. However if preceeded by \'+\', \'-\', \'<\', \'>\', \'#\', \'$\' or \'=\' and a status of the same name is already set on the identified token the command will modify the current duration (or add a new effect) like so: '
+ +' '
+ +'- \'+#\' will increase the duration of the status by # rounds
'
+ +'- \'-#\' will reduce the duration of the status by # rounds
'
+ +'- \'<#\' will compare # to the duration of the current status and use the smaller
'
+ +'- \'>#\' will compare # to the duration of the current status and use the larger
'
+ +'- \'=#\' (or just the number) will replace the duration of the status with # rounds
'
+ +'- \'##\' (a literal hash character followed by a number) will add an additional effect of the same name with the specified duration
'
+ +'- \'$#\' will do the same as =#, but will also trigger any \'-start\' effect associated with the requested status
'
+ +' '
+ +' If a status of the same name does not exist on the identified token, the duration will be applied as normal to a new status for that token. '
+ +' Commands'
+ +' !rounds --addstatus status|duration|direction|[message]|[marker]|[savemod] '
+ +' Update: Adds a status and a marker for that status to the currently selected token(s) (unless an optional savemod is specified, see below). The status has the name given in the status parameter, with the format described above, and will be given the duration specified (or a modified duration as stated above) which will be changed by direction each round. Thus setting a duration of 8 and direction of -1 will decrement the duration by 1 each round for 8 rounds. If the duration gets to 0 the status and token marker will be removed automatically. direction can be any number - including a positive one meaning duration will increase. Each Turn Announcement for the turn of a token with one or more statuses will display the effect-name/status (or the Player Text if specified), the duration and direction, and the message, if specified. The specified marker will be applied to the token - if it is not specified, or is not a valid token marker name, the option will be given to pick one from a menu in the chat window (which can be declined). '
+ +' For player-characters, when the duration reaches 9 or less the duration will be counted-down by a number appearing on the marker. For NPCs this number does not appear (so that Players don\'t see the remaining duration for statuses on NPCs), but the remaining duration does appear for DM only on the status message below the Turn Announcement on the NPCs turn. Turn announcement durations and status count-downs can also be surpressed for player characters by specifying a direction value of less than -1 and a duration suitably multiplied to achieve the same outcome. For example, the spell fly has an uncertain duration and perhaps the player should not be aware of what it is: multiplying the duration by 10 and setting the direction as -10 per round means that the turn announcement will not show the players the remaining duration of the status, and the final count of the duration will be from "10" down to "0" so the status count-down on the token will never display! Alternatively, if you want to give the player just a small hint of it coming to an end, multiplying the duration by 5 and setting the direction to -5 will display one count-down of "5" on the token before dropping to "0" and removing the status (perhaps a bit misleading), or x 2 and -2 will show "8", "6", "4", "2", then remove the status. '
+ +' New: If also using the RPGMaster AttackMaster API, a savemod can optionally be specified, with the form svXXX:[+/-]#, where XXX is the type of saving throw to be made - one of \'par\', \'poi\', \'dea\', \'rod\', \'sta\', \'wan\', \'pet\', \'pol\', \'bre\', \'spe\' for paralysis, poison, death, rod, staff, wand, petrification, polymorph, breath, or spell respectively. The subsequent value (optinally preceeded by plus or minus) is the modifier to the saving throw (usually +0). If a savemod is added to the command call, a status marker will not immediately be applied, but a prompt will appear for the user of the command to ask the player(s) who control the selected token(s) to make the appropriate saving throw - the saving throw modifier will automatically be applied to this saving throw, and the status set automatically if the saving throw is failed: if the save is successfully made, the status is not applied. If AttackMaster is not loaded then the savemod parameter will be ignored. '
+ +' If a Player other than the DM uses this command, the DM will be asked to confirm the setting of the status and marker. This allows the DM to make any decisions on effectiveness. '
+ +' The API-held Effects database and any GM-supplied additional Effects databases will be searched in three ways: when a status marker is set, any Ability Macro with the name Effect-name-start (where Effect-name is from the command using the syntax described above) is run. Each round when it is the turn of a token with the status marker set, the Ability Macro with the name Effect-name-turn is run. And when the status ends (duration reaches 0) or the status is removed using --removestatus, or the token has the Dead marker set or is deleted, an Ability Macro with the name Effect-name-end is run. See the Effects database documentation for full information on effect macros and the options and parameters that can be used in them. '
+ +' !rounds --addtargetstatus tokenID|status|duration|direction|[message]|[marker]|[savemod] '
+ +' Update: This command is identical to addstatus, except for the addition of a tokenID. Instead of using a selected token or tokens to apply the status to, this applies the status to the specified token. The optional savemod parameter also works in the same way. '
+ +' !rounds --edit '
+ +' This command brings up a menu in the chat window showing the current status(es) set on the selected token(s), with the ability to remove or edit them. Against each named status, a spanner icon opens another menu to edit the selected status name, duration, direction, message and marker on all the selected token(s), and also allows this status to be set as a favourite. A bin icon will remove the status from all the selected token(s), and run any status-end macros, if any. '
+ +' !rounds --target[-save/-nosave] CASTER/MULTI|casterID|casterID|status|duration|direction|[message]|[marker]|[savemod] '
+ +'!rounds --target[-save/-nosave] SINGLE/AREA|casterID|targetID|status|duration|direction|[message]|[marker]|[savemod] '
+ +' Update: This command targets a status on a token or a series of tokens. If a version using CASTER is called, it acts identically to the addtargetstatus command, using the casterID as the target token. If the SINGLE version is called, the targetID is used. If the AREA version is used, after applying the status to the targetID token, the system asks in the chat window if the status is to be applied to another target and, if confirmed, asks for the next target to be selected, repeating this process after each targeting and application. If the MULTI version is used, the player is prompted to select multiple tokens and then confirm the selection with a button in the chat window. In each case, it applies the status (with the format defined above), effect macro and marker to the specified token(s) in the same way as --addtargetstatus, including prompting a saving throw if using AttackMaster API and a savemod is specified. '
+ +' Note: the MULTI version of the command temporarily modifies every token on the same Roll20 page as the casterID token, and the character sheets they represent, to set the player who controls the casterID token as a controller for every token, so that the player can select any or all tokens. The command also temporarily changes any GM-controlled or uncontrolled tokens so they do not "have sight" in a dynamic lighting situation - this is so the player does not see anything they shouldn\'t and so that "Explorer Mode" continues to work correctly. Once the player has finished selection and clicks the button in the chat window to confirm the selection (or in fact does any other command) all control and sight returns to the same as it was before the command. '
+ +' New: The behaviour of the --target command can be affected by using the qualifiers -save and -nosave. --target-save will force the GM to always be prompted for a confirmation of the setting of the status even if it is the GM who has made the --target-save call. This allows the GM to ensure a saving throw or other check is made before the status is confirmed as applying to the token. --target-nosave does the opposite: even if it is a player that has made the --target-nosave command call, the GM will not be prompted to confirm the status, which will always be applied immediately in a similar fashion to --gm-target. However, the --target-nosave behaviour can be overridden using the --nosave configuration command - see below - whereas --gm-target cannot be overriden. '
+ +' !rounds --nosave (ON/OFF) '
+ +' New: As described under the --target-nosave command, the default situation for --target-nosave is to not present the GM with the option to confirm the application of a status to a token. The --nosave command can alter this behaviour: --nosave OFF will make --target-nosave behave in the same way as --target, asking the GM for confirmation if issued by a player, but not if issued by the GM. The --target-nosave behaviour can be restored by using --nosave ON. '
+ +' !rounds --gm-target CASTER|casterID|casterID|status|duration|direction|[message]|[marker] '
+ +'!rounds --gm-target SINGLE/AREA|casterID|targetID|status|duration|direction|[message]|[marker] '
+ +' These commands work identically to the --target commands, with the exception that if used by a player, the player will temporarily be given GM privilidges and the GM will not be asked to confirm the status targeting. Use carefully as this may not give the GM the opportunity to do saving throws or other retaliatory actions. '
+ +' !rounds --aoe tokenID|[shape]|[units]|[range]|[length]|[width]|[image]|[confirmed] '
+ +'!rounds --aoe tokenID|[shape]|[units]|[range]|[length]|[width]|[image]|[confirmed]|casterID|SINGLE/AREA|status|duration|direction|[message]|[marker] '
+ +'!rounds --movable-aoe tokenID|[shape]|[units]|[range]|[length]|[width]|[image]|[confirmed] '
+ +' '
+ +' shape | [BOLT/ CIRCLE/ CONE/ ELLIPSE/ RECTANGLE/ SQUARE/ WALL] | '
+ +' units | [SQUARES/ FEET/ YARDS/ UNITS] | '
+ +' image | [ACID/ COLD/ DARK/ FIRE/ LIGHT/ LIGHTNING/ MAGIC/ RED/ YELLOW/ BLUE/ GREEN/ MAGENTA/ CYAN/ WHITE/ BLACK] | '
+ +' confirmed | [TRUE / FALSE] | '
+ +' range, length & width | numbers specified in whatever unit was specified as [units] | '
+ +' '
+ +' This command displays an Area of Effect for an action that has or is to occur, such as a spell. This quite often can be used before the --target area command to identify targets. The system will present lists of options for each parameter that is not specified for the Player to select. On executing this command, if the range is not zero the Player will be given a crosshair to position the effect, and if the range is zero the effect will be centred on the Token (or at its "finger-tips" for directional effects like cones). The range of the effect will be centred on the TokenID specified and will be displayed as a coloured circle - the crosshair should be positioned within this area (the system does not check). The Crosshair (or if range is zero, the Token) can be turned to affect the direction of the effect. The effect "direction" will be the direction the token/crosshair is facing. If Confirmed is false or omitted, the Player will be asked to confirm the positioning of the token/crosshair with a button in the chat window. Setting Confirmed to true will apply the effect immediately - good for range zero circular effects (i.e. don\'t need placing or direction setting). '
+ +' The second form of the --aoe command, with more parameters, combines the display of an area of effect with a subsequent call to a --target command, using the parameters as described for the --target command above. Once the area of effect is shown, a button will be presented in the chat window to select a target (which can be the first in a sequence if the "AREA" parameter is used). '
+ +' Using the aoe command means the area of effect presented is movable or deletable by the DM but not the Player(s). If using the movable-aoe command instead, then any Player who controls the specified token can move or delete the area of effect image. This is useful for representing spells such as Flaming Sphere '
+ +' The effect can have one of the shapes listed: '
+ +' - Bolt is a long rectangle extending away from the crosshair/token for length, and width wide.
'
+ +'- Circle is a circle centred on the crosshair/token of diameter length.
'
+ +'- Cone is a cone starting at the crosshair/token of length, with an end width.
'
+ +'- Ellipse is an ellipse of length extending away, and width wide.
'
+ +'- Rectangle is a rectangle of length extending away, and width wide.
'
+ +'- Square is a square of sides length parallel with the direction the crosshair/token.
'
+ +'- Wall is a rectangle perpendicular to the crosshair or token, i.e. width away and length wide.
'
+ +' For the units, Feet & Yards are obvious and are scaled to the map. Squares are map squares (whatever scale they are set to), and Units are the map scale units and are not scaled. '
+ +' Images are set with transparency and sent to the back of the Object layer. Red/ Yellow/ Blue/ Green/ Magenta/ Cyan/ White/ Black colour the effect area the specified colour, and Acid/ Cold/ Dark/ Fire/ Light/ Lightning/ Magic use textured fills. '
+ +' !rounds --clean '
+ +' Drops all the status markers on the selected token(s), without removing the status(es) from the campaign status object, meaning live statuses will be rebuilt at the end of the round or the next trigger event. This deals with situations where token markers have become corrupted for some reason, and should not be needed very often. '
+ +' !rounds --removestatus status(es) / ALL '
+ +'!rounds --removeglobalstatus status(es) / ALL '
+ +' Removes the status, a comma-delimited list of statuses, or all statuses, and their status marker(s) from the selected token(s) (or all tokens if the global version is used), and runs any associated status-end Ability Macros in any existing Effects database in the campaign. See addstatus command and the Effect database documentation for details on effect macros. Statuses can be "all" which will remove all statuses from the selected token(s). Take care when using the global version as it can have unintended consequences. '
+ +' !rounds --removetargetstatus targetID | status(es) / ALL '
+ +' Exactly the same as the removestatus command, but for a specified token rather than any that is selected. Removes the status, a comma-delimited list of statuses, or all statuses, and their status marker(s) from the specified token, and runs any associated status-end Ability Macros in any existing Effects database in the campaign. See addstatus command and the Effect database documentation for details on effect macros. Statuses can be "all" which will remove all statuses from the token. '
+ +' !rounds --deletestatus status(es) / ALL '
+ +'!rounds --delglobalstatus status(es) / ALL '
+ +' Works the same as removestatus command, except that it does not run any effect macros. '
+ +' !rounds --deltargetstatus tokenID|status(es) / ALL '
+ +' Works the same as removetargetstatus command, except that it does not run any effect macros. '
+ +' !rounds --movestatus '
+ +' For each of the selected tokens in turn, searches for tokens in the whole campaign with the same name and representing the same character sheet, and moves all existing statuses and markers from all the found tokens to the selected token (removing any duplicates). This supports Players moving from one Roll20 map to another and, indeed, roundMaster detects page changes and automatically runs this command for all tokens on the new page controlled by the Players who have moved to the new page. '
+ +' !rounds --state-extract '
+ +'!rounds --state-load [RPGM/ALL/API-Name][|API-Name2|API-Name3|...] '
+ +' New for v5.056: These commands extract the current Roll20 state variable for the current campaign to a character sheet called "StatusMule", ready to be copied or transmogrified to a new (identical) campaign, and then provide the ability to load all or part of the state variable into the copy campaign. This provides support for Roll20 upgrades, such as JumpGate. '
+ +' The --state-extract command does not take any arguments, and extracts the whole Roll20 state variable for the current campaign in JSON text form to the "State" ability on the character sheet "StatusMule". '
+ +' The --state-load command takes one text argument, which can be: '
+ +' '
+ +'RPGM | Loads only the parts of the state variable relevant to the currently installed RPGMaster Mods - including RoundMaster | '
+ +'ALL | Loads the whole state variable for all installed Mods (See Note below) | '
+ +'API-names | Loads the state variables for the named API/Mod(s). Additional names can be separated with pipes "|" (See note below). | '
+ +' '
+ +' Note: Individual API / Mod names are case sensitive (except for RPGM API names, which are checked & corrected automatically). No guarantee is given for the validity or effect of loading non-RPGM API / Mod state variables. You should only load non-RPGM state variables to a copy of a campaign you are prepared to loose if the load does not work properly. '
+ +' !rounds --disptokenstatus [tokenID] '
+ +' Shows the statuses on the specified token to the DM using the same display format as used in the Turn Announcement. '
+ +' !rounds --dancer [INHAND/REBUILD]|tokenID|weapon|[plusChange]|[duration] '
+ +' If cmd is INHAND, gives the identified weapon a dancing-inhand status, with the change in the magical plus of the weapon each round (can be zero or negative, defaults to +1), and the number of rounds to be used in-hand before dancing for that number of rounds (defaults to 4). Also automatically creates the necessary effects to make the weapon dance based on the templates in the in-memory database. '
+ +' If cmd is REBUILD, rebuilds the dancing effects for the specified weapon in the in-memory database, but does not apply any statuses: this version of the command is generally used after the Roll20 campaign is restarted when a character already has a dancing weapon in-hand. '
+ +' !rounds --listmarkers '
+ +' Shows a display of all markers available in the API to the DM, and also lists which are currently in use. '
+ +' !rounds --listfav '
+ +' Shows statuses to the DM that have been defined as favourites (see the edit command), and provides buttons to allow the DM to apply one or more favourite statuses to the selected token(s), and to edit the favourite statuses or remove them as favourites. '
+ +' 3. Other commands'
+ +' !rounds --help '
+ +' Displays a listing of RoundMaster commands and their syntax. '
+ +' !rounds --hsq from|[command] '
+ +'!rounds --handshake from|[command] '
+ +' Either form performs a handshake with another API, whose call (without the \'!\') is specified as from in the command parameters. The command calls the from API command responding with its own command to confirm that RoundMaster is loaded and running: e.g. '
+ +' Received: !rounds --hsq magic '
+ +'Response: !magic --hsr rounds
'
+ +' Optionally, a command query can be made to see if the command is supported by RoundMaster if the command string parameter is added, where command is the RoundMaster command (the \'--\' text without the \'--\'). This will respond with a true/false response: e.g. '
+ +' Received: !rounds --hsq init|addtotraker '
+ +'Response: !init --hsr rounds|addtotracker|true '
+ +' !rounds --debug (ON/OFF) '
+ +' Takes one mandatory argument which should be ON or OFF. '
+ +' The command turns on a verbose diagnostic mode for the API which will trace what commands are being processed, including internal commands, what attributes are being set and changed, and more detail about any errors that are occurring. The command can be used by the DM or any Player - so the DM or a technical advisor can play as a Player and see the debugging messages. '
+ +' ',
+ },
+ EffectsDB_help: {name:'Effects Database Help',
+ version:1.12,
+ avatar:'https://s3.amazonaws.com/files.d20.io/images/257656656/ckSHhNht7v3u60CRKonRTg/thumb.png?1638050703',
+ bio:' '
+ +'Effects Database Help v1.12'
+ +' '
+ +' '
+ +' Effect Database for RoundMaster API v'+version+''
+ +' Effect-DB is a database character sheet created, used and updated by the RoundMaster API (see separate handout). The database holds macros as Ability Macros that are run when certain matching statuses are placed on or removed from tokens (see Roll20 Help Centre for information on Ability Macros and Character Sheet maintenance). The macros are run when various events occur, such as end-of-round or Character\'s turn, at which point no token or an incorrect token may be selected - this makes @{selected|attribute-name} useless as a macro command. Therefore, the macros have certain defined parameters dynamically replaced when run by RoundMaster, which makes the token & character IDs and names, and values such as AC, HP and Thac0, available for manipulation. '
+ +' The Effects database as distributed with the API holds many effects that work with the spell & magic item macros distributed with other RPGMaster APIs. The API also checks for, creates and updates the Effects database to the latest version on start-up. DMs can add their own effects to additional databases, but the database provided is totally rewritten when new updates are released and so the DM must add their own database sheets. If the provided databases are accidentally deleted or overwritten, they will be automatically recreated the next time the Campaign is opened. Additional databases should be named as Effects-DB-[added-name] where "[added-name]" can be any name you want. '
+ +' However: the system will ignore any database with a name that includes a version number of the form "v#.#" where # can be any number or group of numbers e.g. Effects-DB v2.13 will be ignored. This is so that the DM can version control their databases, with only the current one (without a version number) being live. '
+ +' There can be as many additional databases as you want. Other Master series APIs come with additional databases, some of which overlap - this does not cause a problem as version control and merging unique macros is managed by the APIs. '
+ +' Important Note: all Character Sheet databases must have their \'ControlledBy\' value (found under the [Edit] button at the top right of each sheet) set to \'All Players\'. This must be for all databases, both those provided (set by the API) and any user-defined ones. Otherwise, Players will not be able to run the macros contained in them. '
+ +' Effect macros are primarily intended to act on the Token and its variables, but can also act on the represented Character Sheet. A single Character Sheet can have multiple Tokens representing it, and each of these are able to do individual actions using the data on the Character Sheet jointly represented. However, if such multi-token Characters / NPCs / creatures are likely to encounter effects that will affect the Character Sheet they must be split with each Token representing a separate Character Sheet, or else the one effect will affect all tokens associated with the Character Sheet, whether they were targeted or not! In fact, it is recommended that tokens and character sheets are 1-to-1 to keep things simple. '
+ +' Note: Effect macros are heavily dependent upon the ChatSetAttr API and the Tokenmod API, both from the Roll20 Script Library, and they must be loaded. It is also highly recommended to load all the other RPGMaster series APIs: InitiativeMaster, AttackMaster, MagicMaster and CommandMaster. This will provide the most immersive game-support environment '
+ +' Replacing Distributed Effects'
+ +' The RoundMaster API is distributed with an Effect Database containing effects to support items provided in other RPGMaster series APIs. If you want to replace any Effect macro in the provided database, you can do so simply by creating an Ability Macro in one of your own Effect databases with exactly the same name as the provided item to be replaced. The API gives preference to Ability Macros in user-defined databases, so yours will be selected in preference to the one provided with the APIs. '
+ +' Setup of the Token'
+ +' The recommended Token Bar assignments for all APIs in the Master Series are: '
+ +' '
+ +'Bar1 (Green Circle): | Armour Class (AC field) - only current value | '
+ +'Bar2 (Blue Circle): | Base Thac0 (thac0-base field) before adjustments - only current value | '
+ +'Bar3 (Red Circle): | Hit Points (HP field) - current & max | '
+ +' '
+ +' It is recommended to use these assignments, and they are the bar assignments set by the CommandMaster API if its facilities are used to set up the tokens. All tokens must be set the same way, whatever way you eventually choose. '
+ +' These assignments can be changed in the RoundMaster API, by changing the fields object near the top of the API script. See the RPGMaster Character Sheet setup Handout for details of how to do this. However, when using the Effect place holders in the effect macros, the APIs will always search the token and character sheet for the most appropriate field assignments - if you link the token bars differently, the APIs will look at the fields so linked and attempt to use/change/maintain the appropriate ones you have assigned. '
+ +' Macro Parameter Fields'
+ +' Dynamic parameters are identified in the macros by bracketing them with two carets: ^^parameter^^. The standard Roll20 syntax of @{selected|...} is not available, as at the time the macros run the targeted token may not be selected, and @{character_name|...} will not enable the token to be affected (especially where the Character Sheet is represented by more than one token). The ^^...^^ parameters always relate to the token on which a status has been set, and the Character Sheet it represents. Currently available parameters are: '
+ +' '
+ +' '
+ +' Place holder | Replaced with | '
+ +' '
+ +' ^^tid^^ | TokenID | '
+ +' ^^tname^^ | Token_name | '
+ +' ^^cid^^ | CharacterID | '
+ +' ^^cname^^ | Character_name | '
+ +' | '
+ +' ^^ac^^ | Armour Class value (order looked for: a token bar linked to an appropriate field, Character Sheet AC field, MonsterAC - see note) | '
+ +' ^^ac_max^^ | Maximum value of AC, wherever it is found | '
+ +' ^^token_ac^^ | The token field name for AC value field, if set as a token bar | '
+ +' ^^token_ac_max^^ | The token field name for AC max field, if set as a token bar | '
+ +' | '
+ +' ^^thac0^^ | Thac0 value (order looking: a token bar linked to an appropriate field, Character Sheet Thac0_base field, MonsterThac0 - see note) | '
+ +' ^^thac0_max^^ | Maximum value of Thac0, wherever it is found | '
+ +' ^^token_thac0^^ | The token field name for Thac0 value field, if set as a token bar | '
+ +' ^^token_thac0_max^^ | The token field name for Thac0 max field, if set as a token bar | '
+ +' | '
+ +' ^^hp^^ | HP value (order looked for: a token bar linked to an appropriate field, Character Sheet HP field - see note) | '
+ +' ^^hp_max^^ | Maximum value of HP, wherever it is found | '
+ +' ^^token_hp^^ | The token field name for HP value field, if set as a token bar | '
+ +' ^^token_hp_max^^ | The token field name for HP max field, if set as a token bar | '
+ +' | '
+ +' ^^bar1_current^^ | Value of the token Bar1_value field | '
+ +' ^^bar2_current^^ | Value of the token Bar2_value field | '
+ +' ^^bar3_current^^ | Value of the token Bar3_value field | '
+ +' '
+ +' Note: If a legal value is not found in any of these fields, the value in the token bar specified in the API fields object will be used as a last resort. '
+ +' This allows most data on both the token and the character sheet to be accessed. For example @{^^cname^^|strength} will return the strength value from the represented character sheet. Of course all loaded RPGMaster series API commands are available, along with commands for any other APIs you have loaded. '
+ +' Two other APIs from the Roll20 Script Library are extremely useful for these macros, and indeed are used by many of the provided APIs: ChatSetAttr API from joesinghaus allows easy and flexible setting of Character Sheet attributes. Tokenmod API from The Aaron supports easy setting and modifying of Token attributes. Combined with the dynamic parameters above, these make for exceptionally powerful real-time effects in game-play. '
+ +' Effect Macro qualifiers'
+ +' Each effect macro runs when a particular status event occurs. Here is the complete list of effect macro status name qualifiers that can be used. Each of these is appended to the status whenever the status experiences the relevant event, and an effect macro with that name searched for and run if found: '
+ +' '
+ +' statusname-start | The status is created on a token | '
+ +' statusname-turn | Each round the status has a duration that is not zero | '
+ +' statusname-end | The status duration reaches zero | '
+ +' '
+ +' These effect macros are triggered for weapons when certain events take place: '
+ +' '
+ +' weaponname-inhand | A weapon is taken in-hand (triggered by AttackMaster API --weapon command) | '
+ +' weaponname-dancing | A weapon starts dancing (triggered by AttackMaster API --dance command) | '
+ +' weaponname-sheathed | A weapon is sheathed (out of hand - triggered by AttackMaster --weapon cmd) | '
+ +' '
+ +' Examples of Effect Macros'
+ +' Here is an example of an effect macro that runs when a Faerie fire (twilight form) status is placed on a token. The following --target command might be run to set this status, with the caster token selected: '
+ +' !rounds --target area|@{selected|token_id}|@{target|Select first target|token_id}|Faerie-Fire-twilight|[[4*@{selected|Casting-Level}]]|-1|Outlined in dim Faerie Fire, 1 penalty to AC|aura '
+ +' (See the RoundMaster Help handout for an explanation of the --target command and its parameters). This command will result in the following effect macro being run when the first token is targeted: '
+ +' Faerie-fire-twilight-start'
+ +' !token-mod --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|+1 '
+ +'^^tname^^ is surrounded by Faerie Fire, and becomes easier to hit '
+ +' This uses the Tokenmod API to increase the AC number of the targeted token by 1 (making it 1 wose), and then display a message to all Players stating the name of the targeted token, and the effect on it. This will be run for each token targeted, and will be individual to each. Note: the tokens are not \'selected\' in Roll20 terms, and so @{selected|...} will not work '
+ +' When the Faerie Fire status counts down to zero, the following effect macro will be run on each of the tokens it was applied to: '
+ +' Faerie-fire-twilight-end'
+ +' !token-mod --ignore-selected --ids ^^tid^^ --set ^^token_ac^^|-1 '
+ +'^^tname^^ has lost that glow and is now harder to aim at '
+ +' Again, the Tokenmod API is used to decrease the token AC and a message issued confirming what has happened. If messages should only be sent to the Player(s) controlling the character represented by the token, use /w "^^cname^^" before the message. If the message is only for the gm, use /w gm. '
+ +' A more complex example is a Quarterstaff of Dancing, that uses the complete suite of possible effect macros and certain aspects of the AttackMaster API functionality triggered by Weapon table field settings. The first macro is triggered by AttackMaster API when a Character takes a Quarterstaff-of-Dancing in hand to use as a weapon: '
+ +' Quarterstaff-of-Dancing-inhand'
+ +' !rounds --addtargetstatus ^^tid^^|Quarterstaff-of-Dancing|4|-1|Quarterstaff not yet dancing so keep using it|stopwatch '
+ +' This command sets a status marker on the Token of the Character taking the Quarterstaff in hand, and sets a countdown of 4 rounds, running the next effect macro in each of those rounds: '
+ +' Quarterstaff-of-Dancing-turn'
+ +' '
+ +'!attk --quiet-modweap ^^tid^^|quarterstaff-of-dancing|melee|+:+1 --quiet-modweap ^^tid^^|quarterstaff-of-dancing|dmg|+:+1 '
+ +'/w "^^cname^^" Updating the quarterstaff +1 to attk & dmg '
+ +' This command then runs each round as the Quarterstaff-of-Dancing status counts down, and uses the !attk --quiet-modweap command to gradually increment the magical to-hit and dmg plus, round by round. Once the countdown reaches zero, the next effect macro is run: '
+ +' Quarterstaff-of-Dancing-end'
+ +' '
+ +'!attk --dance ^^tid^^|Quarterstaff-of-Dancing '
+ +' This calls an AttackMaster API command to start the weapon dancing, resets the weapon to its specs that it starts dancing with, and the AttackMaster API then automatically calls the next effect macro: '
+ +' Quarterstaff-of-Dancing-dancing'
+ +' '
+ +'!rounds --addtargetstatus ^^tid^^|Dancing-Quarterstaff|4|-1|The Quarterstaff is Dancing by itself. Use this time wisely!|all-for-one '
+ +'!attk --quiet-modweap ^^tid^^|quarterstaff-of-dancing|melee|sb:0 --quiet-modweap ^^tid^^|quarterstaff-of-dancing|dmg|sb:0 '
+ +' This places a new status marker on the token representing the Character with the dancing weapon (note the new status name Dancing-Quarterstaff), and resets the Strength Bonus flags for the weapon - a dancing weapon can\'t have the Strength Bonus of the wielder. As each round now passes, the following different status effect macro is run: '
+ +' Dancing-Quarterstaff-turn'
+ +' '
+ +'!attk --quiet-modweap ^^tid^^|quarterstaff-of-dancing|melee|+:+1 --quiet-modweap ^^tid^^|quarterstaff-of-dancing|dmg|+:+1 '
+ +' As per the previous -turn effect macro, this increments the magical plusses on To-Hit and Dmg, round by round. It has to have a different name, as the -end effect macro does different actions: '
+ +' Dancing-Quarterstaff-end'
+ +' '
+ +'!attk --dance ^^tid^^|Quarterstaff-of-Dancing|stop '
+ +' This uses the AttackMaster API command to stop the Quarterstaff from dancing. As can be seen from the above, quite complex sequences of effect macros can be created. '
+ +' ',
+ },
+
+ });
+
+ var RoundMaster_tmp = (function() {
+ var templates = {
+ button: _.template(' <%= text %>'),
+ confirm_box: _.template(' '
+ + ' '
+ + '<%= message %>'
+ + ' '
+ + ' '
+ + ''
+ + ''
+ + '<%= confirm_button %>'
+ + ' | '
+ + ''
+ + '<%= reject_button %>'
+ + ' | '
+ + ' '
+ + ' '
+ + ' ')
+ };
+
+ return {
+ getTemplate: function(tmpArgs, type) {
+ var retval;
+
+ retval = _.find(templates, function(e,i) {
+ if (type === i) {
+ {return true;}
+ }
+ })(tmpArgs);
+
+ return retval;
+ },
+
+ hasTemplate: function(type) {
+ if (!type)
+ {return false;}
+ return !!_.find(_.keys(templates), function(elem) {
+ {return (elem === type);}
+ });
+
+ }
+ };
+ }());
+
+ /**
+ * PendingResponse constructor
+ */
+ var PendingResponse = function(type,func,args) {
+ if (!type || !args)
+ {return undefined;}
+
+ this.type = type;
+ this.func = func;
+ this.args = args;
+ };
+
+ /**
+ * PendingResponse prototypes
+ */
+ PendingResponse.prototype = {
+ getType: function() { return this.type; },
+ getArgs: function() { return this.args; },
+ doOps: function(carry) {
+ if (!this.func)
+ {return null;}
+ return this.func(this.args,carry);
+ },
+ doCustomOps: function(args) { return this.func(args); },
+ };
+
+ /**
+ * Add a pending response to the stack, return the associated hash
+ * TODO make the search O(1) rather than O(n)
+ */
+ var addPending = function(pr,hash) {
+ if (!pr)
+ {return null;}
+ if (!hash)
+ {hash = genHash(pr.type+pr.args,pending);}
+ var retval = hash;
+ if (pending) {
+ if (pending[hash]) {
+ throw 'hash already in pending queue';
+ }
+ pending[hash] = {};
+ pending[hash].pr = pr;
+ } else {
+ pending = {};
+ pending[hash] = {};
+ pending[hash].pr = pr;
+ }
+ return retval;
+ };
+
+ /**
+ * find a pending response
+ */
+ var findPending = function(hash) {
+ var retval = null;
+ if (!pending)
+ {return retval;}
+ retval = pending[hash];
+ if (retval)
+ {retval = retval.pr;}
+ return retval;
+ };
+
+ /**
+ * Clear pending responses
+ */
+ var clearPending = function(hash) {
+ if (pending[hash])
+ {delete pending[hash]; }
+ };
+
+ /**
+ * @author lordvlad @stackoverflow
+ * @contributor Ken L.
+ */
+ var genHash = function(seed,hashset) {
+ if (!seed)
+ {return null;}
+ seed = seed.toString();
+ var hash = seed.split("").reduce(function(a,b) {a=((a<<5)-a)+b.charCodeAt(0);return a&a;},0);
+ if (hashset && hashset[hash]) {
+ var d = new Date();
+ return genHash((hash+d.getTime()*Math.random()).toString(),hashset);
+ }
+ return hash;
+ };
+
+ /**
+ * Init
+ */
+ var init = function() {
+ try {
+ if (!state.roundMaster)
+ {state.roundMaster = {};}
+ if (!state.roundMaster.effects)
+ {state.roundMaster.effects = {};}
+ if (!state.roundMaster.statuses)
+ {state.roundMaster.statuses = [];}
+ if (!state.roundMaster.favs)
+ {state.roundMaster.favs = {};}
+ if (!state.roundMaster.viewer) {
+ state.roundMaster.viewer = {};
+ state.roundMaster.viewer.is_set = false;
+ state.roundMaster.viewer.pid = '';
+ state.roundMaster.viewer.echo = 'on';
+ }
+ if (_.isUndefined(state.roundMaster.gmTrackAction))
+ {state.roundMaster.gmTrackAction = {};}
+ if (_.isUndefined(state.roundMaster.rotation))
+ {state.roundMaster.rotation = true;}
+ if (_.isUndefined(state.roundMaster.nosave))
+ {state.roundMaster.nosave = true;}
+ if (_.isUndefined(state.roundMaster.debug))
+ {state.roundMaster.debug = false;}
+ if (!state.roundMaster.round)
+ {state.roundMaster.round = 1;
+ log(`-=> roundMaster round reset <=-`);}
+
+ // RED: v3.019 check the version of any existing Effects databases,
+ // and update them as necessary, creating any missing ones.
+ // RED: v4.035 removed, as now read the data directly
+
+ // setTimeout( () => doUpdateEffectsDB(['Silent']), 10 );
+
+ // RED: v3.020 added the help-text handouts and a
+ // function to create and update them
+ setTimeout( () => updateHandouts(true,findTheGM()), 10);
+
+ // RED: v1.301 determine if the Initiative Macro Library is present
+ var initLib = findObjs({ _type: 'character' , name: 'Initiative' });
+ flags.canSetRoundCounter = false;
+ if (initLib) {
+ flags.canSetRoundCounter = (initLib.length > 0);
+ }
+
+ // RED: Forced an update of the Turnorder so that the state of the
+ // RoundMaster is correctly displayed on startup
+
+ var turnorder = undefined;
+ prepareTurnorder(turnorder);
+ updateTurnorderMarker(turnorder);
+
+ // RED: log the version of the API Script
+
+ log('-=> RoundMaster v'+version+' <=- ['+(new Date(lastUpdate*1000))+']');
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster Initialisation');
+ }
+ };
+
+ /**
+ * RED: v1.301 Find the GM, generally when a player can't be found
+ */
+
+ var findTheGM = function() {
+ var playerGM,
+ players = findObjs({ _type:'player' });
+
+ if (players.length !== 0) {
+ if (!_.isUndefined(playerGM = _.find(players, function(p) {
+ var player = p;
+ if (player) {
+ if (playerIsGM(player.id)) {
+ state.roundMaster.gmID = player.id;
+ return player.id;
+ }
+ }
+ }))) {
+ return playerGM.id;
+ }
+ }
+ return state.roundMaster.gmID;
+ }
+
+ /*
+ * Store the Turn Order back to the Campaign object
+ */
+
+ var storeTurnorder = function( turnorder ) {
+ turnorder.reduce((m,t)=>{
+ let o = getObj('graphic',t.id);
+ if(o){
+ t._pageid = o.get('pageid');
+ }
+ return [...m,t];
+ },[]);
+ turnorder = JSON.stringify(turnorder);
+ Campaign().set('turnorder',turnorder);
+ return;
+ }
+
+
+ /**
+ * check if the character object exists, return first match
+ */
+ var characterObjExists = function(name, type, charId) {
+ var retval = null;
+
+ var obj = findObjs({
+ _type: type,
+ name: name,
+ _characterid: charId
+ });
+ if (obj.length > 0) {
+ retval = obj[0];
+ }
+
+ return retval;
+ };
+
+ /*
+ * Determine if the token is controlled by a player
+ */
+
+ var isPlayerControlled = function( curToken ) {
+
+ var charID = curToken.get('represents'),
+ isPlayer = false,
+ charCS,
+ controlledBy,
+ players;
+ if (charID) {
+ charCS = getObj('character',charID);
+ }
+ if (charCS) {
+ controlledBy = charCS.get('controlledby');
+ if (controlledBy.length > 0) {
+ controlledBy = controlledBy.split(',');
+ isPlayer = _.some( controlledBy, function(playerID) {
+ players = findObjs({_type: 'player', _id: playerID});
+ return (players && players.length > 0);
+ })
+ }
+ }
+ return isPlayer;
+ }
+
+ /**
+ * Return the string with the roll formatted, this is accomplished by simply
+ * surrounding roll equations with [[ ]] TODO, should be replaced with a
+ * single regex
+ *
+ */
+ var getFormattedRoll = function(str) {
+ if (!str) {return "";}
+ var retval = str,
+ re = /\d+d\d+/,
+ idx,
+ expr,
+ roll,
+ pre,
+ post;
+
+ if ((roll=re.exec(str))) {
+ expr = getExpandedExpr(roll[0],str,roll.index);
+ idx = str.indexOf(expr);
+ pre = str.substring(0,idx);
+ post = str.substring(idx+expr.length);
+ } else { return retval;}
+ return pre+"[["+expr+"]]"+getFormattedRoll(post);
+ };
+
+ /**
+ * Return the target expression expanded as far as it logically can span
+ * within the provided line.
+ *
+ * ie: target = 1d20
+ * locHint = 4
+ * line = "2+1d20+5+2d4 bla (bla 1d20+8 bla) bla (4d8...) bla bla"
+ *
+ * result = 2+1d20+5+2d4
+ */
+ var getExpandedExpr = function(target, line, locHint) {
+ if (!target || !line)
+ {return;}
+ if (!locHint)
+ {locHint = 0;}
+ var retval = target,
+ re = /\d|[\+\-]|d/,
+ loc = -1,
+ start = 0,
+ end = 0;
+
+ if((loc=line.indexOf(target,locHint)) !== -1) {
+ start = loc;
+ while (start >= 0) {
+ if (line[start].match(re))
+ {start--;}
+ else
+ {start++;break;}
+ }
+ start = Math.max(start,0);
+ end = loc;
+ while (end < line.length) {
+ if (line[end].match(re))
+ {end++;}
+ else
+ {break;}
+ }
+ retval = line.substring(start,end);
+ retval = getLegalRollExpr(retval);
+ }
+
+ return retval;
+ };
+
+ /**
+ * Gets a legal roll expression.
+ */
+ var getLegalRollExpr = function(expr) {
+ if (!expr) {return;}
+ var retval = expr,
+ stray = expr.match(/d/g),
+ valid = expr.match(/\d+d\d+/g),
+ errMsg = "Illegal expression " + expr;
+
+ try {
+ if (expr.match(/[^\s\d\+-d]/g) ||
+ !stray ||
+ !valid ||
+ (stray.length =! valid.length))
+ {throw errMsg;}
+
+ stray = expr.match(/\+/g);
+ valid = expr.match(/\d+\+\d+/g);
+ if ((stray !== null) && (valid !== null) &&
+ (stray.length !== valid.length))
+ {throw errMsg;}
+ stray = expr.match(/-/g);
+ valid = expr.match(/\d+-\d+/g);
+ if ((stray !== null) && (valid !== null) &&
+ (stray.length !== valid.length))
+ {throw errMsg;}
+ } catch (e) {
+ throw e;
+ }
+
+ //check for leading, trailing, operands
+ if (retval[0].match(/\+|-/))
+ {retval = retval.substring(1);}
+ if (retval[retval.length-1].match(/\+|-/))
+ {retval = retval.substring(0,retval.length-1);}
+
+ return retval;
+ };
+
+ /**
+ * RED: v1.190 Added in the inline roll evaluator from ChatSetAttr script v1.9
+ * by Joe Singhaus and C Levett.
+ **/
+
+ var processInlinerolls = function (msg) {
+ if (msg.inlinerolls && msg.inlinerolls.length) {
+ return msg.inlinerolls.map(v => {
+ const ti = v.results.rolls.filter(v2 => v2.table)
+ .map(v2 => v2.results.map(v3 => v3.tableItem.name).join(", "))
+ .join(", ");
+ return (ti.length && ti) || v.results.total || 0;
+ })
+ .reduce((m, v, k) => m.replace(`$[[${k}]]`, v), msg.content);
+ } else {
+ return msg.content;
+ }
+ };
+
+ /*
+ * Function to replace special characters in a string
+ */
+
+ var parseStr = function(str,rep=replacers){
+ return rep.reduce((m, rep) => m.replace(rep[0], rep[1]), str);
+ }
+
+ /**
+ * Calculate/roll a value that has a range
+ * Always tries to create a 3 dice bell curve for the value
+ **/
+
+ var calcAttr = function( attr='3:18' ) {
+ let attrRange = attr.split(':'),
+ low = parseInt(attrRange[0]),
+ high = parseInt(attrRange[1]);
+ if (high && !isNaN(low) && !isNaN(high)) {
+ let range = high - (low - 1);
+ if (range === 2) {
+ return low - 1 + randomInteger(2);
+ } else if (range === 3) {
+ return low - 2 + randomInteger(2) + randomInteger(2);
+ } else if (range === 5) {
+ return low - 2 + randomInteger(3) + randomInteger(3);
+ } else if ((range-2)%3 === 0) {
+ return low - 3 + randomInteger(Math.ceil(range/3)+1) + randomInteger(Math.floor(range/3)+1) + randomInteger(Math.floor(range/3)+1);
+ } else if ((range-1)%3 === 0) {
+ return low - 3 + randomInteger(Math.ceil(range/3)) + randomInteger(Math.ceil(range/3)) + randomInteger(Math.ceil(range/3));
+ } else if ((range)%3 === 0) {
+ return low - 3 + randomInteger((range/3)+1) + randomInteger((range/3)+1) + randomInteger(range/3);
+ }
+ }
+ return attr;
+ }
+
+ /**
+ * A function to calculate an internal dice roll
+ */
+
+ var rollDice = function( count, dice, reroll ) {
+ count = parseInt(count || 1);
+ dice = parseInt(dice || 8);
+ reroll = parseInt(reroll || 0);
+ let total = 0,
+ roll;
+ for (let d=0; d {
+ if (obj.get('_type') !== 'graphic' || obj.get('_subtype') !== 'token' || obj.get('_pageid') !== pageId || !obj.get('represents')) return false;
+ let charObj = getObj('character',obj.get('represents'));
+ if (!charObj) return false;
+ let controllers = charObj.get('controlledby'),
+ sight = obj.get('has_bright_light_vision');
+ if (controllers.includes(playerId)) return false;
+ if (!controllers.length && sight) obj.set('has_bright_light_vision',false);
+ if (sight) objList.sighted.push(obj);
+ else objList.blind.push(obj);
+ return true;
+ });
+ _.each( tempList, obj => {
+ let charObj = getObj('character',obj.get('represents'));
+ let controllers = charObj.get('controlledby');
+ if (controllers.includes(playerId)) return;
+ charObj.set('controlledby',controllers+','+playerId);
+ });
+ } else {
+ _.each( objList.sighted, obj => {
+ let charObj = getObj('character',obj.get('represents'));
+ charObj.set('controlledby',charObj.get('controlledby').split(',').filter((pid) => pid !== playerId).join(','));
+ obj.set('has_bright_light_vision',true);
+ });
+ _.each( objList.blind, obj => {
+ let charObj = getObj('character',obj.get('represents'));
+ charObj.set('controlledby',charObj.get('controlledby').split(',').filter((pid) => pid !== playerId).join(','));
+ });
+ objList = undefined;
+ };
+ return objList;
+ };
+
+ /*
+ * Get a bar value from the right place for this token. This should be from
+ * a bar current value on the token (to support multi-token monsters affected
+ * individually by +/- magic impacts on bar values) but checks if another bar allocated
+ * or, if none are, get from character sheet (monster or character)
+ * NOTE: Different from RPGMaster Library function!!!
+ */
+
+ var getTokenValues = function( curToken, tokenBar, field, altField ) {
+
+ var charCS = getObj('character', curToken.get('represents')),
+ attr = field[0].toLowerCase(),
+ altAttr = altField ? altField[0].toLowerCase() : 'EMPTY',
+ property = field[1],
+ attrVal = {},
+ attrName = {current:'',max:''},
+ attrObj, barName, linkedToken, fieldIndex;
+
+ if (state.RPGMaster && state.RPGMaster.tokenFields) {
+ fieldIndex = state.RPGMaster.tokenFields.indexOf( field[0] );
+ } else {
+ fieldIndex = parseInt(tokenBar[4]) || -1;
+ }
+
+ if (_.some( ['bar2_link','bar1_link','bar3_link'], linkName=>{
+ let linkID = curToken.get(linkName);
+ let tokenField = linkName;
+ barName = '';
+ if (linkID && linkID.length) {
+ linkedToken = true;
+ attrObj = getObj('attribute',linkID);
+ if (attrObj) {
+ attrName = attrObj.get('name').toLowerCase();
+ barName = tokenField.substring(0,4);
+ return (attrName == attr) || (attrName == altAttr);
+ }
+ }
+ return false;
+ })) {
+ attrName = {current:barName+'_value', max:barName+'_max'};
+ attrVal = {current:attrObj.get('current'), max:attrObj.get('max')};
+ }
+ if (isNaN(attrVal) && !linkedToken && fieldIndex >= 0) {
+ barName = 'bar'+(fieldIndex+1);
+ attrName = {current:barName+'_value', max:barName+'_max'};
+ attrVal = {current:parseFloat(curToken.get(barName+'_value')),max:parseFloat(curToken.get(barName+'_max'))};
+ }
+ if (charCS && isNaN(parseFloat(attrVal.current))) {
+ attrName = {current:'',max:''};
+ if (attr.includes('thac0')) {
+ attrObj = findAttrObj( charCS, fields.Thac0_base[0] );
+ attrVal = {current:parseInt(attrObj.get('current')),
+ max:parseInt(attrObj.get('max'))};
+ }
+ if (isNaN(parseFloat(attrVal.current))) {
+ attrObj = findAttrObj( charCS, field[0] );
+ attrVal = {current:parseInt(attrObj.get('current')),
+ max:parseInt(attrObj.get('max'))};
+ }
+ if (altField && isNaN(parseFloat(attrVal.current))) {
+ attrObj = findAttrObj( charCS, altField[0] );
+ attrVal = {current:parseInt(attrObj.get('current')),
+ max:parseInt(attrObj.get('max'))};
+ }
+ }
+ return [attrVal,attrName];
+ }
+
+ /**
+ * Prepare the turn order by checking if the tracker is present,
+ * if so, then we're resuming a previous turnorder (perhaps a restart).
+ * Fetch information from the state and double check that all refereces
+ * line up. If any references don't line up anymore, inform the GM of
+ * this, then remove them from the tracker. In the case of items existing
+ * on the tracker, perform normal impomtu add behavior.
+ */
+ var prepareTurnorder = function(turnorder) {
+ if (!turnorder)
+ {turnorder = Campaign().get('turnorder');}
+ if (!turnorder)
+ {turnorder = [];}
+ else if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+
+ var tracker,
+ rounds,
+ roundCtrCmd;
+
+ if (tracker = _.find(turnorder, function(e,i) {if (parseInt(e.id) === -1 && parseInt(e.pr) === -100 && e.custom.match(/Round\s*\d+/)){return true;}})) {
+ // resume logic
+ // RED: v1.190 Set an attribute in the character sheet Initiative to the value of round
+ // RED: v1.190 Requires that the API ChatSetAttr is loaded and Initiative exists
+ // RED: v1.207 Added status flags to control if ChatSetAttr & Initiative exist
+ // RED: v2.007 Added status and call to new initMaster API Script to set round using --isRound command
+ rounds = tracker.custom.substring(tracker.custom.indexOf('Round')).match(/\d+/);
+ if (flags.canSetAttr && flags.canSetRoundCounter) {
+ roundCtrCmd = '!setattr --silent --name Initiative --round-counter|' + rounds;
+ sendRmAPI(roundCtrCmd);
+ }
+ if (flags.canUseInitMaster) {
+ roundCtrCmd = '!init --isRound ' + rounds;
+ sendRmAPI(roundCtrCmd);
+ }
+ } else {
+ turnorder.push({
+ id: '-1',
+ pr: '-100',
+ custom: 'Round 1',
+ });
+
+ if (!state.roundMaster)
+ {state.roundMaster = {};}
+ if (!state.roundMaster.round)
+ {state.roundMaster.round = 1;}
+ //TODO only clear statuses that have a duration
+ updateTurnorderMarker(turnorder);
+ }
+ if (!state.roundMaster)
+ {state.roundMaster = {};}
+ if (!state.roundMaster.effects)
+ {state.roundMaster.effects = {};}
+ if (!state.roundMaster.statuses)
+ {state.roundMaster.statuses = [];}
+ if (!state.roundMaster.favs)
+ {state.roundMaster.favs = {};}
+ if (!state.roundMaster.round)
+ {state.roundMaster.round = 1;}
+ };
+
+
+ /**
+ * update the status display that appears beneath the turn order
+ * RED: v2.009 added isTurn boolean parameter which triggers increment
+ * of status marker count & calling any '-turn' effect macro
+ * RED: v3.006 changed so that public & hidden statuses are determined
+ * by who controls the character, a player or the GM
+ */
+ var updateStatusDisplay = function(curToken,isTurn) {
+ if (!curToken) {return;}
+ var effects = getStatusEffects(curToken),
+ isPlayer = isPlayerControlled( curToken ),
+ gstatus,
+ statusArgs,
+ toRemove = [],
+ content = '',
+ hcontent = '';
+
+ _.each(effects, function(e) {
+ if (!e) {return;}
+ statusArgs = e;
+ gstatus = statusExists(e.name);
+
+ // RED: v1.204 only need to increment if the first or only turn in the round
+ if (isTurn && parseInt(e.round) !== parseInt(state.roundMaster.round)) {
+ let change = Math.max(state.roundMaster.round - statusArgs.round,0);
+ e.duration = parseInt(statusArgs.duration) +
+ (parseInt(statusArgs.direction) * change);
+ e.round = state.roundMaster.round;
+ if (e.duration > 0) {
+ // RED: v1.301 run the relevant effect-turn macro if it exists
+ sendAPImacro( curToken, statusArgs.msg, statusArgs.name, change, '-turn' );
+ }
+ }
+ if (gstatus.marker && isPlayer)
+ {content += makeStatusDisplay(e,false);}
+ else
+ {hcontent += makeStatusDisplay(e,true);}
+ });
+ effects = _.reject(effects,function(e) {
+ if (e.duration <= 0) {
+
+ // RED: v1.301 when removing the status marker
+ // run the relevant effect-end macro if it exists
+ sendAPImacro( curToken, e.msg, e.name, 0, '-end' );
+ // remove from status args
+ var removedStatus = updateGlobalStatus(e.name,undefined,-1);
+ toRemove.push(removedStatus);
+ return true;
+ }
+ });
+ setStatusEffects(curToken,effects);
+ updateAllTokenMarkers(toRemove);
+ return {public: content, hidden: hcontent};
+ };
+
+ /**
+ * Update the global status array, if a status is removed, return the
+ * removed status (for final cleanup)
+ */
+ var updateGlobalStatus = function(statusName, marker, inc) {
+ if (!statusName || !inc || isNaN(inc)) {return;}
+ var retval;
+ statusName = statusName.toLowerCase();
+ var found = _.find(state.roundMaster.statuses, function(e) {
+ if (e.name === statusName) {
+ retval = e;
+ e.refc = e.refc + (parseInt(inc) || 0);
+ if (e.refc <= 0) {
+ state.roundMaster.statuses = _.reject(state.roundMaster.statuses, function(e) {
+ if (e.name === statusName)
+ {return true;}
+ });
+ }
+ return true;
+ }
+ else if (e.marker && e.marker === marker) {
+ return true;
+ }
+ return false;
+ });
+
+ if (!found) {
+ state.roundMaster.statuses.push({
+ name: statusName.toLowerCase(),
+ marker: marker,
+ tag: libTokenMarkers.getStatus(marker).getTag(),
+ refc: inc
+ });
+ }
+ return retval;
+ };
+
+ /**
+ * Updates every token marker related to a status
+ */
+ var updateAllTokenMarkers = function(toRemove) {
+ var token,
+ isPlayer,
+ effects,
+ tokenStatusString,
+ statusName,
+ status,
+ replaceMarker,
+ foundMarker,
+ foundMarkerVal,
+ markerVals,
+ hasRemovedEffect;
+
+ _.each(_.keys(state.roundMaster.effects), function(e) {
+ token = getObj('graphic',e);
+ if (!token) {
+ return;
+ }
+ effects = _.sortBy(getStatusEffects(token) || [], 'duration').reverse();
+ tokenStatusString = token.get('statusmarkers');
+ if (_.isUndefined(tokenStatusString) || tokenStatusString === 'undefined') {
+ return;
+ }
+ if (!_.isString(tokenStatusString)) {
+ return;
+ }
+
+ isPlayer = isPlayerControlled(token);
+ tokenStatusString = tokenStatusString.split(',');
+
+ if (!!toRemove) {
+ _.each(toRemove,function(e) {
+ if (!e) {return;}
+ hasRemovedEffect = _.findWhere(effects,{name:e.name});
+ if (!hasRemovedEffect) {
+ let marker = libTokenMarkers.getStatuses(e.tag);
+ if (marker.length) marker[0].removeFrom(token);
+ }
+ });
+ }
+
+ tokenStatusString = token.get('statusmarkers').split(/\s*,\s*/g).filter(s => s.length);
+ _.each(effects, function(elem) {
+ statusName = elem.name.toLowerCase();
+ status = _.findWhere(state.roundMaster.statuses,{name: statusName});
+ if (status) {
+ let marker = libTokenMarkers.getStatuses(status.tag);
+ if (marker.length) {
+ marker[0].removeFrom(token);
+ if (isPlayer && elem.duration > 0 && elem.duration <= 9 && elem.direction !== 0) {
+ marker[0].applyWithNumberTo(elem.duration,token);
+ } else {
+ marker[0].applyTo(token);
+ }
+ }
+ }
+ });
+
+ });
+ };
+
+ /**
+ * Update the tracker's marker in the turn order
+ */
+ var updateTurnorderMarker = function(turnorder) {
+ if (!turnorder)
+ {turnorder = Campaign().get('turnorder');}
+ if (!turnorder)
+ {return;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+ var tracker,
+ trackerpos;
+
+ if (!!(tracker = _.find(turnorder, function(e,i) {if (parseInt(e.id) === -1 && parseInt(e.pr) === -100 && e.custom.match(/Round\s*\d+/)){trackerpos = i;return true;}}))) {
+
+ var indicator,
+ graphic = findTrackerGraphic(),
+ rounds = tracker.custom.substring(tracker.custom.indexOf('Round')).match(/\d+/);
+
+ if (rounds) {
+ rounds = parseInt(rounds[0]);
+ state.roundMaster.round = rounds;
+ }
+
+ switch(flags.rw_state) {
+ case RW_StateEnum.ACTIVE:
+ graphic.set('tint_color','transparent');
+ indicator = '\u23F5 ';
+ break;
+ case RW_StateEnum.PAUSED:
+ graphic = findTrackerGraphic();
+ graphic.set('tint_color','#FFFFFF');
+ indicator = '\u23F8 ';
+ break;
+ case RW_StateEnum.STOPPED:
+ graphic.set('tint_color','transparent');
+ indicator = '\u23F9 ';
+ break;
+ default:
+ indicator = tracker.custom.substring(0,tracker.custom.indexOf('Round')).trim();
+ break;
+ }
+ tracker.custom = indicator + 'Round ' + rounds;
+
+ }
+
+ storeTurnorder(turnorder);
+
+ };
+
+ /**
+ * Status exists
+ */
+ var statusExists = function(statusName) {
+ return _.findWhere(state.roundMaster.statuses,{name: statusName});
+ };
+
+ /**
+ * get status effects for a token
+ */
+ var getStatusEffects = function(curToken) {
+ if (!curToken)
+ {return undefined;}
+
+ var effects = state.roundMaster.effects[curToken.get('_id')];
+ if (effects && effects.length > 0)
+ {return effects;}
+ return undefined;
+ };
+
+ /**
+ * set status effects for a token
+ */
+ var setStatusEffects = function(curToken,effects) {
+ if (!curToken)
+ {return;}
+
+ if(Array.isArray(effects))
+ {state.roundMaster.effects[curToken.get('_id')] = effects;}
+ };
+
+ /**
+ * Make the display for editing a status for multiple tokens.
+ * This differs from the single edit case in that it performs
+ * across several tokens.
+ */
+ var makeMultiStatusConfig = function(action, statusName, idString) {
+ if (!action || !statusName || !idString)
+ {return;}
+
+ var content = '',
+ globalStatus = statusExists(statusName),
+ mImg;
+
+ if (!statusName) {
+ sendDebug('makeMultiStatusConfig: Invalid syntax - statusName undefined');
+ return 'Invalid syntax';
+ }
+ if (!globalStatus) {
+ dendDebug('makeMultiStatusConfig: Status no longer exists - globalStatus undefined');
+ return 'Status no longer exists';
+ }
+
+ mImg = libTokenMarkers.getStatuses( globalStatus.marker );
+ if (mImg.length)
+ {mImg = '';}
+ else
+ {mImg = 'none';}
+
+ content += ''
+ + ' '
+ + ' Edit Group Status "'+statusName+'" |
'
+ + ' '
+ + ' '
+ + ''
+ + ''
+ + ' Name '+''+statusName+' '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Marker '+''+mImg+' '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Duration '+'Varies '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Direction '+'Varies '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Message '+'Varies '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ' '
+ + ' ';
+
+ return content;
+
+ };
+
+ /**
+ * Make the display for multi-token configuration in selecting
+ * which status to edit for the group of tokens selected.
+ */
+ var makeMultiTokenConfig = function(tuple) {
+ if (!tuple)
+ {return;}
+
+ var content = '',
+ midcontent = '',
+ gstatus,
+ markerdef;
+
+ _.each(tuple, function(e) {
+ gstatus = statusExists(e.statusName);
+ if (!gstatus)
+ {return;}
+ markerdef = libTokenMarkers.getStatuses(gstatus.marker);
+ midcontent +=
+ ''
+ + (markerdef.length ? (''
+ + ''
+ + ' | '):' | ')
+ + ''
+ + e.statusName
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' ';
+ });
+
+ if ('' === midcontent) {
+ midcontent = 'No Status Effects Present';
+ }
+
+ content += ''
+ + ' '
+ + 'Edit Status Group'
+ + ' '
+ + ' '
+ + 'Warning: Changing a status across multiple tokens will change the status for all selected tokens.'
+ + ' '
+ + ' ';
+ content += midcontent;
+ content += ' ';
+ return content;
+ };
+
+ /**
+ * Build marker selection display
+ */
+ var makeMarkerDisplay = function(statusName,favored,custcommand) {
+ var markerList = '',
+ takenList = '',
+ command,
+ taken,
+ statusMarkers = libTokenMarkers.getOrderedList(),
+ content;
+
+ _.each(statusMarkers,function(e) {
+ let sName = e.getName();
+ if (!favored)
+ {command = (!custcommand ? ('!rounds --marker ' + sName + ' %% ' + statusName) : (custcommand+sName));}
+ else
+ {command = (!custcommand ? ('!rounds --marker ' + sName + ' %% ' + statusName + ' %% ' + 'fav') : (custcommand+sName));}
+ //n*m is evil
+ if (!favored && (taken = _.findWhere(state.roundMaster.statuses,{marker: sName}))) {
+ takenList += ''
+ + ' '
+ +' ';
+ } else {
+ markerList += ''
+ + ' '
+ + ''
+ + ' ';
+ }
+ });
+ content = ''
+ + ' '
+ + 'Available Markers'
+ + ' '
+ + ' '
+ + markerList
+ +' '
+ + ' '
+ + (takenList ? (' '
+ + ' '
+ + 'Taken Markers'
+ + ' '
+ + ' '
+ + takenList
+ +' '
+ + ' '):'')
+ + ' ';
+
+ return content;
+ };
+
+ /**
+ * Build status display
+ */
+ var makeStatusDisplay = function(statusArgs,isGM) {
+ var content = '',
+ gstatus = statusExists(statusArgs.name),
+ markerdef,
+ dir = parseInt(statusArgs.direction),
+ dur = parseInt(statusArgs.duration);
+
+ if (gstatus && gstatus.marker)
+ {markerdef = libTokenMarkers.getStatuses(gstatus.marker);}
+
+ content += ''
+ + ' '
+ + ''
+ + (markerdef.length ? (' | '):'')
+ + ''+(/_([^_]+)_?/.exec(statusArgs.name) || ['',statusArgs.name])[1] + ' ' + (dir === 0 ? '': (dur <= 0 ? 'Expiring':((!isGM && dir < -1) ? '' : dur)))
+ + (dir===0 ? '\u221E' : (dir > 0 ? '\u25B2(+'+dir+')':'\u25BC'+((!isGM && dir < -1) ? '' : ('('+dir+')'))+''))
+ + ((statusArgs.msg) ? (' ' + getFormattedRoll(parseStr(statusArgs.msg)) + ''):'')+' | '
+ + ' '
+ + ' '
+ + ' ';
+ return content;
+ };
+
+ /**
+ * Build round display
+ */
+ var makeRoundDisplay = function(round) {
+ if (!round)
+ {return;}
+ var content = '';
+
+ content += ''
+ + 'Round ' + round
+ +' ';
+ return content;
+ };
+
+ /**
+ * Build turn display
+ */
+ var makeTurnDisplay = function(curToken,msg,isGM=false) {
+ if (!curToken)
+ {return;}
+
+ var content = '',
+ journal,
+ journalId,
+ name,
+ player,
+ action = '',
+ speedobj,
+ speed = 0,
+ controllers = getTokenControllers(curToken);
+
+ // RED: v1.202 don't ever display a name if !showplayers_name
+ if (curToken.get('showplayers_name') || isGM) {
+ if ((journal = getObj('character',curToken.get('represents')))) {
+
+ journalId = journal.get('_id');
+ name = characterObjExists('character_name','attribute',journalId);
+ if (name) {
+ name = name.get('current');
+ } else {
+ name = curToken.get('name');
+ }
+ // else
+ // {name = journal.get('name');}
+ if (!msg && (action = characterObjExists('init_action','attribute',journalId)))
+ {action = action.get('current');}
+ else
+ {action = msg;}
+ if (speedobj = characterObjExists('init_speed','attribute',journalId)) {
+ speed = parseInt(speedobj.get('current'))/10;
+ if (speed > 1) {
+ speedobj.set('current',(speed*10)-10);
+ if (speedobj = characterObjExists('init-carry_speed','attribute',journalId))
+ {speedobj.set('current',(speed*10)-10);}
+ } else if (speedobj = characterObjExists('init-carry','attribute',journalId)) {
+ speedobj.set('current',0);
+ }
+ }
+ } else {
+ name = curToken.get('name');
+ }
+ }
+
+ content += ''
+ + ' '
+ + ''
+ + ''
+ + ' | '
+ + ''
+ + (name ? ('It is ' + name + '\'s turn ' + action + (speed > 1 ? (' for ' + (speed-1) + ' more rounds') : '')) : 'Turn')
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' ';
+
+ if (_.find(controllers,function(e){return (e === 'all');})) {
+ content += ''
+ + 'All Players | '
+ + ' ';
+ } else {
+ _.each(controllers,function(e) {
+ player = getObj('player',e);
+ if (player) {
+ content += ''
+ + '' + player.get('displayname') + ' | '
+ + ' ';
+ }
+ });
+ }
+ content += ' '
+ + " ";
+
+ return content;
+ };
+
+ /**
+ * RED: v1.203 Build Initiative roll display
+ */
+ var makeInitiativeDisplay = function(curToken,initiative,msg) {
+ if (!curToken)
+ {return;}
+
+ var content = '',
+ journal,
+ name,
+ player,
+ controllers = getTokenControllers(curToken);
+
+ if ((journal = getObj('character',curToken.get('represents')))) {
+
+ name = characterObjExists('name','attribute',journal.get('_id'));
+ if (name) {
+ name = name.get('current');
+ } else {
+ name = curToken.get('name');
+ }
+ } else {
+ name = curToken.get('name');
+ }
+
+ content += ''
+ + ' '
+ + ''
+ + ''
+ + ' | '
+ + ''
+ + name + '\'s initiative is ' + parseInt(initiative) + ' ' + msg
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' ';
+
+ content += ' '
+ + " ";
+
+ return content;
+ };
+
+ /**
+ * Build a listing of favorites with buttons that allow them
+ * to be applied to a selection.
+ */
+ var makeFavoriteConfig = function() {
+ var midcontent = '',
+ content = '',
+ markerdef;
+
+ _.each(state.roundMaster.favs,function(e) {
+ markerdef = libTokenMarkers.getStatuses(e.marker);
+ midcontent +=
+ ''
+ + (markerdef.length ? (''
+ + ''
+ + ' | '):' | ')
+ + ''
+ + e.name
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' ';
+ });
+
+ if ('' === midcontent)
+ {midcontent = 'No Favorites Available';}
+
+ content = ''
+ + ' '
+ + 'Favorites'
+ + ' '
+ + ' ';
+ content += midcontent;
+ content += ' ';
+
+ return content;
+ };
+
+ /**
+ * Build a settings dialog given a token that has effects upon it.
+ */
+ var makeStatusConfig = function(curToken, statusName, favored) {
+ if (!statusName || (!curToken && !favored)) {
+ sendDebug('makeStatusConfig: Invalid syntax - statusName or both curToken and favored undefined');
+ return 'Invalid syntax';
+ }
+ var globalStatus = statusExists(statusName),
+ effects = getStatusEffects(curToken),
+ status = _.findWhere(effects,{name:statusName}),
+ mImg,
+ content = '';
+
+ if (!favored && (!status || !globalStatus)) {
+ sendDebug('makeStatusConfig: Invalid syntax - favored or both status and globalStatus undefined');
+ return 'Invalid syntax';
+ }
+
+ if (favored) {
+ status=favored;
+ globalStatus=favored;
+ }
+
+ if (!globalStatus || !status) {
+ sendDebug('makeStatusConfig: Status does not exist internally - globalStatus or status not found');
+ return 'Status does not exist internally';
+ }
+
+ mImg = libTokenMarkers.getStatuses(globalStatus.marker);
+ if (!!mImg.length)
+ {mImg = '';}
+ else
+ {mImg = 'none';}
+
+ content += ''
+ + ' '
+ + ' '+ (favored ? 'Edit Favorite' :('Edit "'+statusName+'" for'))+' | '+(favored ? (''+statusName+' | ') : (' | ')) + '
'
+ + ' '
+ + ' '
+ + ''
+ + ''
+ + ' Name '+''+statusName+' '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Marker '+''+mImg+' '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Duration '+''+status.duration+' '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Direction '+''+status.direction+' '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + ''
+ + ''
+ + ' Message '+''+status.msg+' '
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' '
+ + (favored ? '':(''
+ + ''
+ //+ 'cookies'
+ //+ ' Add to Favorites'
+ + RoundMaster_tmp.getTemplate({command: '!rounds --addfav '+statusName+' %% '+status.duration+' %% '+status.direction+' %% '+status.msg+' %% '+globalStatus.marker, text: 'Add to Favorites'},'button')
+
+ + ' | '
+ + ' '))
+ + ' '
+ + ' ';
+
+ return content;
+
+ };
+
+ /**
+ * Build the token dialog to display statuses effecting it
+ */
+ var makeTokenConfig = function(curToken,gmConfig=true) {
+ if (!curToken) {
+ log('makeTokenConfig: no token');
+ return;
+ }
+
+ var content = '',
+ midcontent = '',
+ gstatus,
+ markerdef,
+ effects = getStatusEffects(curToken);
+
+ _.each(effects, function(e) {
+ gstatus = statusExists(e.name);
+ if (!gstatus)
+ {return;}
+ markerdef = libTokenMarkers.getStatuses(gstatus.marker);
+ midcontent +=
+ ''
+ + (!!markerdef.length ? (''
+ + ''
+ + ' | '):' | ')
+ + ''
+ + e.name
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ''
+ + ''
+ + ' | '
+ + ' ';
+ });
+
+ if ('' === midcontent) {
+ midcontent += 'No Status Effects Present | ';
+ if (gmConfig) midcontent += '';
+ } else if (gmConfig)
+ midcontent += ' | ';
+
+ if (_.isUndefined(state.roundMaster.gmTrackAction[curToken.id])) state.roundMaster.gmTrackAction[curToken.id] = true;
+ if (gmConfig) midcontent += 'Echo initiative to GM | '+(state.roundMaster.gmTrackAction[curToken.id] ? '\u2705' : '\u2B1C')+' | '
+
+ content += ''
+ + ' '
+ + ' ';
+ content += midcontent;
+ content += ' ';
+
+ // RED: changed the parameter seperator in the -addstatus call below from ':' to '|'
+ // RED: to allow use of !rounds calls in API Buttons
+ content += ' ';
+ return content;
+ };
+
+ /**
+ * Show a listing of markers
+ */
+ var doShowMarkers = function() {
+ var disp = makeMarkerDisplay();
+ sendFeedback(disp);
+ };
+
+ /**
+ * Is a tracker
+ */
+ var isTracker = function(turn) {
+ if (parseInt(turn.id) === -1
+ && parseInt(turn.pr) === -100
+ && turn.custom.match(/Round\s*\d+/))
+ {return true;}
+ return false;
+ };
+
+ /**
+ * Get the graphic object for the tracker (if any) for the current page.
+ * If it does not exist, create it. Avoid creating a duplicate where possible
+ */
+ var findTrackerGraphic = function(pageid) {
+ var graphic = getObj('graphic',fields.trackerId),
+ curToken = findCurrentTurnToken();
+
+ pageid = (pageid ? pageid : (curToken ? curToken.get('_pageid') : Campaign().get('playerpageid')));
+
+ if (graphic && graphic.get('_pageid') === pageid) {
+ return graphic;
+ } else {
+ // we find the graphic
+ var cannidates = findObjs({
+ _pageid: pageid,
+ _type: 'graphic',
+ name: fields.trackerName,
+ });
+ if (cannidates && cannidates[0]) {
+ graphic = cannidates[0];
+ fields.trackerId = graphic.get('_id');
+ return graphic;
+ } else {
+ // we make the graphic
+ graphic = createObj('graphic', {
+ _type: 'graphic',
+ _subtype: 'token',
+ _pageid: pageid,
+ name: fields.trackerName,
+ imgsrc: fields.trackerImg,
+ layer: 'gmlayer',
+ width: 70,
+ height: 70,
+ });
+ fields.trackerId = graphic.get('_id');
+ return graphic;
+ }
+ }
+
+ };
+
+ /**
+ * Find the current token at the top of the tracker if any
+ */
+ var findCurrentTurnToken = function(turnorder) {
+ if (!turnorder)
+ {turnorder = Campaign().get('turnorder');}
+ if (!turnorder)
+ {return undefined;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+ if (turnorder && turnorder.length > 0 && turnorder[0].id !== -1)
+ {return getObj('graphic',turnorder[0].id);}
+ return;
+ };
+
+ /**
+ * Announce the round
+ */
+ var announceRound = function(round) {
+ if (!round)
+ {return;}
+ var disp = makeRoundDisplay(round);
+ sendPublic(disp);
+ };
+
+ /**
+ * Announce the turn with an optional rider display
+ */
+ var announceTurn = function(curToken,statusRiders,msg) {
+ if (!curToken)
+ {return;}
+ var disp = makeTurnDisplay(curToken,msg);
+ disp += statusRiders.public;
+ setTimeout(() => {
+ sendPublic(disp);
+ if (!isPlayerControlled( curToken )) {
+ disp = makeTurnDisplay(curToken,msg,true);
+ disp += statusRiders.public;
+ disp += statusRiders.hidden;
+ sendFeedback(disp);
+ }
+ }, 0);
+ };
+
+ /**
+ * RED: function to get an alpha comparison of two turnorder entries
+ **/
+ var compareTokenNames = function(a,b) {
+ if (!a || !b) {return 0};
+ var name1, name2, curToken;
+ if (parseInt(a.id) === -1) {
+ name1 = a.custom;
+ } else {
+ curToken = getObj('graphic',a.id);
+ name1 = curToken.get('name');
+ }
+ if (parseInt(b.id) === -1) {
+ name2 = b.custom;
+ } else {
+ curToken = getObj('graphic',b.id);
+ name2 = curToken.get('name');
+ }
+ if (name1 === name2) {
+ return 0;
+ } else {
+ return (name1 > name2 ? 1 : -1);
+ }
+ };
+
+ /**
+ * Add or remove a playerid to control a character associated with a token
+ */
+
+ var addRemovePID = function(curToken,viewerID,addPlayer,addNPC) {
+ if (!curToken) {return;}
+
+ var charCS,
+ controllers, curCtrl, allCtrl, viewerCtrl = false,
+ tokenSight, curSight,
+ player = getObj('player',viewerID),
+ viewerName = player.get('_displayname'),
+ charID = curToken.get('represents');
+
+ charCS = (charID) ? getObj('character',charID) : false;
+ controllers = (charCS) ? (charCS.get('controlledby') || '') : '';
+ if (!controllers && !addNPC) {return;}
+ curSight = curToken.get('has_bright_light_vision');
+ if (!_.isUndefined(state.roundMaster.viewer[curToken.id])) {
+ tokenSight = state.roundMaster.viewer[curToken.id] = curSight;
+ } else {
+ tokenSight = false;
+ }
+
+ curCtrl = controllers.includes(viewerID);
+ allCtrl = controllers.includes('all');
+ controllers = controllers.split(',').filter(id => (!!id && id != viewerID));
+ addNPC = addNPC || controllers.length;
+ if (addPlayer && addNPC && !allCtrl) {
+ state.roundMaster.viewer[curToken.id] = curSight;
+ controllers.push(viewerID);
+ viewerCtrl = tokenSight = true;
+ }
+
+ if (viewerCtrl != curCtrl) {
+ charCS.set('controlledby',controllers.join());
+ }
+ if (tokenSight != curSight) {
+ curToken.set('has_bright_light_vision',tokenSight);
+ }
+ if ((viewerCtrl != curCtrl) || (tokenSight != curSight)) {
+ setTimeout(function() {
+ curToken.set('left',(curToken.get('left')+(state.roundMaster.round%2 ? 1 : -1))); // moving the token forces a screen update for ray tracing
+ },400);
+ }
+ return;
+ }
+
+ /**
+ * Handle the turn order advancement given the current and prior ordering
+ */
+ var handleAdvanceTurn = function(turnorder,priororder) {
+ if (flags.rw_state === RW_StateEnum.STOPPED || flags.rw_state === RW_StateEnum.PAUSED || !turnorder || !priororder)
+ {return;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+ if (typeof(priororder) === 'string')
+ {priororder = JSON.parse(priororder);}
+ var currentTurn = turnorder[0],
+ newRound = false,
+ curPageID = Campaign().get('playerpageid'),
+ playerPages = Campaign().get('playerspecificpages'),
+ viewerPageID = playerPages[state.roundMaster.viewer.pid] || curPageID,
+ newRoundSort = function(turnorder,sortorder) {
+ switch (sortorder) {
+ case TO_SortEnum.NUMASCEND:
+ turnorder.sort(function(a,b) { return parseInt(a.pr) - parseInt(b.pr); }); break;
+ case TO_SortEnum.NUMDESCEND:
+ turnorder.sort(function(a,b) { return parseInt(b.pr) - parseInt(a.pr); }); break;
+ case TO_SortEnum.ALPHAASCEND:
+ turnorder.sort(function(a,b) { return compareTokenNames(a,b); }); break;
+ case TO_SortEnum.ALPHADESCEND:
+ turnorder.sort(function(a,b) { return compareTokenNames(b,a); }); break;
+ }
+ return turnorder;
+ };
+
+ if (currentTurn) {
+ if (turnorder.length > 1
+ && isTracker(currentTurn)) {
+ // ensure that last turn we weren't also atop the order
+ if (!priororder || isTracker(priororder[0]))
+ {return;}
+ var rounds = parseInt(currentTurn.custom.match(/\d+/)[0]),
+ roundCtrCmd;
+ // RED: this is a newRound
+ newRound = flags.clearonnewround;
+ rounds++;
+ currentTurn.custom = currentTurn.custom.substring(0,currentTurn.custom.indexOf('Round'))
+ + 'Round ' + rounds;
+ announceRound(rounds);
+ turnorder.shift();
+ // RED: Remove Graphic if clearing the turnorder on a newRound
+ // RED: v3.011 If there is a "Viewer" player, add it back into control
+ // list for character sheets
+ if (flags.clearonnewround) {
+ var trackergraphics = findObjs({
+ _type: 'graphic',
+ name: fields.trackerName,
+ });
+ _.each(trackergraphics, function(elem) {
+ if (elem)
+ {elem.remove();}
+ });
+ if (state.roundMaster.viewer.is_set) {
+ filterObjs( obj => {
+ if (obj.get('type') !== 'graphic' || obj.get('subtype') !== 'token') {return false;}
+ addRemovePID(obj,state.roundMaster.viewer.pid,true,false);
+ });
+ state.roundMaster.viewer.tokenID = '';
+ }
+ } else {
+ turnorder = newRoundSort(turnorder,flags.newRoundSort);
+ };
+ // RED: v1.204 visit every token with statuses and update them if not already done
+ // TODO
+ _.each(_.keys(state.roundMaster.effects), function(e) {
+ var token = getObj('graphic',e);
+ if (!token) {
+ return;
+ }
+ updateStatusDisplay(token,true);
+ });
+ turnorder.push(currentTurn);
+ currentTurn = turnorder[0];
+ updateTurnorderMarker(turnorder);
+
+ // RED: v1.204 set the global round state variable to the current round number
+ state.roundMaster.round = rounds;
+
+ // RED: v2.007 introduced the new initMaster API script. Send it the round counter
+ // RED: v3.019 added an InitMaster initiative management menu to support AD&D2e
+ // Standard, Group & Individual initiative types, take initiative dice rolls, and
+ // manage the player characters for which initiative is done
+ if (flags.canUseInitMaster) {
+ roundCtrCmd = '!init --isRound ' + rounds + ' --init ||rounds';
+ sendRmAPI(roundCtrCmd);
+ }
+
+ // RED: v1.202 If just advanced into the start of a new round, sort the turnorder
+ } else if (turnorder.length > 1 && !!priororder) {
+ if (isTracker(priororder[0])) {
+
+ //RED: sort the turnorder according to the configuration
+ var priorturn = turnorder.pop();
+ turnorder = newRoundSort(turnorder,flags.newRoundSort);
+ if (state.roundMaster.viewer.is_set) {
+ filterObjs( obj => {
+ if (obj.get('type') !== 'graphic' || obj.get('subtype') !== 'token' || obj.get('_pageid') !== viewerPageID) {return false;}
+ addRemovePID(obj,state.roundMaster.viewer.pid,(obj.id === turnorder[0].id),false);
+ });
+ state.roundMaster.viewer.tokenID = '';
+
+ }
+ turnorder.push(priorturn);
+ updateTurnorderMarker(turnorder);
+ currentTurn = turnorder[0];
+ }
+ }
+ // RED: v1.190 vary the behavior based on a config re clear on newRound
+ if (!newRound) {
+ if (currentTurn.id !== -1 && priororder) {
+ var curToken = getObj('graphic',currentTurn.id);
+ if (priororder[0].id !== currentTurn.id) {
+ var graphic,
+ priorToken = getObj('graphic',priororder[0].id),
+ maxsize = 0;
+ if (!curToken) {
+ sendDebug( 'handleAdvanceTurn: invalid token in turnorder' );
+ } else {
+ if (state.roundMaster.viewer.is_set) {
+ var showPC = !isPlayerControlled(curToken);
+ filterObjs( obj => {
+ if (obj.get('type') !== 'graphic' || obj.get('subtype') !== 'token' || obj.get('_pageid') !== viewerPageID) {return false;}
+ addRemovePID(obj,state.roundMaster.viewer.pid,(showPC || obj.id == curToken.id),false);
+ });
+ state.roundMaster.viewer.tokenID = curToken.id;
+ }
+
+ if (priorToken && priorToken.get('_pageid') !== curToken.get('_pageid')) {
+ graphic = findTrackerGraphic(priorToken.get('_pageid'));
+ graphic.set('layer','gmlayer');
+ }
+ graphic = findTrackerGraphic();
+
+ if (flags.rw_state === RW_StateEnum.ACTIVE)
+ {flags.rw_state = RW_StateEnum.FROZEN;}
+ maxsize = Math.max(parseInt(curToken.get('width')),parseInt(curToken.get('height')));
+ graphic.set('layer','gmlayer');
+ graphic.set('left',curToken.get('left'));
+ graphic.set('top',curToken.get('top'));
+ graphic.set('width',parseFloat(maxsize*fields.trackerImgRatio));
+ graphic.set('height',parseFloat(maxsize*fields.trackerImgRatio));
+ toFront(curToken);
+ setTimeout(function() {
+ if (graphic) {
+ if (curToken.get('layer') === 'gmlayer') {
+ graphic.set('layer','gmlayer');
+ toBack(graphic);
+ } else {
+ graphic.set('layer','map');
+ toFront(graphic);
+ }
+ if (flags.rw_state === RW_StateEnum.FROZEN)
+ {flags.rw_state = RW_StateEnum.ACTIVE;}
+ }
+ },500);
+ // Manage status
+ // Announce Turn
+ }
+ }
+ if (curToken) {
+ announceTurn(curToken,updateStatusDisplay(curToken,true),currentTurn.custom);
+ }
+ }
+ }
+ }
+
+ storeTurnorder(turnorder);
+ if (newRound) {
+ doClearTurnorder();
+ }
+ };
+
+ /**
+ * Check if a favorite status exists
+ */
+ var favoriteExists = function(statusName) {
+ statusName = statusName.toLowerCase();
+ var found = _.find(_.keys(state.roundMaster.favs), function(e) {
+ return e === statusName;
+ });
+ if (found)
+ {found = state.roundMaster.favs[found]; }
+ return found;
+ };
+
+ /**
+ * Produce a listing of favorites
+ */
+ var doApplyFavorite = function(statusName,selection) {
+ if (!statusName)
+ {return;}
+ statusName = statusName.toLowerCase();
+
+ var fav = favoriteExists(statusName),
+ markerdef,
+ curToken,
+ effectId,
+ effectList,
+ status,
+ content = '',
+ midcontent = '';
+
+ if (!fav) {
+ sendDebug('doApplyFavorite: ' + statusName + ' is not a known status');
+ sendError('"'+statusName+'" is not a known favorite status');
+ return;
+ }
+
+ var markerUsed = _.find(state.roundMaster.statuses, function(e) {
+ if (typeof(e.marker) !== 'undefined'
+ && e.marker === fav.marker
+ && e.name !== fav.name)
+ {return true;}
+ });
+
+ if (markerUsed) {
+ markerdef = libTokenMarkers.getStatus(markerUsed.marker);
+ sendError('Status "'+markerUsed.name+'" already uses marker '+markerdef.getHTML()+'. You can either change the marker for favorite "'+statusName+'" or the marker for "'+markerUsed.name+'"');
+ return;
+ }
+
+ markerdef = libTokenMarkers.getStatuses(fav.marker);
+
+ _.each(selection,function(e) {
+ curToken = getObj('graphic', e._id);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))
+ {return;}
+ effectId = e._id;
+ effectList = state.roundMaster.effects[effectId];
+
+ if ((status = _.find(effectList,function(elem) {return elem.name.toLowerCase() === fav.name.toLowerCase();}))) {
+ return;
+ } else if (effectList && Array.isArray(effectList)) {
+
+ // RED: v1.204 set the round of creation to the current round
+ effectList.push({
+ name: fav.name,
+ duration: fav.duration,
+ direction: fav.direction,
+ round: state.roundMaster.round,
+ msg: fav.msg,
+ });
+ updateGlobalStatus(fav.name,undefined,1);
+ } else {
+ // RED: v1.204 set the round of creation to the current round
+ state.roundMaster.effects[effectId] = effectList = new Array({
+ name: fav.name,
+ duration: fav.duration,
+ direction: fav.direction,
+ round: state.roundMaster.round,
+ msg: fav.msg,
+ });
+ updateGlobalStatus(fav.name,undefined,1);
+ }
+ midcontent += '';
+ });
+
+ if ('' === midcontent)
+ {midcontent = 'None ';}
+
+ content += ''
+ + ' '
+ + 'Apply Favorite'
+ + ' '
+ + 'Name: ' + ' '+fav.name+''
+ + ' Marker: ' + (!!markerdef.length ? (' '):'none')
+ + ' Duration: ' + fav.duration
+ + ' Direction: ' + fav.direction + (fav.msg ? (' Message: ' + fav.msg):'')
+ + ' Status placed on the following:' ;
+
+ content += midcontent;
+
+ status = statusExists(fav.name.toLowerCase());
+ if (status && !status.marker && fav.marker)
+ {doDirectMarkerApply(fav.marker+' %% '+fav.name); }
+ else if (status && !status.marker)
+ {content += ' '+RoundMaster_tmp.getTemplate({command: '!rounds --dispmarker '+fav.name, text: 'Choose Marker'},'button')+' ';}
+
+ updateAllTokenMarkers();
+ content += ' ';
+ sendFeedback(content);
+ };
+
+ /**
+ * Add a favorite status to the list of statuses
+ */
+ var doAddFavorite = function(args) {
+ if (!args)
+ {return;}
+
+ args = args.split(/:| %% /);
+
+ if (args.length < 3 || args.length > 5) {
+ sendDebug('doAddFavorite: Invalid syntax - wrong number of args');
+ sendError('Invalid favorite status syntax');
+ return;
+ }
+
+ var name = args[0],
+ duration = parseInt(args[1]),
+ direction = parseInt(args[2]),
+ msg = args[3],
+ marker = args[4],
+ markerdef;
+
+ if (typeof(name) === 'string')
+ {name = name.toLowerCase();}
+
+ if (isNaN(duration) || isNaN(direction)) {
+ sendDebug('doAddFavorite: Invalid syntax - duration or direction not a number');
+ sendError('Invalid favorite status syntax');
+ return;
+ }
+
+ if (marker && !libTokenMarkers.getStatuses(marker).length) {
+ marker = undefined;
+ } else {
+ markerdef = libTokenMarkers.getStatus(marker);
+ }
+
+ if (favoriteExists(name)) {
+ sendDebug('doAddFavorite: Favorite with the name "'+name+'" already exists');
+ sendError('Favorite with the name "'+name+'" already exists');
+ return;
+ }
+
+ var newFav = {
+ name: name,
+ duration: duration,
+ direction: direction,
+ msg: msg,
+ marker: marker
+ };
+
+ state.roundMaster.favs[name] = newFav;
+
+ var content = ''
+ + ' '
+ + 'Add Favorite'
+ + ' '
+ + 'Name: ' + ' '+name+''
+ + ' Marker: ' + (markerdef ? (' '):'none')
+ + ' Duration: ' + duration
+ + ' Direction: ' + direction
+ + (msg ? (' Message: ' + msg):'')
+ + (marker ? '':(' '+RoundMaster_tmp.getTemplate({command: '!rounds --dispmarker '+name+ ' %% fav', text: 'Choose Marker'},'button')+' '));
+ content += ' ';
+
+ sendFeedback(content);
+
+ };
+
+ /**
+ * Remove a favorite from the tracker
+ */
+ var doRemoveFavorite = function(statusName) {
+ if (!statusName)
+ {return;}
+ statusName = statusName.toLowerCase();
+
+ if (!favoriteExists(statusName)) {
+ sendDebug('doRemoveFavorite: Status "' + statusName + '" is not on the favorite list');
+ sendFeedback('Status "' + statusName + '" is not on the favorite list');
+ return;
+ }
+
+ var content = ''
+ + ' '
+ + 'Remove Favorite'
+ + ' '
+ + 'Favorite ' + ' '+statusName+' removed.'
+ + ' ';
+
+ delete state.roundMaster.favs[statusName];
+ sendFeedback(content);
+ };
+
+ /**
+ * RED: v1.204 Additional version of doAddStatus that takes a token_id as
+ * the first argument
+ */
+ var doAddTargetStatus = function(args,senderId) {
+ if (!args)
+ {return;}
+
+ if (args.length <4) {
+ sendDebug('doAddTargetStatus: Invalid number of args');
+ sendError('Invalid status item syntax');
+ return;
+ }
+
+ if (!args[4] || !args[4].length) {
+ args[4]=' ';
+ }
+ if (!args[5]) {
+ args[5]='';
+ }
+
+ var target = getObj('graphic', args.shift().trim());
+
+ if (!target) {
+ log('doAddTargetStatus: invalid target - args = '+args);
+ // RED v3.002 If dealing with an effect triggered by anyone
+ // deleting a token with effects on it, the token may
+ // legitimately no longer exist
+ return;
+ }
+// args = args.join('|');
+ sendDebug('doAddTargetStatus: Target is ' + target.get('name'));
+ doAddStatus(args,target,senderId);
+ return;
+ }
+
+ /**
+ * Add turn item
+ */
+ var doAddStatus = function(args,selection,senderId) {
+ if (!args)
+ {return;}
+ if (!selection) {
+ sendDebug('doAddStatus: selection undefined');
+ sendError('Invalid selection');
+ return;
+ }
+
+ if (args.length <3) {
+ sendDebug('doAddStatus: wrong number of args');
+ sendError('Invalid status item syntax');
+ return;
+ }
+ args[1] = String(args[1]);
+ args[2] = String(args[2]);
+ var mod;
+ if ('+-<>=#$'.includes(args[1][0])) {
+ mod = args[1][0];
+ if (mod !=='-' && mod !=='+') {args[1] = args[1].slice(1)};
+ }
+ var isGM = playerIsGM(senderId),
+ effect = args[0].trim(),
+ duration = parseInt(evalAttr(args[1].replace(/#/g,String(selection.length)))),
+ direction = parseInt(evalAttr(args[2].replace(/#/g,String(selection.length)))),
+ msg = (args[3] || '').trim(),
+ marker = (args[4] || '').trim().toLowerCase(),
+ saveSpec = (args[5] || ''),
+ newMarkerReason = '',
+ names = effect.split('_'),
+ gmEffectName = names[0],
+ playerEffectName = isGM ? effect : (names[1] || gmEffectName);
+
+ if (typeof(effect) === 'string')
+ {effect = effect.toLowerCase();}
+
+ if (isNaN(duration) || isNaN(direction) || !effect) {
+ sendDebug('doAddStatus: duration or direction not numbers, or name undefined');
+ sendError('Invalid status item syntax');
+ return;
+ }
+
+ if (marker === 'undefined' || !marker.length) {
+ newMarkerReason = 'Unspecified marker. ';
+ marker = false;
+ } else if (!libTokenMarkers.getStatuses(marker).length) {
+ // RED: v1.206 If the marker is not valid (misspelt or some such) just ask for one to
+ // be specified by the user...
+ newMarkerReason = 'Invalid marker '+marker+'. ';
+ marker = false;
+ }
+
+ // RED: v1.207 fixed error where a marker called the same name as a status caused an error
+ // RED: v1.301 added a flag to allow non-unique markers - i.e. two different effects can have
+ // the same marker, allowing effects of the same type to be less confusing
+ if (flags.uniqueMarkers && !!_.find(state.roundMaster.statuses, function(e) {return e.marker === marker;})) {
+ // RED: v1.207 and also failed softly by asking the user to specify a different marker instead
+ newMarkerReason = 'Marker already used. ';
+ marker = false;
+ }
+
+ var curToken,
+ effectId,
+ effectList,
+ status,
+ content = '',
+ midcontent = '';
+
+ if (saveSpec && saveSpec.length && 'undefined' !== typeof attackMaster) {
+ let names = [],
+ reSave = /[,\[\s]sv([a-z]{3}):.+[,\s\]]/g,
+ xlateSave = {att:'Attribute',par:'Paralysis',poi:'Poison',dea:'Death',rod:'Rod',sta:'Staff',wan:'Wand',pet:'Petrify',pol:'Polymorph',bre:'Breath',spe:'Spell',str:'Strength',con:'Constitution',dex:'Dexterity',int:'Intelligence',wis:'Wisdom',chr:'Charisma'};
+ saveSpec = saveSpec.replace(/#/g,String(selection.length));
+ let spec = '['+parseStr(saveSpec)+']';
+ let save = [...spec.matchAll(reSave)];
+// log('doAddStatus: spec = '+spec+', save = '+save+', map = '+save.map(s => xlateSave[s[1]]).join(' and '));
+ _.each(selection,function(e) {
+ curToken = getObj('graphic', e._id);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))
+ {return;}
+ sendRmAPI( fields.attackMaster + ' --set-savemod '+e._id+'|add|'+playerEffectName+'|'+playerEffectName+'|'+saveSpec+'|'+save.length+'||'+args.slice(0,5).join('|') );
+ names.push(curToken.get('name'));
+ });
+ if (names.length) {
+ let andName = names.length > 1 ? '** and **'+names.pop() : '';
+ content = '&{template:default}{{name=Save Required}}{{ =Ask **'+names.join(', ')+andName+'** to make a saving throw vs '+save.map(s => xlateSave[s[1]]).join(' and ')+'. If it is failed a status of *"'+playerEffectName+'"* will automatically be set}}';
+ isGM ? sendFeedback(content) : sendResponse(senderId,content);
+ };
+ return;
+ }
+
+ _.each(selection,function(e) {
+ curToken = getObj('graphic', e._id);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))
+ {return;}
+ effectId = e._id;
+
+ effectList = state.roundMaster.effects[effectId];
+
+ if (_.find(effectList,function(elem,k) {
+ if (elem.name.toLowerCase() === effect.toLowerCase()) {
+ switch (mod || ' ') {
+ case '+':
+ case '-':
+ effectList[k].duration += duration;
+ break;
+ case '<':
+ effectList[k].duration = Math.min(effectList[k].duration,duration);
+ break;
+ case '>':
+ effectList[k].duration = Math.max(effectList[k].duration,duration);
+ break;
+ case '#':
+ effectList.push({
+ name: effect,
+ duration: duration,
+ direction: direction,
+ round: state.roundMaster.round,
+ msg: msg,
+ index: effectList[k].index + 1,
+ });
+ break;
+ default:
+ effectList[k].duration = duration;
+ break;
+ }
+ if (mod !== '#') duration = effectList[k].duration;
+ effectList[k].direction = direction;
+ effectList[k].msg = msg;
+ return true;
+ }
+ })
+ ) {
+ if (!mod || (mod !== '#' && mod !== '$')) return;
+ } else if (effectList && Array.isArray(effectList)) {
+ // RED: v1.204 added the round of last update
+ effectList.push({
+ name: effect,
+ duration: duration,
+ direction: direction,
+ round: state.roundMaster.round,
+ msg: msg,
+ index: 0,
+ });
+ } else {
+ // RED: v1.204 added the round of last update
+ state.roundMaster.effects[effectId] = effectList = new Array({
+ name: effect,
+ duration: duration,
+ direction: direction,
+ round: state.roundMaster.round,
+ msg: msg,
+ index: 0,
+ });
+ }
+ updateGlobalStatus(effect,undefined,1);
+
+ // RED: v1.301 when adding a new effect marker
+ // run the relevant effect-start macro if it exists
+ // NOTE: if multiple tokens for same character sheet,
+ // This will apply the macro multiple times
+ // TODO Add list of cid to status and stop duplication
+ sendAPImacro( curToken, msg, effect, duration, '-start' );
+
+ midcontent += '';
+ });
+
+ if ('' === midcontent)
+ {midcontent = 'None ';}
+
+
+ content += ''
+ + ' '
+ + 'Add Status'
+ + ' '
+ + 'Name: ' + ' '+playerEffectName+''
+ + ' Duration: ' + duration
+ + ' Direction: ' + direction + (msg ? (' Message: ' + msg):'')
+ + ' Status placed on the following:' ;
+ content += midcontent;
+
+ status = statusExists(effect.toLowerCase());
+ if (status && !status.marker) {
+ if (marker) {
+ status.marker = marker;
+ status.tag = libTokenMarkers.getStatus(marker).getTag();
+ } else {
+ if (newMarkerReason)
+ {content += ' '+newMarkerReason+'';}
+ content += ' '+RoundMaster_tmp.getTemplate({command: '!rounds --dispmarker '+effect, text: 'Choose Marker'},'button')+' ';
+ }
+ }
+
+ content += ' ';
+ updateAllTokenMarkers();
+ isGM ? sendFeedback(content) : sendResponse(senderId,content);
+ };
+
+ /*
+ * RED: v5.050 added generalised dancing weapon capability
+ * by parsing skeleton dancing effects and pushing customised
+ * versions to the character sheet
+ */
+
+ var doTakeDancerInhand = function(args) {
+
+ var cmd = args[0],
+ tokenID = args[1],
+ weapon = args[2],
+ weapType = args[3] || 'melee',
+ plusChange = args[4] || '+1',
+ duration = args[5] || '4',
+ curToken = (tokenID ? getObj('graphic',tokenID) : undefined),
+ charCS = (curToken ? getObj('character',curToken.get('represents')) : undefined);
+
+ if (!curToken || !weapon) return;
+
+ if (!abilityLookup( fields.effectlib, weapon+'-inhand', tokenID ).action) {
+
+ _.each(dbNames,dB => {
+ _.each(dB.db,obj => {
+ if (obj.name.toLowerCase().startsWith('dancer') && /\^\^weapon\^\^/im.test(obj.body)) {
+ setAbility( charCS,
+ obj.name.replace(/dancer/i,weapon),
+ obj.body.replace(/\^\^weapon\^\^/img,weapon)
+ .replace(/\^\^weapType\^\^/img,weapType)
+ .replace(/\^\^plusChange\^\^/img,plusChange)
+ .replace(/\^\^duration\^\^/img,duration)
+ );
+ };
+ });
+ });
+ };
+
+ if (cmd.toLowerCase() === 'inhand') sendAPImacro( curToken, '', weapon, duration, '-inhand' );
+ };
+
+ /*
+ * RED: v3.010 added capability to target a token to delete one or more
+ * statuses, or all statuses, mainly so the command can be called from
+ * an effect macro (which means the selected token will not be passed
+ * with the command API call)
+ */
+
+ var doDelTargetStatus = function(args,endMacro) {
+ if (!args)
+ {return;}
+
+ args = args.split('|');
+ if (args.length < 2) {
+ sendDebug('doDelTargetStatus: Invalid number of args');
+ sendError('Invalid status item syntax');
+ return;
+ }
+
+ var target = getObj('graphic', args.shift());
+
+ if (!target) {
+ // RED v3.002 If dealing with an effect triggered by anyone
+ // deleting a token with effects on it, the token may
+ // legitimately no longer exist
+ return;
+ }
+ args = args.join('|');
+ sendDebug('doDelTargetStatus: Target is ' + target.get('name'));
+ doRemoveStatus(args,[target],endMacro,false);
+ return;
+ }
+ /**
+ * Remove a status from the selected tokens
+ */
+ var doRemoveStatus = function(args,selection,endMacro,allTokens=false) {
+ if (!args || (!selection && !allTokens)) {
+ sendError('Invalid selection');
+ return;
+ }
+ var effects,
+ allEffects = state.roundMaster.effects,
+ maxIndex,
+ found = false,
+ toRemove = [],
+ curToken,
+ effectId,
+ removedStatus,
+ content = '',
+ midcontent = '';
+
+ var rejectEffects = function( args, curToken, effects ) {
+ let maxIndex = _.chain(effects).filter(elem => args.includes(elem.name.toLowerCase().replace(/\s/g,'-'))).sortBy('duration').first().value();
+ effects = _.reject(effects,function(elem) {
+ if ((elem.index === (maxIndex ? maxIndex.index : 0) && args.includes(elem.name.toLowerCase().replace(/\s/g,'-'))) || args.includes('all')) {
+ // RED: v2.003 changed '==='' comparison of strings to 'includes()' comparison
+ // so that multiple effects can be removed at the same time
+ found = true;
+ midcontent += '';
+ if (endMacro) {
+ // RED: v1.301 when removing the status marker
+ // run the relevant effect-end macro if it exists
+ // RED: v3.010 if using the new --deletestatus command,
+ // so endMacro is false, don't trigger the -end effect
+ sendAPImacro( curToken, elem.msg, elem.name, 0, '-end' );
+ }
+ removedStatus = updateGlobalStatus(elem.name,undefined,-1);
+ toRemove.push(removedStatus);
+ return true;
+ }
+ return false;
+ });
+ setStatusEffects(curToken,effects);
+ // Remove markers
+ };
+
+ args = args.toLowerCase().replace(/\s/g,'-').split('|');
+
+ if (allTokens) {
+
+ _.each(allEffects, (effects,tokenID) => {
+ curToken = getObj('graphic', tokenID);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing')) return;
+ rejectEffects( args, curToken, effects );
+ });
+ } else if (selection && selection.length) {
+ _.each(selection, function(e) {
+ effectId = e._id || e.id;
+ curToken = getObj('graphic', effectId);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing')) return;
+ effects = state.roundMaster.effects[effectId];
+ rejectEffects( args, curToken, effects );
+ });
+ };
+
+ if ('' === midcontent) midcontent = 'None ';
+
+ content += ''
+ + ' '
+ + 'Remove Status'
+ + ' '
+ + ' Status "' +args.join(', ')+'" removed from the following:';
+ content += midcontent;
+ content += ' ';
+ if (!found && endMacro && !args.includes('all'))
+ {content = 'No status "' + args.join(', ') + '" exists on any in the selection'; }
+ updateAllTokenMarkers(toRemove);
+ if (!args.includes('silent')) sendFeedback(content);
+ };
+
+ /**
+ * Display marker list (internally used)
+ */
+ var doDisplayMarkers = function(args) {
+ if (!args)
+ {return;}
+ args = args.toLowerCase();
+ args = args.split(' %% ');
+ var statusName = args[0],
+ isfav = args[1],
+ content = '';
+
+ if (!isfav && !statusExists(statusName))
+ {return;}
+
+ content = makeMarkerDisplay(statusName,(isfav === 'fav'));
+ sendFeedback(content);
+ };
+
+ /**
+ * Display token configuration (internally used)
+ */
+ var doDisplayTokenConfig = function(args) {
+ if (!args)
+ {return;}
+
+ var curToken = getObj('graphic',args);
+ if (!curToken || curToken.get('_subtype') !== 'token') {
+ sendDebug('doDisplayTokenConfig: Invalid token selected')
+ sendError('Invalid target');
+ }
+
+ var content = makeTokenConfig(curToken);
+ sendFeedback(content);
+ };
+
+ /**
+ * Display the status configuration of a token, in the
+ * same way as a turn announcement. If run by the GM
+ * show both public and hidden statuses
+ **/
+
+ var doDisplayTokenStatus = function(args,selected,senderId,isGM) {
+ if (!args) args = [];
+
+ if (!args[0] && selected && selected.length) {
+ args[0] = selected[0]._id;
+ } else if (!args[0]) {
+ sendDebug('doDisplayTokenStatus: Invalid token selected');
+ sendError('Invalid target');
+ }
+ var curToken = getObj('graphic',args[0]);
+
+ if (!curToken) {
+ sendDebug('doDisplayTokenStatus: Invalid token selected');
+ sendError('Invalid target');
+ }
+
+ var msg = updateStatusDisplay(curToken,false);
+ if (!isGM) {
+ sendResponse(senderId,msg.public);
+ } else {
+ sendFeedback( msg.public+msg.hidden );
+ }
+ return;
+ }
+
+ /**
+ * Display status configuration (internally used)
+ */
+ var doDisplayStatusConfig = function(args) {
+ if (!args)
+ {return;}
+
+ args = args.split(/ %% /);
+ var tokenId = args[0],
+ action = args[1],
+ statusName = args[2];
+
+ // dirty fix for lack of trim()
+ if (tokenId)
+ {tokenId = tokenId.trim();}
+
+ var curToken = getObj('graphic',tokenId);
+ if ((tokenId && (!curToken || curToken.get('_subtype') !== 'token'))
+ || !action
+ || !statusName) {
+ sendDebug('doDisplayStatusConfig: invalid argument syntax. Action "' + action + '" statusName "' + statusName)
+ sendError('Invalid syntax');
+ return;
+ }
+
+ var content;
+ switch (action.toLowerCase()) {
+ case 'remove':
+ doRemoveStatus(statusName,[{_id: tokenId}],true,false);
+ break;
+ case 'change':
+ content = makeStatusConfig(curToken,statusName);
+ sendFeedback(content);
+ break;
+ case 'removefav':
+ doRemoveFavorite(statusName);
+ break;
+ case 'changefav':
+ content = makeStatusConfig('',statusName,favoriteExists(statusName));
+ sendFeedback(content);
+ break;
+ default:
+ sendError('Invalid syntax');
+ return;
+ }
+ };
+
+ /**
+ * Display favorite configuration
+ */
+ var doDisplayFavConfig = function() {
+ var content = makeFavoriteConfig();
+ sendFeedback(content);
+ };
+
+ /**
+ * Perform a single edit operation
+ */
+ var doEditTokenStatus = function(selection) {
+ var graphic;
+ if (!selection
+ || selection.length !== 1
+ || !(graphic = getObj('graphic',selection[0]._id)
+ || graphic.get('_subtype') !== 'token' )
+ || graphic.get('isdrawing')) {
+ sendDebug('doEdit TokenStatus: Invalid selection of tokens')
+ sendError('Invalid selection');
+ return;
+ }
+ var curToken = getObj('graphic',selection[0]._id);
+ var content = makeTokenConfig(curToken);
+ sendFeedback(content);
+ };
+
+ /**
+ * Display the status edit dialog for a multi edit
+ */
+ var doDisplayMultiStatusConfig = function(args) {
+ if (!args)
+ {return;}
+
+ args = args.split(' @ ');
+
+ var action = args[0],
+ statusName = args[1],
+ idString = args[2],
+ content = '';
+
+ if (action === 'remove') {
+ idString = idString.split(' %% ');
+ var selection = [];
+ _.each(idString, function(e) {
+ selection.push({_id: e, _type: 'graphic'});
+ });
+ doRemoveStatus(statusName,selection,true,false);
+ return;
+ } else if (action !== 'change') {
+ return;
+ }
+
+ content = makeMultiStatusConfig(action,statusName,idString);
+
+ sendFeedback(content);
+
+ };
+
+ /**
+ * Display the multi edit token dialog
+ */
+ var doMultiEditTokenStatus = function(selection) {
+ if (!selection)
+ {sendError('No token selected');return;}
+ if (selection.length === 1)
+ {return doEditTokenStatus(selection);}
+
+ var tuple = [],
+ subTuple,
+ curToken,
+ effects,
+ content;
+
+ _.each(selection,function(e) {
+ curToken = getObj('graphic',e._id);
+ if(curToken && curToken.get('_subtype') === 'token' && !curToken.get('isdrawing')) {
+ effects = getStatusEffects(curToken);
+ if (effects) {
+ _.each(effects,function(f) {
+ if (!(subTuple=_.find(tuple,function(g){return g.statusName === f.name;})))
+ {tuple.push({id: e._id, statusName: f.name});}
+ else
+ {subTuple.id = subTuple.id + ' %% ' + e._id;}
+ });
+ }
+ }
+ });
+ content = makeMultiTokenConfig(tuple);
+ sendFeedback(content);
+ };
+
+ /**
+ * Perform the edit operation on multiple tokens whose ids
+ * are supplied.
+ */
+ var doEditMultiStatus = function(args) {
+ if (!args)
+ {return;}
+
+ args = args.split(' @ ');
+
+ var statusName = args[0],
+ attrName = args[1],
+ newValue = args[2],
+ idString = args[3],
+ gstatus = statusExists(statusName),
+ effectList,
+ content = '',
+ midcontent,
+ errMsg;
+
+ // input sanitation
+ if (!newValue)
+ {newValue = '';}
+ if (!statusName || !attrName) {
+ sendDebug('doEditMultiStatus: Invalid arguments. statusName "' + statusName + '", attrName "' + attrName + '"');
+ sendError('Error on multi-selection');
+ return;
+ }
+
+ // dirty fix for lack of trim()
+ statusName = statusName.toLowerCase().trim();
+ idString = idString.trim();
+ idString = idString.split(' %% ');
+
+
+ if (attrName === 'name') {
+ if (statusExists(newValue)) {
+ sendError('Status name already exists');
+ sendDebug('doEditMultiStatus: status name "' + newValue + '" already exists');
+ return;
+ }
+ gstatus = statusExists(statusName);
+ newValue = newValue.toLowerCase();
+ effectList = state.roundMaster.effects;
+ _.each(effectList,function(effects) {
+ _.each(effects,function(e) {
+ if (e.name === statusName)
+ {e.name = newValue;}
+ });
+ });
+ gstatus.name = newValue;
+ midcontent = 'New status name is "' + newValue + '"';
+ } else if (attrName === 'marker') {
+ content = makeMarkerDisplay(statusName);
+ sendFeedback(content);
+ return;
+ } else {
+ idString = _.chain(_.keys(state.roundMaster.effects))
+ .reject(function(n) {
+ return !_.contains(idString,n);
+ })
+ .value();
+ _.each(idString, function(e) {
+ effectList = getStatusEffects(getObj('graphic',e));
+ _.find(effectList,function(f) {
+ if (f.name === statusName) {
+ switch (attrName.toLowerCase()) {
+ case 'duration':
+ if (!isNaN(newValue)) {
+ f.duration = parseInt(newValue);
+ if (!midcontent)
+ {midcontent = 'New duration is ' + newValue;}
+ } else if (!errMsg) {
+ errMsg = 'Invalid Value';
+ }
+ // change duration for selected statuses
+ break;
+ case 'direction':
+ if (!isNaN(newValue)) {
+ f.direction = parseInt(newValue);
+ if (!midcontent)
+ {midcontent = 'New direction is ' + newValue;}
+ } else if (!errMsg) {
+ errMsg = 'Invalid Value';
+ }
+ // change direction for selected statuses
+ break;
+ case 'message':
+ f.msg = newValue;
+ if (!midcontent)
+ {midcontent = 'New message is ' + newValue;}
+ // change message for selected statuses
+ break;
+ default:
+ sendDebug('doEditMultiStatus: Bad syntax or selection. statusName "' + statusName + '", attrName "' + attrName + '"');
+ sendError('Bad syntax/selection');
+ return;
+ }
+ }
+ });
+ });
+ if (errMsg)
+ {sendError(errMsg);}
+ else
+ {updateAllTokenMarkers();}
+ }
+
+ content += ''
+ + ' '
+ + ' Edit Group Status "'+statusName+'" |
'
+ + ' ';
+ content += midcontent;
+ content += ' ';
+
+ if (midcontent)
+ {sendFeedback(content);}
+ };
+
+ /**
+ * RED: v1.204 Additional version of doPlayerAddStatus that takes a token_id as
+ * the first argument
+ */
+ var doPlayerTargetStatus = function(args,senderId) {
+ if (!args)
+ {return;}
+
+ if (args.length <4) {
+ sendDebug('doPlayerTargetStatus: Invalid number of arguments');
+ sendError('Invalid status item syntax');
+ return;
+ }
+
+ var target = getObj('graphic', args[0]);
+ args.shift();
+
+ if (!target) {
+ sendDebug('doPlayerTargetStatus: Target token object not found');
+ sendFeedback('Could not find target');
+ return;
+ }
+
+ doPlayerAddStatus(args,target,senderId);
+ return;
+ }
+
+ /**
+ * Add player statuses
+ */
+ var doPlayerAddStatus = function(args, selection, senderId) {
+
+ if (!args)
+ {return;}
+ if (!selection) {
+ sendDebug('doPlayerAddStatus: Selection undefined');
+ sendResponseError(senderId,'Invalid selection');
+ return;
+ }
+
+ // RED: v1.204 extended arguments to optionally include the marker
+ if (args.length <3) {
+ sendDebug('doPlayerAddStatus: Invalid number of arguments');
+ sendResponseError(senderId,'Invalid status item syntax');
+ return;
+ }
+ var mod;
+
+ if ('+-<>=#$'.includes(args[1][0])) {
+ mod = args[1][0];
+ if (mod !=='-' && mod !=='+') {args[1] = args[1].slice(1)};
+ }
+ var name = args[0],
+ duration = parseInt(args[1]),
+ direction = parseInt(args[2]),
+ msg = args[3],
+ marker = args[4],
+ saveSpec = args[5] || '',
+ statusArgs = {},
+ statusArgsString = '',
+ status,
+ markerdef,
+ hashes = [],
+ curToken,
+ pr_choosemarker,
+ pr_nomarker,
+ choosemarker_args = {},
+ nomarker_args = {},
+ content = '',
+ midcontent = '',
+ d = new Date();
+
+ if (typeof(name) === 'string')
+ {name = name.toLowerCase();}
+
+ if (isNaN(duration) || isNaN(direction)) {
+ sendDebug('doPlayerAddStatus: duration or direction not a number. Duration "' + duration + '", direction "' + direction + '"');
+ sendResponseError(senderId,'Invalid status item syntax');
+ return;
+ }
+
+ if(saveSpec && 'undefined' !== typeof attackMaster) {
+ _.each(selection,function(e) {
+ curToken = getObj('graphic', e._id);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))
+ {return;}
+ sendRmAPI( fields.attackMaster + ' --set-savemod '+e._id+'|add|'+name+'|'+name+'|'+saveSpec+'|1||'+args.slice(0,5).join('|') );
+ content = '&{template:default}{{name=Save Required}}{{ =Ask '+curToken.get('name')+' to make a saving throw. If it is failed a status of *"'+name+'"* will automatically be set}}';
+ playerIsGM(senderId) ? sendFeedback(content) : sendResponse(senderId,content);
+ });
+ return;
+ }
+
+ if (!!(status=statusExists(name))) {
+ markerdef = libTokenMarkers.getStatuses(status.marker);
+ } else {
+ // RED: v1.206 fixed issue of player macros not able to set marker in command line
+ markerdef = libTokenMarkers.getStatuses(marker);
+ }
+ markerdef = !!markerdef.length ? markerdef[0] : undefined;
+
+ // RED: v1.204 added the round of last update
+ statusArgs.name = name;
+ statusArgs.duration = duration;
+ statusArgs.direction = direction;
+ statusArgs.round = state.roundMaster.round;
+ statusArgs.msg = msg;
+ // RED: v1.204 If markerdef is not defined, then use the marker parameter passed in
+ // RED: If the marker parameter is also undefined, works as previously coded
+
+ if (!!markerdef) {
+ statusArgs.marker = markerdef.getName();
+ } else {
+ statusArgs.marker = marker;
+ }
+
+ statusArgsString = name + ' @ ' + duration + ' @ ' + direction + ' @ ' + msg + ' @ ' + statusArgs.marker;
+
+ hashes.push(genHash(d.getTime()*Math.random(),pending));
+ hashes.push(genHash(d.getTime()*Math.random(),pending));
+ choosemarker_args.hlist = hashes;
+ choosemarker_args.statusArgs = statusArgs;
+ choosemarker_args.statusArgsString = statusArgsString;
+ choosemarker_args.senderId = senderId;
+ choosemarker_args.selection = selection;
+ nomarker_args.hlist = hashes;
+ nomarker_args.statusArgs = statusArgs;
+ nomarker_args.senderId = senderId;
+ nomarker_args.selection = selection;
+
+ pr_choosemarker = new PendingResponse(PR_Enum.CUSTOM,function(args) {
+ var hashes = [],
+ pr_marker,
+ content;
+
+ hashes.push(genHash(d.getTime()*Math.random(),pending));
+
+ pr_marker = new PendingResponse(PR_Enum.CUSTOM,function(args, carry) {
+ args.statusArgs.marker = carry;
+ doDispPlayerStatusAllow(args.statusArgs,args.selection,args.senderId);
+
+ },args);
+ addPending(pr_marker,hashes[0]);
+
+ content = makeMarkerDisplay(undefined,false,'!rounds --relay hc% '
+ + hashes[0]
+ + ' %% ');
+
+ sendResponse(args.senderId,content);
+ _.each(args.hlist,function(e) {
+ clearPending(e) ;
+ });
+ },choosemarker_args);
+
+ pr_nomarker = new PendingResponse(PR_Enum.CUSTOM,function(args) {
+ sendResponse('Request sent for \''+(/_(.+)_?/.exec(statusArgs.name) || ['',statusArgs.name])[1]+'\'');
+ doDispPlayerStatusAllow(args.statusArgs,args.selection,args.senderId);
+ _.each(args.hlist,function(e) {
+ clearPending(e) ;
+ });
+ },nomarker_args);
+
+ addPending(pr_choosemarker,hashes[0]);
+ addPending(pr_nomarker,hashes[1]);
+
+
+ _.each(selection,function(e) {
+ curToken = getObj('graphic', e._id);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))
+ {return;}
+ midcontent += '';
+ });
+
+ if (!playerIsGM(senderId)) name = (name.split('_'))[1];
+
+ content += ''
+ + ' '
+ + 'Request Add Status'
+ + ' '
+ + 'Name: ' + ' '+name+''
+ + ' Marker: ' + (markerdef ? markerdef.getHTML():'none')
+ + ' Duration: ' + duration
+ + ' Direction: ' + direction + (msg ? (' Message: ' + msg):'')
+ + ' Status requested to be placed on the following:';
+ content += midcontent;
+ content += (markerdef ? '': (
+ ' '
+ + RoundMaster_tmp.getTemplate({command: '!rounds --relay hc% ' + hashes[0], text: 'Choose Marker'},'button')
+ + RoundMaster_tmp.getTemplate({command: '!rounds --relay hc% ' + hashes[1], text: 'Request Without Marker'},'button')
+ + ' '
+ ));
+ content += ' ';
+ if (!playerIsGM(senderId) || !markerdef) sendResponse(senderId,content);
+
+ if (markerdef)
+ {doDispPlayerStatusAllow(statusArgs,selection,senderId);}
+ };
+
+ /**
+ * make dialog to allow/disallow a player status add
+ */
+ var doDispPlayerStatusAllow = function(statusArgs,selection,senderId) {
+
+ var hashes = [],
+ confirmArgs = {},
+ rejectArgs = {},
+ pr_confirm,
+ pr_reject,
+ content = '',
+ midcontent = '',
+ player,
+ markerdef,
+ curToken,
+ d = new Date();
+
+ player = getObj('player',senderId);
+ if (!player) {
+ sendDebug('doDispPlayerStatusAllow: Non-existant player requested to add a status?');
+ sendError('Non-existant player requested to add a status?');
+ return;
+ }
+
+ _.each(selection,function(e) {
+ curToken = getObj('graphic', e._id);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))
+ {return;}
+ midcontent += '';
+ });
+
+ hashes.push(genHash(d.getTime()*Math.random(),pending));
+ hashes.push(genHash(d.getTime()*Math.random(),pending));
+ confirmArgs.hlist = hashes;
+ confirmArgs.statusArgs = statusArgs;
+ confirmArgs.selection = selection;
+ confirmArgs.senderId = senderId;
+ rejectArgs.hlist = hashes;
+ rejectArgs.statusArgs = statusArgs;
+ rejectArgs.selection = selection;
+ rejectArgs.senderId = senderId;
+
+ pr_confirm = new PendingResponse(PR_Enum.YESNO,function(args) {
+ // RED: changed the parameter seperator from ':' to '|'
+ // RED: to allow use of !rounds calls in API Buttons
+ var addArgs = [],
+ markerdef = libTokenMarkers.getStatus(statusArgs.marker);
+ addArgs[0] = args.statusArgs.name;
+ addArgs[1] = args.statusArgs.duration;
+ addArgs[2] = args.statusArgs.direction;
+ addArgs[3] = args.statusArgs.msg;
+ addArgs[4] = args.statusArgs.marker;
+
+ // RED: v2.002 The system should now be able to deal with a marker used for multiple different effects as per v1.302
+ doAddStatus(addArgs,selection,senderId);
+
+ /*
+ if (statusExists(args.statusArgs.name)) {
+ doAddStatus(argStr,selection);
+ } else if(!!!_.find(state.roundMaster.statuses,function(e){if (e.marker === args.statusArgs.marker){return true;}})) {
+ doAddStatus(argStr,selection);
+ } else {
+ sendDebug('doDispPlayerStatusAllow: Marker "' + statusArgs.marker + '" is already in use');
+ sendError('Marker is already in use, cannot use it for \'' + args.statusArgs.name + '\' ');
+ sendResponseError(args.senderId,'Status application \''+statusArgs.name+'\' rejected, marker already in use');
+ return;
+ }
+ */
+ sendResponse(args.senderId,'Status application for \''+(/_(.+)_?/.exec(statusArgs.name) || ['',statusArgs.name])[1]+'\' accepted');
+
+ _.each(args.hlist,function(e) {
+ clearPending(e) ;
+ });
+ },confirmArgs);
+
+ pr_reject = new PendingResponse(PR_Enum.YESNO,function(args) {
+ var player = getObj('player',args.senderId);
+ if (!player) {
+ sendDebug('doDispPlayerStatusAllow: Non-existant player requested to add a status?');
+ sendError('Non-existant player requested to add a status?');
+ }
+ sendResponseError(args.senderId,'Status application for \''+(/_(.+)_?/.exec(statusArgs.name) || ['',statusArgs.name])[1]+'\' rejected');
+ sendError('Rejected status application for \''+statusArgs.name+'\' from ' + player.get('_displayname'));
+
+ _.each(args.hlist,function(e) {
+ clearPending(e) ;
+ });
+ },rejectArgs);
+
+ addPending(pr_confirm,hashes[0]);
+ addPending(pr_reject,hashes[1]);
+
+
+ markerdef = libTokenMarkers.getStatuses(statusArgs.marker);
+ markerdef = !!markerdef.length ? markerdef[0] : undefined;
+
+ content += ''
+ + ' '
+ + 'Request Add Status'
+ + ' '
+ + ' '+ player.get('_displayname') + ' requested to add the following status... '
+ + ' Name: ' + ' '+statusArgs.name+''
+ + ' Marker: ' + (markerdef ? (' '):'none')
+ + ' Duration: ' + statusArgs.duration
+ + ' Direction: ' + statusArgs.direction + (statusArgs.msg ? (' Message: ' + statusArgs.msg):'')
+ + ' Status requested to be placed on the following:';
+ content += midcontent;
+
+ content += ' '
+ + ''
+ + ''
+ + RoundMaster_tmp.getTemplate({command: '!rounds --relay hc% ' + hashes[0], text: 'Confirm'},'button')
+ + ' | '
+ + ''
+ + RoundMaster_tmp.getTemplate({command: '!rounds --relay hc% ' + hashes[1], text: 'Reject'},'button')
+ + ' | '
+ + ' '
+ + ' ';
+ // GM feedback
+ sendFeedback(content);
+ // Player feedback
+ sendResponse(senderId,' Request sent for \''+(/_(.+)_?/.exec(statusArgs.name) || ['',statusArgs.name])[1]+'\'');
+ };
+
+ /**
+ * Performs a direct marker application to a status name.
+ * An internal command that is still sanitized to prevent
+ * awful things.
+ */
+ var doDirectMarkerApply = function(args) {
+ // directly apply a marker to a token id
+ if (!args)
+ {return;}
+ args = args.split(' %% ');
+ if (!args)
+ {return;}
+
+ var markerName = args[0],
+ statusName = args[1],
+ isFav = args[2];
+
+ isFav = isFav === 'fav';
+
+ if (typeof(markerName) === 'string')
+ {markerName = markerName.toLowerCase();}
+ if (typeof(statusName) === 'string')
+ {statusName = statusName.toLowerCase();}
+
+ var status,
+ found,
+ markerdef,
+ oldMarker,
+ oldTag;
+
+ // if we're a favorite we don't bother with the status and active effects.
+ if (isFav) {
+ var fav = favoriteExists(statusName);
+ if (fav) {
+ fav.marker = markerName;
+ markerdef = libTokenMarkers.getStatus(markerName);
+ sendFeedback(' Marker for Favorite "'+statusName+'" set as ' );
+ } else {
+ sendDebug('doDirectMarkerApply: Favorite "'+statusName+'" does not exist.');
+ sendError('Favorite "'+statusName+'" does not exist.');
+ }
+ return;
+ }
+
+ _.each(state.roundMaster.statuses, function(e) {
+ if (e.marker === markerName)
+ {found = e;}
+ if (e.name === statusName)
+ {status = e;}
+ });
+ if (status) {
+ if (found) {
+ markerdef = libTokenMarkers.getStatuses(markerName);
+ if (!markerdef.length)
+ {return;}
+ sendDebug('doDirectMarkerApply: Marker "'+markerName+'" already used by "' + found.name + '"');
+ sendError('Marker already taken by "' + found.name + '"');
+ // marker taken
+ } else {
+ if (status.marker) {
+ oldMarker = status.marker;
+ oldTag = status.tag;
+ }
+ markerdef = libTokenMarkers.getStatuses(markerName);
+ status.marker = markerName;
+ if (!markerdef.length) {
+ status.tag = markerName;
+ return;
+ }
+ status.tag = markerdef[0].getTag();
+ sendFeedback(' Marker for "'+statusName+'" set as ' );
+ updateAllTokenMarkers([{name: '', marker: oldMarker, tag: oldTag}]);
+ }
+ }
+ };
+
+ /**
+ * Perform a status edit on a single token, internal command, but
+ * still performs sanitation of input to prevent awful things.
+ */
+ var doEditStatus = function(args) {
+ if (!args) {
+ sendError('Bad syntax/selection');
+ sendDebug('doEditStatus: No arguments');
+ return;
+ }
+
+ args = args.split(' %% ');
+ var action = args[0],
+ tokenId = args[1],
+ statusName = args[2],
+ attrName = args[3],
+ newValue = args[4],
+ effects,
+ effectList,
+ curToken,
+ localEffect,
+ fav,
+ content = '',
+ midcontent = '';
+
+ if (!newValue) {
+ newValue = '';
+ attrName = attrName.replace('%%','').trim();
+ }
+ if (!action
+ || !statusName
+ || !attrName) {
+ sendDebug('doEditStatus: Invalid args. action "'+action+'", statusName "'+statusName+'", attrName "'+attrName+'"');
+ sendError('Bad syntax/selection values');
+ return;
+ }
+
+ // if no token is available
+ curToken = getObj('graphic',tokenId);
+ if (tokenId
+ && curToken
+ && (curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))) {
+ sendDebug('doEditStatus: selection is not a valid token');
+ sendError('Bad syntax/selection');
+ return;
+ }
+ if (action === 'change') {
+ switch(attrName.toLowerCase()) {
+ case 'name':
+ var gstatus = statusExists(statusName);
+ if (!gstatus) {
+ sendDebug('doEditStatus: Status "'+statusName+'" does not exist');
+ sendError('Status "'+statusName+'" does not exist');
+ return;
+ }
+ if (statusExists(newValue)) {
+ sendDebug('doEditStatus: Status "'+newValue+'" already exists');
+ sendError('Status name already exists');
+ return;
+ }
+ gstatus = statusExists(statusName);
+ newValue = newValue.toLowerCase();
+ effectList = state.roundMaster.effects;
+ _.each(effectList,function(effects) {
+ _.each(effects,function(e) {
+ if (e.name === statusName) {
+ e.name = newValue;
+ }
+ });
+ });
+
+ gstatus.name = newValue;
+ midcontent += 'Status name now: ' + newValue;
+ break;
+ case 'marker':
+ content = makeMarkerDisplay(statusName);
+ sendFeedback(content);
+ return;
+ case 'duration':
+ effects = getStatusEffects(curToken);
+ localEffect = _.findWhere(effects,{name: statusName});
+ if (!localEffect || isNaN(newValue)) {
+ sendDebug('doEditStatus: Can\'t set duration for statusName "'+statusName+'" to "'+newValue+'"');
+ sendError('Bad syntax/selection');
+ return;
+ }
+ localEffect.duration = parseInt(newValue);
+ midcontent += 'New "'+statusName+'" duration ' + newValue;
+ updateAllTokenMarkers();
+ break;
+ case 'direction':
+ effects = getStatusEffects(curToken);
+ localEffect = _.findWhere(effects,{name: statusName});
+ if (!localEffect || isNaN(newValue)) {
+ sendDebug('doEditStatus: Can\'t set direction for statusName "'+statusName+'" to "'+newValue+'"');
+ sendError('Bad syntax/selection');
+ return;
+ }
+ localEffect.direction = parseInt(newValue);
+ midcontent += 'New "'+statusName+'" direction ' + newValue;
+ updateAllTokenMarkers();
+ break;
+ case 'message':
+ effects = getStatusEffects(curToken);
+ localEffect = _.findWhere(effects,{name: statusName});
+ if (!localEffect) {
+ sendDebug('doEditStatus: Can\'t set message for statusName "'+statusName+'" to "'+newValue+'"');
+ sendError('Bad syntax/selection');
+ return;
+ }
+ localEffect.msg = newValue;
+ midcontent += 'New "'+statusName+'" message ' + newValue;
+ break;
+ default:
+ sendDebug('doEditStatus: Invalid attrName "'+attrName+'"');
+ sendError('Bad syntax/selection');
+ return;
+ }
+ } else if (action === 'changefav') {
+ switch(attrName.toLowerCase()) {
+ case 'name':
+ fav = favoriteExists(statusName);
+ if (favoriteExists(newValue)) {
+ sendDebug('doEditStatus: Favorite name newValue "'+newValue+'" already exists');
+ sendError('Favorite name already exists');
+ return;
+ }
+ fav.name = newValue.toLowerCase();
+ //manually remove from state
+ delete state.roundMaster.favs[statusName];
+ state.roundMaster.favs[newValue] = fav;
+ midcontent += 'Status name now: ' + newValue;
+ break;
+ case 'marker':
+ fav = favoriteExists(statusName);
+ content = makeMarkerDisplay(statusName,fav);
+ sendFeedback(content);
+ return;
+ case 'duration':
+ fav = favoriteExists(statusName);
+ if (!fav || isNaN(newValue)) {
+ sendDebug('doEditStatus: Can\'t set duration for favorite statusName "'+statusName+'" to "'+newValue+'"');
+ sendError('Bad syntax/selection');
+ }
+ fav.duration = parseInt(newValue);
+ midcontent += 'New "'+statusName+'" duration ' + newValue;
+ break;
+ case 'direction':
+ fav = favoriteExists(statusName);
+ if (!fav || isNaN(newValue)) {
+ sendDebug('doEditStatus: Can\'t set direction for favorite statusName "'+statusName+'" to "'+newValue+'"');
+ sendError('Bad syntax/selection');
+ }
+ fav.direction = parseInt(newValue);
+ midcontent += 'New "'+statusName+'" direction ' + newValue;
+ break;
+ case 'message':
+ fav = favoriteExists(statusName);
+ if (!fav) {
+ sendDebug('doEditStatus: Can\'t set message for favorite statusName "'+statusName+'" to "'+newValue+'"');
+ sendError('Bad syntax/selection');
+ }
+ fav.msg = newValue;
+ midcontent += 'New "'+statusName+'" message ' + newValue;
+ break;
+ default:
+ sendError('Bad syntax/selection');
+ return;
+ }
+ }
+
+ content += ' '
+ + ' '
+ + ' '+(curToken ? ('Editing "'+statusName+'" for'):('Editing Favorite ' + statusName))+' | '+ (tokenId ? (' | '):'') +'
'
+ + ' ';
+ content += midcontent;
+ content += ' ';
+ sendFeedback(content);
+ return;
+ };
+
+ /**
+ * RED: v1.208 Strange circumstances can leave an orphaned marker that !rounds does
+ * not know about (perhaps set by the DM). doCleanToken() gets rid of these.
+ **/
+
+ var doCleanTokens = function(selection) {
+ if (!selection) {
+ sendDebug('doCleanToken: selection undefined');
+ sendError('Invalid selection');
+ return;
+ }
+
+ var curToken,
+ name,
+ tokenStatusMarkers;
+
+ _.each(selection,function(e) {
+ curToken = getObj('graphic', e._id);
+ if (!curToken || curToken.get('_subtype') !== 'token' || curToken.get('isdrawing'))
+ {return;}
+ name = curToken.get('name');
+ tokenStatusMarkers = curToken.get('statusmarkers');
+ sendDebug('doCleanTokens: Statusmarkers string for "' + name + '" was "' + tokenStatusMarkers + '"');
+ curToken.set('statusmarkers','');
+ });
+ updateAllTokenMarkers();
+
+ }
+
+ /**
+ * RED: v1.202 Added configuration function -clearonround [on/off], default is on
+ **/
+ var doSetClearOnRound = function(args) {
+ flags.clearonnewround = (args[0] || '').toLowerCase() != 'off';
+ sendFeedback('Turn Order will '+(flags.clearonnewround ? '' : ' not ')+'be cleared at the end of the round');
+ return;
+ }
+
+ /**
+ * RED: v1.202 Added configuration function -clearonclose [on/off], default is off
+ **/
+ var doSetClearOnClose = function(args) {
+ flags.clearonclose = (args[0] || '').toLowerCase() == 'on';
+ sendFeedback('Turn Order will '+(flags.clearonclose ? '' : ' not ')+'be cleared and stopped when it is closed');
+ return;
+ }
+
+ /**
+ * RED: v1.202 Added configuration function -sort [ascending/descending/atoz/ztoa/nosort], default is ascending
+ **/
+ var doSetSort = function(args) {
+ var sortorder;
+ switch (args[0].toLowerCase()) {
+ case 'nosort':
+ flags.newRoundSort=TO_SortEnum.NOSORT;
+ sortorder = 'not be sorted';
+ break;
+ case 'descending':
+ flags.newRoundSort=TO_SortEnum.NUMDESCEND;
+ sortorder = 'be sorted in descending order'
+ break;
+ case 'atoz':
+ flags.newRoundSort=TO_SortEnum.ALPHAASCEND;
+ sortorder = 'be sorted a to z'
+ break;
+ case 'ztoa':
+ flags.newRoundSort=TO_SortEnum.ALPHADESCEND;
+ sortorder = 'be sorted z to a'
+ break;
+ default:
+ flags.newRoundSort=TO_SortEnum.NUMASCEND;
+ sortorder = 'be sorted in ascending order'
+ }
+ sendFeedback('Turn Order will ' + sortorder + ' at the start of each round');
+ return;
+ }
+
+ /**
+ * Resets the turn order to the provided round number
+ * or in its absence, configures it to 1. Does no other
+ * operation other than change the round counter.
+ */
+ var doResetTurnorder = function(args,isTurn=true) {
+ var initial = (typeof(args) === 'string' ? (args.match(/[+-]?\d+/) || ['+1','+1'])[0] : '+1');
+ if (!initial)
+ {initial = '+1';}
+ var turnorder = Campaign().get('turnorder');
+ if (turnorder && typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+
+ if (!turnorder) {
+ prepareTurnorder();
+ } else {
+ if(!_.find(turnorder, function(e) {
+ if (parseInt(e.id) === -1 && parseInt(e.pr) === -100 && e.custom.match(/Round\s*\d+/)) {
+ if ('+-'.includes(initial[0])) {
+ initial = parseInt( e.custom.match(/\d+/) || 1 ) + parseInt(initial);
+ }
+ e.custom = 'Round ' + initial;
+ return true;
+ }
+ })) {
+ // RED: v1.204 prepareTurnorder() sets the state round number to 1
+ prepareTurnorder();
+ } else {
+ updateTurnorderMarker(turnorder);
+ // RED: v1.204 update the global state round number
+ initial = Math.abs(parseInt(initial)||1);
+ state.roundMaster.round = initial;
+ // RED: v1.190 update the round counter stored in the Initiative macro library
+ // RED: if it exists - requires the ChatSetAttr API Script to be loaded
+ var roundCtrCmd;
+ if (flags.canSetAttr && flags.canSetRoundCounter) {
+ //RED v1.207 only do this if the flags are set for ChatSetAttr and Initiative being present
+ roundCtrCmd = '!setattr --mute --name Initiative --round-counter|' + initial;
+ sendRmAPI(roundCtrCmd);
+ }
+ // RED: v2.007 introduced the new initMaster API Script. Set it's round counter
+ if (flags.canUseInitMaster) {
+ roundCtrCmd = '!init --isRound ' + initial + '|true';
+ sendRmAPI(roundCtrCmd);
+ }
+ _.each(_.keys(state.roundMaster.effects), function(e) {
+ var token = getObj('graphic',e);
+ if (!token) {
+ return;
+ }
+ updateStatusDisplay(token,isTurn);
+ });
+ }
+ }
+
+ };
+
+ /**
+ * Find an ability macro with the specified name in any
+ * macro database with the specified root name, returning
+ * the database name. If can't find a matching ability macro
+ * then return undefined objects
+ * RED: v3.025 added a preference for user-defined macros
+ * RED: v4.035 hold std Effects in data
+ * RED: v5.051 search character sheet for Effects not found
+ * in database
+ **/
+
+ var abilityLookup = function( rootDB, abilityName, tokenID ) {
+
+ abilityName = abilityName.toLowerCase().replace(reIgnore,'').trim();
+ rootDB = rootDB.toLowerCase();
+ if (!abilityName || abilityName.length==0) {
+ return {dB: rootDB, action: undefined};
+ }
+
+ var dBname,
+ magicDB, magicName,
+ action, abilityObj,
+ found = false,
+ curToken = tokenID ? getObj('graphic',tokenID) : undefined,
+ charCS = curToken ? getObj('character',curToken.get('represents')) : undefined;
+
+ filterObjs(function(obj) {
+ if (found) return false;
+ if (obj.get('type') != 'ability') return false;
+ if (obj.get('name').toLowerCase().replace(reIgnore,'') != abilityName) return false;
+ if (!(magicDB = getObj('character',obj.get('characterid')))) return false;
+ magicName = magicDB.get('name');
+ if (!magicName.toLowerCase().startsWith(rootDB) || (/\s*v\d*\.\d*/i.test(magicName))) return false;
+ if (!dbNames[magicName.replace(/-/g,'_')]) {
+ dBname = magicName;
+ found = true;
+ } else if (!dBname) dBname = magicName;
+ action = obj.get('action');
+ return true;
+ });
+ if (!action) {
+ if (_.some(dbNames,dB => !!(abilityObj = _.find(dB.db,obj => obj.name.toLowerCase().replace(reIgnore,'') == abilityName)))) {
+ action = parseStr(abilityObj.body);
+ }
+ dBname = rootDB;
+ }
+ if (!action && !_.isUndefined(charCS)) {
+ filterObjs( obj => {
+ if (found) return false;
+ if (obj.get('type') !== 'ability') return false;
+ if (obj.get('characterid') !== charCS.id) return false;
+ if (obj.get('name').toLowerCase().replace(reIgnore,'') != abilityName) return false;
+ dBname = magicName;
+ found = true;
+ action = obj.get('action');
+ return true;
+ });
+ };
+
+ return {dB: dBname.toLowerCase(), action:action};
+ }
+
+ /*
+ * Create or update an ability on a character sheet
+ */
+
+ var setAbility = function( charCS, abilityName, abilityMacro, actionBar=false ) {
+
+ var abilityObj = findObjs({type: 'ability',
+ characterid: charCS.id,
+ name: abilityName},
+ {caseInsensitive:true});
+ if (!abilityObj || abilityObj.length == 0) {
+ abilityObj = createObj( 'ability', {characterid: charCS.id,
+ name: abilityName,
+ action: abilityMacro,
+ istokenaction: actionBar});
+ } else {
+ abilityObj = abilityObj[0];
+ abilityObj.set( 'action', abilityMacro );
+ abilityObj.set( 'istokenaction', actionBar );
+ }
+ return abilityObj;
+ }
+
+ /**
+ * Get an array of controllers for the current token either
+ * from the direct token control, or linked journal control
+ */
+ var getTokenControllers = function(token) {
+ if (!token)
+ {return;}
+ var controllers;
+ if (token.get('represents')) {
+ var journal = getObj('character',token.get('represents'));
+ if (journal)
+ {controllers = journal.get('controlledby').split(',');}
+ } else {
+ controllers = token.get('controlledby').split(',');
+ }
+ return controllers;
+ };
+
+ /**
+ * determine if the sender controls the token either by
+ * linked journal, or by direct token control.
+ */
+ var isTokenController = function(token,senderId) {
+ if (!token) {
+ return false;
+ } else if (playerIsGM(senderId)) {
+ return true;
+ } else if (_.find(token.get('controlledby').split(','),function(e){return e===senderId;})) {
+ return true;
+ } else if (token.get('represents')) {
+ var journal = getObj('character',token.get('represents'));
+ if (journal && _.find(journal.get('controlledby').split(','),function(e){return e===senderId;})) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ /**
+ * Animate the tracker
+ *
+ * TODO make the rotation rate a field variable
+ */
+ var animateTracker = function() {
+ if (!flags.animating)
+ {return;}
+
+ if (flags.rw_state === RW_StateEnum.ACTIVE) {
+ if (state.roundMaster.rotation) {
+ var graphic = findTrackerGraphic();
+ graphic.set('rotation',parseInt(graphic.get('rotation'))+fields.rotation_degree);
+ }
+ setTimeout(function() {animateTracker();},500);
+ } else if (flags.rw_state === RW_StateEnum.PAUSED
+ || flags.rw_state === RW_StateEnum.FROZEN) {
+ setTimeout(function() {animateTracker();},500);
+ } else {
+ flags.animating = false;
+ }
+ };
+
+ /*
+ * Check the version of a Character Sheet database against
+ * the current version in the API. Return true if needs updating
+ */
+
+ var checkDBver = function( dbFullName, dbObj, silent ) {
+
+ dbFullName = dbFullName.replace(/_/g,'-');
+
+ var dbName = dbFullName.toLowerCase(),
+ dbCS = findObjs({ type:'character', name:dbFullName },{caseInsensitive:true}),
+ dbVersion = 0.0,
+ msg, versionObj;
+
+ if (dbCS && dbCS.length) {
+ dbCS = dbCS[0];
+ versionObj = findAttrObj( dbCS, fields.dbVersion[0] );
+ dbVersion = parseFloat(versionObj.get('current') || dbVersion);
+
+ if (dbVersion >= (parseFloat(dbObj.version) || 0)) {
+ msg = dbFullName+' v'+dbVersion+' not updated as is already latest version';
+ if (!silent) sendFeedback(msg);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /*
+ * Check the version of a Character Sheet database and, if
+ * it is earlier than the static data held in this API, update
+ * it to the latest version.
+ */
+
+ async function buildCSdb( dbFullName, dbObj, silent ) {
+
+ dbFullName = dbFullName.replace(/_/g,'-');
+
+ var dbName = dbFullName.toLowerCase(),
+ dbCS = findObjs({ type:'character', name:dbFullName },{caseInsensitive:true}),
+ dbVersion = 0.0,
+ errFlag = false,
+ foundItems = [],
+ rootDB = dbObj.root.toLowerCase(),
+ msg, versionObj, curDB;
+
+ if (!checkDBver( dbFullName, dbObj, silent )) return false;
+
+ if (dbCS && dbCS.length) {
+ let abilities = findObjs({ _type:'ability', _characterid:dbCS[0].id });
+ _.each( abilities, a => a.remove() );
+ dbCS = dbCS[0];
+ } else {
+ dbCS = createObj( 'character', {name:dbFullName} );
+ }
+
+ _.each(_.sortBy(dbObj.db,'name'),function( item ) {
+ if (!foundItems.includes(item.name)) {
+ foundItems.push(item.name);
+ item.body = parseStr(item.body,dbReplacers);
+ errFlag = errFlag || !setAbility( dbCS, item.name, item.body );
+ }
+ });
+ if (errFlag) {
+ sendError( 'Unable to completely update database '+dbName );
+ } else {
+ versionObj = findAttrObj( dbCS, fields.dbVersion[0] );
+ versionObj.set( 'current', dbObj.version );
+ dbCS.set('avatar',dbObj.avatar);
+ dbCS.set('bio',dbObj.bio);
+ dbCS.set('controlledby',dbObj.controlledby);
+ dbCS.set('gmnotes',dbObj.gmnotes);
+ msg = 'Updated database '+dbName+' to version '+String(dbObj.version);
+ if (!silent) {
+ sendFeedback( msg );
+ } else {
+ log(msg);
+ }
+ }
+ return !errFlag;
+ };
+
+ /**
+ * Ask the player/GM to place a cross-hair on the centre of an area-of-effect
+ * and then display a token aura around the cross-hair representative of the
+ * aoe parameter.
+ *
+ * !rounds --aoe crosshairID|shape|units|length|width|confirmed|image
+ */
+ var doSetAOE = function( args, selected, senderID, movable=false ) {
+
+ const colors = {
+ RED: '#FF0000',
+ YELLOW: '#FFFF00',
+ BLUE: '#0000FF',
+ GREEN: '#00FF00',
+ MAGENTA:'#FF00FF',
+ CYAN: '#00FFFF',
+ WHITE: '#FFFFFF',
+ BLACK: '#000000',
+ };
+
+ const convertFt = {
+ ft: 1,
+ m: 3,
+ km: 3280,
+ mi: 5280,
+ in: (1/12),
+ cm: (1/30),
+ un: 1,
+ hex: 1,
+ sq: 1,
+ };
+
+ if (!args) args = [];
+ if (!args[0] && selected && selected.length) {
+ args[0] = selected[0]._id;
+ };
+
+ var crossHairID = args[0],
+ shape = (args[1] || '').toUpperCase(),
+ units = (args[2] || '').toUpperCase(),
+ range = (parseInt(args[3] || -1) || -1),
+ length = (parseInt(args[4] || 0) || 0),
+ relLength = (args[4] || ' ').startsWith('+'),
+ width = (parseInt(args[5] || 0) || 0),
+ relWidth = (args[5] || ' ').startsWith('+'),
+ aoeImage = (args[6] || '').toUpperCase(),
+ confirmedDrop = args[7] && args[7].length && (args[7] === 'true' || args[7] === '1'),
+ casterID = args[8],
+ crossHair = getObj('graphic',crossHairID),
+ question = false,
+ content = '',
+ charID = '',
+ degToRad = function(degrees) {return degrees * (Math.PI / 180);},
+ pageid = crossHair ? crossHair.get('_pageid') : Campaign().get('playerpageid'),
+ pageObj = getObj('page',pageid),
+ chLeft = crossHair ? crossHair.get('left') : 70,
+ chTop = crossHair ? crossHair.get('top') : 70,
+ chWidth = crossHair ? crossHair.get('width') : 70,
+ chHeight = crossHair ? crossHair.get('height') : 70,
+ chRotation = crossHair ? crossHair.get('rotation') : 0,
+ scale = pageObj.get('scale_number'),
+ ftSize = convertFt[pageObj.get('scale_units')] || 1,
+ cellSize = pageObj.get('snapping_increment');
+
+ if (!crossHair || !crossHair.get('name').toLowerCase().replace(reIgnore,'').includes('crosshair')) {
+ if (!confirmedDrop || ['ARC180','ARC90','BOLT','CONE'].includes(shape)) {
+ chLeft += Math.sin(degToRad(chRotation))*35;
+ chTop -= Math.cos(degToRad(chRotation))*35;
+ }
+ range = ((units == 'YARDS') ? (range * 3 / ftSize) : ((units == 'FEET') ? (range / ftSize) : range ));
+ let chName = crossHair ? crossHair.get('name') : fields.crossHairName,
+ chOwnerID = crossHair ? crossHair.get('represents') : '',
+ chImg = ((shape == 'CIRCLE')?fields.chCircleImage:((shape=='SQUARE')?fields.chSquareImage:fields.chConeImage)),
+ crossHairObj = createObj('graphic', {
+ _type: 'graphic',
+ _subtype: 'token',
+ _pageid: pageid,
+ isdrawing: 1,
+ name: fields.crossHairName,
+ imgsrc: chImg,
+ layer: 'objects',
+ width: 70,
+ height: 70,
+ left: chLeft,
+ top: chTop,
+ rotation: chRotation,
+ represents: chOwnerID,
+ });
+ if (crossHair && !confirmedDrop) {
+ crossHair.set({aura2_color:colors.GREEN,aura2_radius:range});
+ }
+ toFront(crossHairObj);
+ crossHairObj.set('left',chLeft+1);
+ args[8] = crossHairID;
+ args[0] = crossHairID = crossHairObj.id;
+ crossHair = crossHairObj;
+ question = !!!confirmedDrop;
+
+ }
+ if (!shape || !['ARC180','ARC90','BOLT','CIRCLE','CONE','ELIPSE','RECTANGLE','SQUARE','WALL'].includes(shape)) {
+ // ask for shape of aoe
+ args[1] = '?{Specify area of effect shape|Arc180|Arc90|Bolt|Circle|Cone|Elipse|Rectangle|Square|Wall}';
+ question = true;
+ shape = 'ELIPSE';
+ }
+ if (!units || !['SQUARES','FEET','YARDS','UNITS'].includes(units)) {
+ // ask for units of dimensions
+ args[2] = '?{Specify units of measurement|Grid squares,squares|Feet,feet|Yards,yards}';
+ question = true;
+ }
+ if (!args[3]) {
+ // ask for range
+ args[3] = '?{Specify the range'+(units ? (' in '+units) : '')+'}';
+ question = true;
+ }
+ if (!length || length <= 0) {
+ // ask for length
+ args[4] = '?{Specify area of effect diameter/length'+(units ? (' in '+units) : '')+'}';
+ question = true;
+ }
+ if ((!width || width <= 0) && ['CONE','RECTANGLE','ELIPSE','BOLT','WALL'].includes(shape)) {
+ // ask for width
+ args[5] = '?{Specify area of effect width'+(units ? (' in '+units) : '')+'}';
+ question = true;
+ }
+ if (!aoeImage || !aoeImage.length) {
+ // If there is no defined image, ask for a colour
+ args[6] = '?{Choose an effect/colour to show|Acid|Cold|Dark|Fire|Light|Lightning|Magic|Red|Yellow|Blue|Green|Magenta|Cyan|White|Black}';
+ question = true;
+ }
+ if (!state.roundMaster.dropOnce && !confirmedDrop) {
+ // display a chat window button asking to confirm position of cross-hair
+ // Button will call --aoe with a confirmedDrop
+ args[7] = true;
+ question = true;
+ }
+ if (question) {
+ content = '&{template:'+fields.defaultTemplate+'}{{name=Confirm AOE placement}}'
+ + '{{AOE='+(range==0 ? ('Range is 0.') : ('Move the crosshair '+(range > 0 ? 'within the range depicted by the green area, then' : 'within the range, then')))
+ + ' [Confirm](!rounds '+(movable ? '--movable-aoe' : '--aoe')+' '+args.join('|')+') Area of Effect placement}}'
+ + (['ARC180','ARC90','BOLT','CONE'].includes(shape)?'{{Direction=Turn the cross hair so the arrow points in the direction of the effect}}':'')
+ + (['RECTANGLE','SQUARE'].includes(shape)?'{{Orientation=Turn the cross hair so the arrow aligns with the orientation of the effect}}':'')
+ + (['WALL'].includes(shape)?'{{Orientation=Turn the cross hair so the arrow points the way the wall is facing}}':'')
+ + '{{Location='+(['ARC180','ARC90','BOLT','CONE'].includes(shape)?'Effect will extend from the cross hair in the direction selected':'Effect will be centred on the cross hair')+'}}';
+ sendResponse( senderID, content );
+
+ } else {
+ switch (shape) {
+ case 'CIRCLE':
+ case 'SQUARE':
+ width = length;
+ break;
+ case 'ARC180':
+ width = 2*length;
+ break;
+ case 'ARC90':
+ width = Math.sqrt(2*length*length);
+ break;
+ case 'WALL':
+ chWidth = width;
+ width = length;
+ length = chWidth;
+ shape = 'RECTANGLE';
+ break;
+ }
+ if (casterID) {
+ let casterToken = getObj('graphic',casterID);
+ if (casterToken) {
+ casterToken.set('aura2_radius','');
+ charID = movable ? casterToken.get('represents') : '';
+ }
+ }
+ // Get the page the cross hair is on and
+ // discover it's units and scale. Set the
+ // aoe radius as required based on these
+ let pageObj = getObj('page',crossHair.get('_pageid')),
+ chLeft = crossHair.get('left'),
+ chTop = crossHair.get('top'),
+ scale = pageObj.get('scale_number') || 5,
+ ftSize = convertFt[pageObj.get('scale_units')] || 1,
+ cellSize = pageObj.get('snapping_increment') || 1,
+ radius = ((units == 'YARDS') ? (length * 3 / ftSize) : ((units == 'FEET') ? (length / ftSize) : length ))/((units == 'SQUARES') ? 1 : scale),
+ endWidth = (((units == 'YARDS') ? (width * 3 / ftSize) : ((units == 'FEET') ? (width / ftSize) : width ))/((units == 'SQUARES') ? 1 : scale)),
+ chImage = aoeImages[aoeImage.toUpperCase()];
+
+ if (!_.isUndefined(chImage)) {
+ chImage = chImage[shape] || '';
+ } else {
+ chImage = aoeImages.COLOR[shape] || '';
+ }
+ chHeight = (70*cellSize*radius) + (relLength ? chHeight : 0);
+ radius = chHeight;
+ endWidth = (70*cellSize*endWidth) + (relWidth ? chWidth : 0);
+ if (['ARC180','ARC90','CONE','BOLT'].includes(shape)) {
+ chLeft += Math.sin(degToRad(chRotation))*radius/2;
+ chTop -= Math.cos(degToRad(chRotation))*radius/2;
+ }
+ crossHair.set({tint_color:(colors[aoeImage.toUpperCase()] || 'transparent'),
+ left:chLeft,
+ top:chTop,
+ height:chHeight,
+ width:endWidth,
+ imgsrc:chImage,
+ represents:charID});
+ toBack(crossHair);
+
+ casterID = args[8];
+ args = args.slice(9);
+ let cmd = args.shift();
+ if (args.length) {
+ switch (cmd.toLowerCase()) {
+ case 'caster':
+ case 'multi':
+ sendRmAPI( '!rounds --target '+cmd+'|'+casterID+'|'+args.join('|') );
+ break;
+ default:
+ content = '&{template:'+fields.defaultTemplate+'}{{name=Target Area-Effect Spell}}'
+ + '{{[Select a target](!rounds --target '+cmd+'|'+casterID+'|@{target|Select A Target|token_id}|'+args.join('|')+') or just do something else}}';
+ sendResponse( senderID, content );
+ break;
+ }
+ }
+ }
+ return;
+ };
+
+ /**
+ * Start/Pause the tracker, does not annouce the starting turn
+ * as if you're moving around while paused, to reposition, you
+ * don't want it to tick down on status effects.
+ */
+ var doStartTracker = function( args ) {
+
+ if ((!args || !args[0] || args[0].toLowerCase() !== 'always') && flags.rw_state === RW_StateEnum.ACTIVE) {
+ doPauseTracker();
+ return;
+ }
+ if (flags.rw_state === RW_StateEnum.ACTIVE) return;
+
+ flags.rw_state = RW_StateEnum.ACTIVE;
+ prepareTurnorder();
+ var curToken = findCurrentTurnToken();
+ if (curToken) {
+ var graphic = findTrackerGraphic();
+ var maxsize = Math.max(parseInt(curToken.get('width')),parseInt(curToken.get('height')));
+ graphic.set('layer','gmlayer');
+ graphic.set('left',curToken.get('left'));
+ graphic.set('top',curToken.get('top'));
+ graphic.set('width',maxsize*fields.trackerImgRatio);
+ graphic.set('height',maxsize*fields.trackerImgRatio);
+ setTimeout(function() {
+ if (!!(curToken = getObj('graphic',curToken.get('_id')))) {
+ if (curToken.get('layer') === 'gmlayer') {
+ graphic.set('layer','gmlayer');
+ toBack(graphic);
+ } else {
+ graphic.set('layer','map');
+ toFront(graphic);
+ }
+ }
+ },500);
+ }
+
+ updateTurnorderMarker();
+ if (!flags.animating) {
+ flags.animating = state.roundMaster.rotation;
+ animateTracker();
+ }
+ };
+
+ /**
+ * Stops the tracker, removing all RoundMaster controlled
+ * statuses.
+ */
+ var doStopTracker = function() {
+ flags.rw_state = RW_StateEnum.STOPPED;
+ // Remove Graphic
+ var trackergraphics = findObjs({
+ _type: 'graphic',
+ name: fields.trackerName,
+ });
+ _.each(trackergraphics, function(elem) {
+ if (elem)
+ {elem.remove();}
+ });
+ // Update turnorder
+ updateTurnorderMarker();
+ // Clean markers
+ var toRemove = [];
+ _.each(state.roundMaster.statuses,function(e) {
+ toRemove.push({name: '', marker: e.marker, tag: e.tag});
+ });
+ updateAllTokenMarkers(toRemove);
+ // Clean state
+ state.roundMaster.effects = {};
+ state.roundMaster.statuses = [];
+ };
+
+ /**
+ * Pause the tracker
+ *
+ * DEPRECATED due to toggle of !rounds --start
+ */
+ var doPauseTracker = function() {
+
+ // Turn off the tracker graphic if we are pausing
+ var trackergraphics = findObjs({
+ _type: 'graphic',
+ name: fields.trackerName,
+ });
+ _.each(trackergraphics, function(elem) {
+ if (elem)
+ {elem.remove();}
+ });
+ flags.rw_state = RW_StateEnum.PAUSED;
+ updateTurnorderMarker();
+ };
+
+ /**
+ * Perform player controled turn advancement (!eot)
+ */
+ var doPlayerAdvanceTurn = function(senderId) {
+ if (!senderId || flags.rw_state !== RW_StateEnum.ACTIVE)
+ {return;}
+ var turnorder = Campaign().get('turnorder');
+ if (!turnorder)
+ {return;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+
+ var token = getObj('graphic',turnorder[0].id);
+ if ((token && isTokenController(token,senderId)) || !!state.roundMaster.debug) {
+ var priorOrder = JSON.stringify(turnorder);
+ turnorder.push(turnorder.shift());
+ turnorder = JSON.stringify(turnorder);
+ handleAdvanceTurn(turnorder,priorOrder);
+ }
+ };
+
+ /**
+ * Clear the turn order
+ */
+ var doClearTurnorder = function() {
+ /**
+ * RED: v1.190 Inserted code copied from elsewhere to save the current round
+ */
+ var turnorder = Campaign().get('turnorder');
+ if (!turnorder)
+ {return;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+ var tracker,
+ trackerpos;
+
+ if (!!(tracker = _.find(turnorder, function(e,i) {if (parseInt(e.id) === -1 && parseInt(e.pr) === -100 && e.custom.match(/Round\s*\d+/)){trackerpos = i;return true;}}))) {
+
+ var indicator,
+ graphic = findTrackerGraphic(),
+ rounds = tracker.custom.substring(tracker.custom.indexOf('Round')).match(/\d+/);
+
+ if (rounds)
+ {rounds = parseInt(rounds[0]);}
+
+ rounds = 'Round ' + rounds;
+ var trackergraphics = findObjs({
+ _type: 'graphic',
+ name: fields.trackerName,
+ });
+ _.each(trackergraphics, function(elem) {
+ if (elem)
+ {elem.remove();}
+ });
+
+ // RED: v4.034 If InitMaster is present reset all tokens in the
+ // turnorder to allow them to do Initiative again.
+
+ let cmd = fields.initMaster;
+ let redo = false;
+ _.each(turnorder, e => {
+ if (parseInt(e.id) === -1) return;
+ cmd += ' --redo '+e.id+'|silent';
+ redo = true;
+ });
+ if (redo) sendRmAPI(cmd);
+
+ /**
+ * RED: v1.190 Blank the turnorder before pushing the round counter back in
+ */
+ Campaign().set('turnorder', '');
+ /**
+ * RED: v1.190 Push the round counter back into the turn order
+ * set at the preserved round number
+ */
+ prepareTurnorder();
+ doResetTurnorder(rounds,false);
+ }
+
+
+ /**
+ * RED: v1.190 Removed call to stop tracker, so Clear just empties the tracker
+ * while preserving the round counter
+ *
+ * doStopTracker();
+ *
+ **/
+
+ };
+
+ /**
+ * RED: v1.190 New callable function to add an entry into the turnorder.
+ * RED: v1.203 Added optional ignore flag argument, and optional message argument
+ * RED: v3.012 Changed turn increments to be absolute rolled values
+ *
+ * Arguments are: name, id, priority, qualifier (optional) | message (optional) | detail (optional)
+ *
+ * - If qualifier exists and not one of first,last,smallest,largest,all or 0, then
+ * the rest of the command is ignored, otherwise if qualifier is:
+ * first: the earliest entry is kept
+ * last: the latest entry is kept
+ * smallest: the lowest priority entry is kept
+ * largest: the highest priority entry is kept
+ * all or 0: all entries are kept and another is added with priority
+ *
+ * - If priority starts with + or - then it is an increment on the existing selected entry
+ * if there is one. If not it is applied as the priority of a new entry. - can be forced
+ * as a new priority using =-
+ *
+ * - If id is a token_id or name a custom entry that already exists in the turnorder, this
+ * is updated in line with qualifier
+ *
+ * - If neither the id or the name can be found in the current turnorder,
+ * a new custom entry is created, custom if id=-1, or for tokenID = id
+ *
+ * - If message exists, an initiative message is displayed in the chat window with
+ * the form '[name]'s initiative is [final number] [message] [detail]' and the turn
+ * announcement will include the message '[name]'s turn doing [message]'
+ **/
+ var doAddToTracker = function(args,senderId) {
+
+ if (!args)
+ {return;}
+
+ if (args.length < 3 || args.length > 6) {
+ sendDebug('doAddToTracker: Invalid number of arguments');
+ sendError('Invalid tracker item syntax');
+ return;
+ }
+
+ var turnorder = Campaign().get('turnorder');
+ if (!turnorder)
+ {return;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+
+ var name = args[0],
+ tokenId = args[1],
+ increment = ('+-'.includes(args[2][0])),
+ priority = parseInt((args[2][0] == '=') ? (args[2].slice(1)) : args[2]),
+ qualifier = (args[3] || '0').toLowerCase(),
+ msg = (args[4] || ''),
+ detail = (args[5] || ''),
+ searchTerm = new RegExp(name,''),
+ keepAll = ['all','0'].includes(qualifier),
+ newEntry = {id: tokenId, pr: priority, custom: (tokenId != -1 ? msg : name)},
+ tracker = [],
+ trackerpos;
+
+ if (isNaN(priority) || !['first','last','smallest','largest','all','0'].includes(qualifier))
+ {return;}
+
+ if (keepAll && !increment) {
+ turnorder.push(newEntry);
+
+ } else {
+ turnorder = _.filter(turnorder,(e,i)=>{if (parseInt(e.id) == -1 && e.custom.match(searchTerm)) {
+ tracker.push({id: '-1', ix: i, pr: e.pr, custom: name});
+ return keepAll;
+ } else if (parseInt(tokenId) != -1 && e.id == tokenId) {
+ tracker.push({id: e.id, ix: i, pr: e.pr, custom: msg});
+ return keepAll;
+ } else {
+ return true;
+ }
+ });
+
+ if (tracker.length) {
+
+ tracker = _.sortBy(tracker,'ix');
+ switch (qualifier) {
+
+ case 'smallest':
+ case 'largest':
+ if (!increment) tracker.push(newEntry);
+ newEntry = (qualifier == 'smallest') ? (_.sortBy(tracker,'pr')[0]) : (_.chain(tracker).sortBy('pr').last().value());
+ if (increment) newEntry.pr += priority;
+ break;
+ case 'first':
+ case 'last':
+ default:
+ newEntry = (qualifier != 'first') ? (!increment ? newEntry : _.last(tracker)) : _.first(tracker);
+ if (increment) {
+ newEntry.pr += priority;
+ }
+ break;
+ }
+ }
+ turnorder.push({
+ id: newEntry.id,
+ pr: newEntry.pr,
+ custom: newEntry.custom,
+ });
+ }
+
+ if (tokenId != -1 && msg && msg.length > 0) {
+ var controllers,
+ player,
+ curToken = getObj('graphic',tokenId);
+ msg = makeInitiativeDisplay(curToken,priority,msg+detail);
+ controllers=getTokenControllers(curToken);
+ if (_.find(controllers,function(e){return (e === 'all');})) {
+ sendPublic(msg);
+ } else {
+ if (_.isUndefined(state.roundMaster.gmTrackAction[tokenId])) state.roundMaster.gmTrackAction[tokenId] = true;
+ if (state.roundMaster.gmTrackAction[tokenId]) sendFeedback(msg); // GM addToTracker message
+ _.each(controllers,function(e) {
+ player = getObj('player',e);
+ if (player && (!playerIsGM(player.id) || !state.roundMaster.gmTrackAction[tokenId]) && (!state.roundMaster.viewer.is_set || (state.roundMaster.viewer.pid != player.id))) {
+ sendResponse(player.id,msg);
+ }
+ });
+ }
+ }
+
+ prepareTurnorder(turnorder);
+ updateTurnorderMarker(turnorder);
+ turnorder.reduce((m,t)=>{
+ let o = getObj('graphic',t.id);
+ if(o){
+ t._pageid = o.get('pageid');
+ }
+ return [...m,t];
+ },[]);
+ storeTurnorder(turnorder);
+
+ };
+
+ /**
+ * RED: v1.202 resort the tracker at the GMs request, e.g. if someone does initiative
+ * after the GM has already started the round. Automatically moves Round
+ * back to the top, ready to restart the round
+ **/
+
+ var doSort = function() {
+
+ // Pause the tracker
+ // if (flags.rw_state === RW_StateEnum.ACTIVE) {
+ // doPauseTracker();
+ // }
+
+ // Find the round tracker in the turnorder
+ var turnorder = Campaign().get('turnorder');
+ if (!turnorder)
+ {return;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+ var tracker,
+ trackerpos;
+
+ if (!!(tracker = _.find(turnorder, function(e,i) {if (parseInt(e.id) === -1 && parseInt(e.pr) === -100 && e.custom.match(/Round\s*\d+/)){trackerpos = i;return true;}}))) {
+
+ // Clear the tracker graphic as will effectively be starting round again
+ var trackergraphics = findObjs({
+ _type: 'graphic',
+ name: fields.trackerName,
+ });
+ _.each(trackergraphics, function(elem) {
+ if (elem)
+ {elem.remove();}
+ });
+
+ //Remove the round tracker from the turnorder
+ turnorder.splice(trackerpos,1);
+
+ //Sort the turnorder
+ switch (flags.newRoundSort) {
+ case TO_SortEnum.NUMASCEND:
+ turnorder.sort(function(a,b) { return parseInt(a.pr) - parseInt(b.pr); }); break;
+ case TO_SortEnum.NUMDESCEND:
+ turnorder.sort(function(a,b) { return parseInt(b.pr) - parseInt(a.pr); }); break;
+ case TO_SortEnum.ALPHAASCEND:
+ turnorder.sort(function(a,b) { return compareTokenNames(a,b); }); break;
+ case TO_SortEnum.ALPHADESCEND:
+ turnorder.sort(function(a,b) { return compareTokenNames(b,a); }); break;
+ }
+
+ //Push the round tracker back on to the turnorder at the top
+ turnorder.unshift(tracker);
+
+ //Update the turnorder
+ prepareTurnorder(turnorder);
+ updateTurnorderMarker(turnorder);
+ storeTurnorder(turnorder);
+
+ }
+ //Restart the tracker
+ //doStartTracker();
+ return;
+ }
+
+ /**
+ * RED: v1.202 Created
+ * RED: v1.203 Extended with optional no_to_retain argument
+ *
+ * Remove all entries in the tracker for a specific Id or Name
+ * Arguments token_name, token_id, no_to_retain (optional, default 0)
+ *
+ **/
+ var doRemoveFromTracker = function(args,selection) {
+
+ if (!args && !selection)
+ {return;}
+
+ args = args.length ? args.split('|') : [];
+
+ if (args.length > 3) {
+ sendDebug('doRemoveFromTracker: Invalid number of arguments');
+ sendError('Invalid tracker item syntax');
+ return;
+ }
+
+ if (!args.length) {
+ let cmd = '!rounds'
+ _.each(selection,token => {
+ let tokenID = token._id,
+ curToken = getObj('graphic',tokenID),
+ name = curToken ? curToken.get('name') : '';
+ if (curToken) cmd += (' --removefromtracker '+name+'|'+tokenID);
+ });
+ sendRmAPI(cmd);
+ return;
+ };
+
+ var turnorder = Campaign().get('turnorder');
+ if (!turnorder)
+ {return;}
+ if (typeof(turnorder) === 'string')
+ {turnorder = JSON.parse(turnorder);}
+
+ var name = args[0],
+ tokenId = args[1],
+ retain = args[2],
+ tracker,
+ trackerpos = 0;
+
+ if (!retain) {
+ retain = 0;
+ } else {
+ retain = parseInt(retain);
+ }
+
+ // Pause the tracker
+ if (flags.rw_state === RW_StateEnum.ACTIVE) {
+ doPauseTracker();
+ }
+
+ // Single pass find and remove the requisite number of entries
+ while (trackerpos < turnorder.length) {
+ tracker = turnorder[trackerpos];
+ if (parseInt(tracker.id) === -1 && tracker.custom.match(name)) {
+ //Remove the found item from the turnorder if not to be retained
+ if (retain === 0) {
+ turnorder.splice(trackerpos,1);
+ } else {
+ retain--;
+ trackerpos++;
+ }
+ } else if (parseInt(tracker.id) !== -1 && tracker.id === tokenId) {
+ //Remove the found item from the turnorder if not to be retained
+ if (retain === 0) {
+ turnorder.splice(trackerpos,1);
+ } else {
+ retain--;
+ trackerpos++;
+ }
+ } else {
+ trackerpos++;
+ }
+ }
+
+ //Update the turnorder
+ prepareTurnorder(turnorder);
+ updateTurnorderMarker(turnorder);
+ storeTurnorder(turnorder);
+
+ //Restart the tracker
+ //RED: v1.207 Only restert if in a PAUSED state, not FROZEN or STOPPED
+ if (flags.rw_state === RW_StateEnum.PAUSED) {
+ doStartTracker();
+ }
+ }
+
+ /**
+ * RED:v3.002 Adding a function to push live effects away from the selected
+ * token to one other token with the same name and representing the same character,
+ * preferably on the same page, but if not then elsewhere.
+ */
+
+ var doPushStatus = function( oldID, oldName, oldRepresents ) {
+
+ var tokens = [],
+ oldToken,
+ effectList;
+
+ oldToken = getObj('graphic',oldID);
+ if (!oldToken)
+ {return};
+
+ effectList = getStatusEffects(oldToken);
+ if (!effectList || !Array.isArray(effectList))
+ {return;}
+
+ tokens[0] = _.find( findObjs({
+ _pageid: Campaign().get('playerpageid'),
+ _type: 'graphic',
+ name: oldName,
+ represents: oldRepresents
+ }), function(t) {return t.id != oldID});
+ if (!tokens[0]) {
+ tokens[0] = _.find( findObjs({
+ _type: 'graphic',
+ name: oldName,
+ represents: oldRepresents
+ }), function(t) {return t.id != oldID});
+ }
+ if (tokens[0]) {
+ doMoveStatus( tokens );
+ }
+ return;
+ };
+
+ /**
+ * RED:v2.001 Adding a function to move live effects to the selected token from
+ * all other tokens with the same token_name and represents character_ID to
+ * support a move of live effects from one map to another
+ **/
+ var doMoveStatus = function(selection) { //curToken
+ if (!selection)
+ {sendError('No tokens selected');return;}
+ var newToken, oldToken,
+ newToken_id,
+ name, char_id, page_id, charObj,
+ oldName, oldChar_id, oldPage_id, oldChar,
+ effectList, oldEffects,
+ hp, hpField, hpLink,
+ tokenStatusMarkers, oldStatusMarkers;
+
+ _.each(selection,function(e) {
+ newToken_id = e.id; // RED: v3.027 Note: had to remove underscore from ._id to fix Player Page Change - just in case this causes issue elsewhere
+ newToken = getObj('graphic', newToken_id);
+ if (!newToken || newToken.get('_subtype') !== 'token' || newToken.get('isdrawing')) {
+ return;
+ }
+
+ // RED: v5.053 don't move effects or status markers to any token
+ // that is part of a "mob" - multiple tokens representing different
+ // individual creatures with a shared character sheet
+ hpLink = '';
+ [hp,hpField] = getTokenValues(newToken,fields.Token_HP,fields.HP);
+ if (hpField && hpField.current && hpField.current.length) hpLink = newToken.get(hpField.current.substring(0,4) + '_link');
+ if (!hpLink || !hpLink.length || !getObj('attribute',hpLink)) return;
+
+ // RED: v3.004 get the page_id of the token to move stuff to
+ // as don't want to move stuff from the same page
+ page_id = newToken.get('_pageid');
+ char_id = newToken.get('represents');
+ name = newToken.get('name').toLowerCase();
+ if (char_id && (!name || name.length == 0)) {
+ charObj = getObj('character', char_id );
+ if (charObj) {
+ name = charObj.get('name').toLowerCase();
+ }
+ }
+ effectList = getStatusEffects(newToken);
+ tokenStatusMarkers = newToken.get('statusmarkers');
+
+ _.each(_.keys(state.roundMaster.effects), function(elem) {
+ if (newToken_id === elem) {
+ return;
+ }
+ oldToken = getObj('graphic',elem);
+ if (!oldToken) {
+ return;
+ }
+
+ // RED: v3.004 don't move effects or status markers from any token
+ // on the same page, regardless of if it shares name & character
+ oldPage_id = oldToken.get('_pageid');
+ if (oldPage_id == page_id) {
+ return;
+ }
+
+ oldName = oldToken.get('name');
+ oldChar_id = oldToken.get('represents');
+ if (!oldName || oldName.length == 0) {
+ oldChar = getObj('character',oldChar_id);
+ if (!!oldChar) {
+ oldName = oldChar.get('name');
+ }
+ }
+ if (name === oldName.toLowerCase() && char_id === oldChar_id) {
+ oldEffects = getStatusEffects(oldToken);
+ oldStatusMarkers = oldToken.get('statusmarkers');
+
+ if (oldEffects && Array.isArray(oldEffects)) {
+
+ if (effectList && Array.isArray(effectList)) {
+ effectList = effectList.concat(oldEffects);
+ } else {
+ effectList = oldEffects;
+ }
+ oldEffects = [];
+ setStatusEffects(oldToken,oldEffects);
+ }
+ if (tokenStatusMarkers && tokenStatusMarkers.length > 0) {
+ tokenStatusMarkers += ',' + oldStatusMarkers;
+ } else {
+ tokenStatusMarkers = oldStatusMarkers;
+ }
+ oldToken.set('statusmarkers','');
+ }
+ });
+ if (effectList) {
+ setStatusEffects(newToken,effectList);
+ }
+ if (tokenStatusMarkers) {
+ newToken.set('statusmarkers',tokenStatusMarkers);
+ }
+ });
+ updateAllTokenMarkers();
+ }
+
+ /*
+ * Grant or revoke access by a player to control of all tokens
+ * Used when targeting multiple tokens for statuses using --target Multi
+ */
+
+ var doGrantTokenAccess = function( args, senderId ) {
+ var token_id = args[0],
+ grant = (args[1] || '').toLowerCase() === 'grant',
+ curToken = getObj('graphic',token_id);
+
+ if (!curToken) return;
+ if (!playerIsGM(senderId)) undoList[senderId] = grantTokenAccess( senderId, curToken.get('_pageid'), grant, undoList[senderId] );
+ return;
+ };
+
+ /*
+ * Target a spell at a token
+ */
+
+ var doTarget = function( args, senderId, selection, checkSave=false, asGM=false ) {
+
+ if (!args) {return;}
+ if (args.length < 5) {
+ sendDebug('doTarget: invalid number of arguments');
+ sendError('Too few targeting arguments');
+ return;
+ }
+
+ var command = args[0].toUpperCase(),
+ tokenID = args[1],
+ curToken = getObj('graphic',tokenID),
+ tokenName,
+ argString,
+ content;
+
+ if (!curToken) {
+ sendDebug('doTarget: invalid tokenID parameter');
+ sendError('Invalid roundMaster parameters');
+ return;
+ }
+ if (!['CASTER','TARGET','SINGLE','AREA','ATTACK','MULTI'].includes(command.toUpperCase())) {
+ sendError('Invalid targeting command: must be CASTER, SINGLE, ATTACK, AREA or MULTI');
+ return;
+ }
+ args.shift();
+ if (args[1]==tokenID && (command === 'CASTER' || command === 'MULTI')) {
+ args.shift();
+ }
+ if (command !== 'CASTER') {
+ args.shift();
+ }
+
+ argString = args.join('|'); // @{target|token_id}|StatusName|duration|increment|message|marker|saveSpec
+
+ if (command === 'MULTI') {
+ if ((!flags.multiCaster || !selection) && (!selection || !selection.length || selection.length <= 1)) {
+ if (!playerIsGM(senderId)) undoList[senderId] = grantTokenAccess( senderId, curToken.get('_pageid'), true );
+ content = '&{template:'+fields.defaultTemplate+'}{{name=Select Multiple Tokens}}{{Select multiple tokens, then [add status changes](!rounds --'+(asGM ? 'gm-' : '')+'addStatus '+argString.replace(':','\\clon;')+' --tokenaccess '+tokenID+'|revoke)\nor just do something else}}';
+ sendResponse( senderId, content );
+/* flags.multiCaster = true;
+ } else if (selection && selection.length) {
+ grantTokenAccess( senderId, curToken.get('_pageid'), false, undoList[senderId] );
+ undoList[senderId] = undefined;
+ if ((playerIsGM(senderId) || asGM) && !checkSave) {
+ doAddStatus(args,selection,senderId);
+ } else {
+ doPlayerAddStatus(args, selection, senderId);
+ }
+ flags.multiCaster = false;
+*/ };
+ return;
+ }
+
+ if ((playerIsGM(senderId) || asGM) && !checkSave) {
+ doAddTargetStatus(args,senderId);
+ } else {
+ doPlayerTargetStatus(args,senderId);
+ }
+
+ args = argString.split('|');
+ if (command == 'AREA') {
+ tokenID = args.shift();
+ content = '&{template:'+fields.defaultTemplate+'}{{name=Target Area-Effect Spell}}'
+ + '{{[Select another target](!rounds --target'+(checkSave ? '-save' : (asGM ? '-nosave' : ''))+' '+command+'|'+tokenID+'|@{target|Select Next Target|token_id}|'+args.join('|')+') or just do something else}}';
+ sendResponse( senderId, content );
+ args.unshift(tokenID);
+ }
+
+ return;
+ }
+
+ /**
+ * Set any defined roundMaster options
+ **/
+
+ var doSetOptions = function(args) {
+ if (!args) return;
+ switch (args[0].toLowerCase()) {
+ case 'gmecho':
+ state.roundMaster.gmTrackAction[args[1]] = _.isUndefined(state.roundMaster.gmTrackAction[args[1]]) ? true : !state.roundMaster.gmTrackAction[args[1]];
+ sendFeedback( makeTokenConfig(getObj('graphic',args[1])) );
+ break;
+ default:
+ break;
+ }
+ return;
+ };
+
+ /**
+ * Set or clear a playerid as a "viewer" that sees what each token
+ * in the turn order can see at it gets to the top of the turn order.
+ */
+
+ var doSetViewer = function(args,senderId) {
+ var player = getObj('player',state.roundMaster.viewer.pid),
+ playerName = 'not set';
+
+ if (player) playerName = player.get('_displayname');
+
+ if (!args) {
+ if (senderId == state.roundMaster.viewer.pid) {
+ state.roundMaster.viewer.is_set = !state.roundMaster.viewer.is_set;
+ } else {
+ state.roundMaster.viewer.is_set = true;
+ state.roundMaster.viewer.pid = senderId;
+ }
+ sendResponse(senderId,'Viewer '+playerName+' turned '+(state.roundMaster.viewer.is_set ? 'on' : 'off'));
+ } else {
+ args = args.split('|');
+ let cmd = args[0].toLowerCase();
+ switch (cmd) {
+ case 'on':
+ case 'off':
+ state.roundMaster.viewer.is_set = (cmd == 'on');
+ if (cmd == 'off') {
+ filterObjs( obj => {
+ if (obj.get('type') !== 'graphic' || obj.get('subtype') !== 'token') {return false;}
+ addRemovePID(obj,state.roundMaster.viewer.pid,true,false);
+ return true;
+ });
+ state.roundMaster.viewer.tokenID = '';
+ } else {
+ state.roundMaster.viewer.pid = senderId;
+ }
+ sendResponse(senderId,'Viewer '+playerName+' turned '+(state.roundMaster.viewer.is_set ? 'on' : 'off'));
+ break;
+ case 'echo':
+ args[1] = args[1].toLowerCase();
+ if (['on','off','all'].includes(args[1])) {
+ state.roundMaster.viewer.echo = args[1];
+ sendResponse(senderID,'Viewer '+playerName+' echo option set to '+args[1]);
+ } else {
+ sendResponseError(senderId,'Invalid Viewer echo option');
+ }
+ break;
+ case 'all':
+ default:
+ let tokenID = args[0],
+ allView = cmd == 'all',
+ curToken = getObj('graphic',tokenID);
+ if ((curToken || allView) && state.roundMaster.viewer.is_set) {
+ filterObjs( obj => {
+ if (obj.get('type') !== 'graphic' || obj.get('subtype') !== 'token') {return false;}
+ addRemovePID(obj,state.roundMaster.viewer.pid,allView,false);
+ });
+ state.roundMaster.viewer.tokenID = '';
+ if (!allView) {
+ addRemovePID(curToken,state.roundMaster.viewer.pid,true,true);
+ state.roundMaster.viewer.priorID = state.roundMaster.viewer.tokenID;
+ state.roundMaster.viewer.tokenID = tokenID;
+ }
+ sendResponse(senderId,'View set to '+(allView ? 'all' : curToken.get('name')));
+ } else {
+ sendDebug('doSetViewer: invalid argument '+args[0]);
+ sendResponseError(senderId,'Invalid Viewer command');
+ }
+ break;
+ }
+ }
+ return;
+ }
+
+
+ /*
+ * Update effect databases to latest versions held in API
+ */
+
+ var doUpdateEffectsDB = function(args) {
+
+ var silent = (args[1] || '').toLowerCase() == 'silent',
+ dbName = args[0];
+
+ if (dbName && dbName.length) {
+ let dbLabel = dbName.replace(/-/g,'_');
+ if (!dbNames[dbLabel]) {
+ sendError('Not found database '+dbName);
+ } else {
+ log('Updating database '+dbName);
+ sendFeedback('Updating database '+dbName);
+ buildCSdb( dbName, dbNames[dbLabel], silent );
+ }
+ } else if (_.some( dbNames, (db,dbName) => checkDBver( dbName, db, silent ))) {
+ if (!silent) sendFeedback('Updating all Effect databases');
+ log('doUpdateEffectsDB: multi build');
+ _.each( dbNames, (db,dbName) => {
+ let dbCS = findObjs({ type:'character', name:dbName.replace(/_/g,'-') },{caseInsensitive:true});
+ if (dbCS && dbCS.length) {
+ let versionObj = findAttrObj( dbCS[0], fields.dbVersion[0] );
+ versionObj.set(fields.dbVersion[1], 0);
+ }
+ });
+ // Have to remove all pre-defined databases before updating them
+ // so that moves can happen without causing duplicates
+ _.each( dbNames, (db,dbName) => buildCSdb( dbName, db, silent ));
+ }
+
+ return;
+ }
+
+ /**
+ * Extract the whole status object to a character sheet
+ * to be transmogrified to another game (generally a copy)
+ * so that effects and other configuration options are
+ * also copied across
+ **/
+
+ var doExtractState = function(senderId) {
+
+ var stateMule = findObjs({type: 'character',name: 'StatusMule'},{caseInsensitive:true});
+ if (!stateMule || !stateMule.length) {
+ stateMule = createObj('character',{name: 'StatusMule'});
+ } else {
+ stateMule = stateMule[0];
+ }
+ if (!stateMule) {
+ sendError('Unable to create state mule');
+ return;
+ }
+ var stateText = JSON.stringify(state);
+ if (!stateText || !stateText.length) {
+ sendError('Unable to create the JSON string of current state');
+ return;
+ }
+ var muleObj = findObjs({type:'ability',characterid:stateMule.id,name:'State'},{caseInsensitive:true});
+ if (!muleObj || !muleObj.length) {
+ muleObj = createObj('ability',{name:'State',characterid:stateMule.id});
+ } else {
+ muleObj = muleObj[0];
+ }
+ if (!muleObj) {
+ sendError('Not able to create state mule ability');
+ return;
+ }
+ muleObj.set('action',stateText);
+ sendFeedback(doneMsgDiv + 'State extracted to StatusMule character sheet. Transmogrify to desired Campaign copy, then run *!rounds --state-load RPGM*. ');
+ return;
+ };
+
+ /**
+ * Load a JSON version of the state object from a
+ * State Mule character sheet.
+ **/
+
+ var doLoadState = function(args, senderId) {
+
+ var loadState = args[0] || 'rpgm',
+ stateMule = findObjs({type:'character',name:'StatusMule'},{caseInsensitive:true});
+ if (!stateMule || !stateMule.length) {
+ sendError('Unable to find StatusMule character sheet');
+ return;
+ } else {
+ stateMule = stateMule[0];
+ };
+ var muleObj = findObjs({type:'ability',characterid:stateMule.id,name:'State'},{caseInsensitive:true});
+ if (!muleObj || !muleObj.length) {
+ sendError('Not able to find State ability');
+ return;
+ } else {
+ muleObj = muleObj[0];
+ };
+ var stateObj = JSON.parse(muleObj.get('action'));
+ if (!stateObj) {
+ sendError('State ability is blank or incorrectly formatted');
+ return;
+ }
+ const states = 'RPGMaster|libRPGMaster|roundMaster|initMaster|moneyMaster|attackMaster|MagicMaster|CommandMaster';
+ switch (loadState.toLowerCase()) {
+ case 'all':
+ state = stateObj;
+ break;
+ case 'rpgm':
+ loadState = states;
+ default:
+ if (!loadState || !loadState.length) break;
+ let lowerState = states.toLowerCase().split('|');
+ let stateArray = states.split('|');
+ _.each( loadState.trim().toLowerCase().split('|').filter( s => !!s ), s => {
+ let ls = stateArray[lowerState.indexOf(s)];
+ if (s && s.length && stateObj[ls]) {
+ state[ls] = stateObj[ls];
+ }
+ });
+ break;
+ };
+
+ sendFeedback(doneMsgDiv + 'States for '+loadState+' loaded from the StatusMule character sheet. ');
+ return;
+ }
+
+
+ /**
+ * Update or create the help handouts
+ **/
+
+ var updateHandouts = function(silent,senderId) {
+
+ _.each(handouts,(obj,k) => {
+ let dbCS = findObjs({ type:'handout', name:obj.name },{caseInsensitive:true});
+ if (!dbCS || !dbCS[0]) {
+ log(obj.name+' not found. Creating version '+obj.version);
+ if (!silent) sendFeedback(obj.name+' not found. Creating version '+obj.version);
+ dbCS = createObj('handout',{name:obj.name,inplayerjournals:(senderId || '')});
+ dbCS.set('notes',obj.bio);
+ dbCS.set('avatar',obj.avatar);
+ } else {
+ dbCS = dbCS[0];
+ dbCS.get('notes',function(note) {
+ let reVersion = new RegExp(obj.name+'\\s*?v(\\d+?.\\d*?)', 'im');
+ let version = note.match(reVersion);
+ version = (version && version.length) ? (parseFloat(version[1]) || 0) : 0;
+ if (version >= obj.version) {
+ if (!silent) sendFeedback('Not updating handout '+obj.name+' as is already version '+obj.version);
+ return;
+ }
+ dbCS.set('notes',obj.bio);
+ dbCS.set('avatar',obj.avatar);
+ if (!silent) sendFeedback(obj.name+' handout updated to version '+obj.version);
+ log(obj.name+' handout updated to version '+obj.version);
+ });
+ }
+ });
+ return;
+ }
+
+ /*
+ * Run the effect macro specified in an external command call
+ * Used by AttackMaster for weapon effects
+ */
+
+ var runEffect = function(args) {
+
+ var tokenID = args[0],
+ msg = args[1],
+ effect = args[2],
+ macro = args[3],
+ curToken = getObj('graphic',tokenID);
+
+ if (!curToken || !effect || !macro) return;
+ sendAPImacro( curToken, msg, effect, 0, macro );
+ return;
+ }
+
+ /**
+ * Just echo the parameter string. Mostly for Effect
+ * macros to be able to whisper messages from API buttons
+ **/
+
+ var doEcho = function(argStr) {
+
+ sendChat('',argStr,null,{noarchive:!flags.archive, use3d:false});
+ return;
+ }
+
+
+ /**
+ * Handle Pending Requests
+ */
+ var doRelay = function(args,senderId) {
+ if (!args)
+ {return;}
+ var carry,
+ hash;
+ args = args.split(' %% ');
+ if (!args) { log(args); return; }
+ hash = args[0];
+ if (hash) {
+ hash = hash.match(/hc% .+/);
+ if (!hash) { log(hash); return; }
+ hash = hash[0].replace('hc% ','');
+ carry = args[1];
+ if (carry)
+ {carry = carry.trim();}
+ var pr = findPending(hash);
+ if (pr) {
+ pr.doOps(carry);
+ clearPending(hash);
+ } else {
+ sendDebug('doRelay: Selection Invalidated');
+ sendResponseError(senderId,'Selection Invalidated');
+ }
+ }
+ };
+
+ /**
+ * Handle handshake request
+ **/
+
+ var doHsQueryResponse = function(args) {
+ if (!args) return;
+ var from = args[0] || '',
+ func = args[1] || '',
+ funcTrue = ['start','stop','pause','reset','addtotracker','removefromtracker','sort','sortorder','clearonround','clearonclose','clear,','viewer','addstatus',
+ 'addtargetstatus','aoe','edit','target','clean','removestatus','deletestatus','deltargetstatus','movestatus','s_marker','disptokenconfig','listfav']
+ .includes(func.toLowerCase()),
+ cmd = '!'+from+' --hsr rounds'+((func && func.length) ? ('|'+func+'|'+funcTrue) : '');
+
+ sendRmAPI(cmd);
+ return;
+ };
+
+ /**
+ * Show help message
+ */
+ var showHelp = function() {
+ var content =
+ ''
+ + ' '
+ + 'RoundMaster v'+version+''
+ + ' '
+ + ' '
+ + ' See RoundMaster Help handout in the Journal for full information '
+ + ' !rounds --help '
+ + ' Display this message'
+ + ' !rounds --start '
+ + ' Toggle Start/Pause Tracker functionality'
+ + ' !rounds --stop '
+ + ' Stop Tracker & dump all Statuses'
+ + ' !rounds --pause '
+ + ' Pause Tracker functionality'
+ + ' !rounds --reset [number] '
+ + ' Set current Tracker round number (default is 1)'
+ + ' !rounds --sort '
+ + ' Sort Tracker in previously defined order (dafault ascending numeric)'
+ + ' !rounds --clear '
+ + ' Clear all Tracker entries'
+ + ' !rounds --clearonround [OFF/ON] '
+ + ' Alter behaviour at end of round (default on)'
+ + ' !rounds --clearonclose [OFF/ON] '
+ + ' Alter behaviour on closing the Tracker (default off)'
+ + ' !rounds --sortorder [order] '
+ + ' Set the Tracker sort order to one of NOSORT, ATOZ, ZTOA, DESCENDING, ASCENDING (default ASCENDING)'
+ + ' !rounds --addtotracker name|tokenID/-1|priority|[qualifier]|[msg]|[detail] '
+ + ' Add entry to Turn Order for tokenID or if tokenID=-1 custom entry name. Qualifier defines which entry is kept: FIRST, LAST, SMALLEST, LARGEST, ALL (default ALL)'
+ + ' !rounds --removefromtracker name|tokenID/-1|[retain] '
+ + ' Remove Turn Order entries for tokenID or if tokenID=-1 custom entry name. Optionally retain first retain entries'
+ + ' !rounds --addstatus status|duration|[-]direction|[msg]|[marker] '
+ + ' Add a status and status marker to currently selected token(s) for duration incremented by direction each round. Display optional msg each time is token\'s turn'
+ + ' !rounds --addtargetstatus tokenID|status|duration|[-]direction|[msg]|[marker] '
+ + ' Same as addstatus, but for a single specified token'
+ + ' !rounds --edit '
+ + ' Edit the statuses on the selected token(s)'
+ + ' !rounds --target CASTER|casterID|status|duration|[-]direction|[msg]|[marker] '
+ + ' Same as addtargetstatus for token casterID'
+ + ' !rounds --target SINGLE|casterID|targetID|status|duration|[-]direction|[msg]|[marker] '
+ + ' Same as addtargetstatus for token tokenID'
+ + ' !rounds --target AREA|casterID|targetID|status|duration|[-]direction|[msg]|[marker] '
+ + ' Performs addtargetstatus for token tokenID then asks Player whether to target another token'
+ + ' !rounds --aoe tokenID|[shape]|[units]|[range]|[length]|[width]|[image]|[confirmed] '
+ + ' Displays an Area of Effect, prompting for any needed parameters that are not supplied in the command'
+ + ' !rounds --clean '
+ + ' Remove token markers on selected token(s) without dropping statuses - status markers recreated at start of next round'
+ + ' !rounds --removestatus status(es)/ALL '
+ + ' Removes the named status(es) from the selected token(s), running any assossiated effects'
+ + ' !rounds --deletestatus status(es)/ALL '
+ + ' Removes the named status(es) from the selected token(s), but does not run any assossiated effects'
+ + ' !rounds --deltargetstatus tokenID|status(es) / ALL '
+ + ' Runs deletestatus for the identified token'
+ + ' !rounds --movestatus '
+ + ' Move all statuses from identical tokens in the rest of the campaign to the selected token'
+ + ' !rounds --listmarkers '
+ + ' Display markers and which are in use'
+ + ' !rounds --disptokenstatus [tokenID] '
+ + ' Display statuses for selected token(s) in Turn Announcement format'
+ + ' !rounds --listfav '
+ + ' Display statuses defined as favourites, and allow changes and applying to tokens'
+ + ' See RoundMaster Help handout for full information '
+ + ' '
+ + ' ';
+
+ sendFeedback(content);
+ };
+
+ /**
+ * Send public message
+ */
+ var sendPublic = function(msg) {
+ if (!msg)
+ {return undefined;}
+ var content = '/desc ' + msg;
+ sendChat('',content,null,{noarchive:!flags.archive, use3d:false});
+ };
+
+ /**
+ * RED: v1.301 Function to send an API command to chat
+ * that has '^^parameter^^' replaced by relevant names & ids
+ **/
+ var sendAPImacro = function(curToken,msg,effect,rounds,macro) {
+ if (!curToken || !macro || !effect) {
+ sendDebug('sendAPImacro: a parameter is null');
+ return;
+ }
+ var journal,
+ tid = curToken.id,
+ tname = curToken.get('name'),
+ cid = curToken.get('represents'),
+ words;
+
+ sendDebug( 'msg is ' + msg );
+
+ if (msg.length && msg.length > 0) {
+ words = msg.split(' ');
+ if (words.length && words.length > 1 && words[0].toLowerCase() === 'effect')
+ {effect = words[1];}
+ }
+ if (cid) {
+ journal = getObj( 'character', cid );
+ }
+ effect = /^[^_]+/.exec(effect) || effect;
+ var cname = journal ? journal.get('name') : curToken.get('name'),
+ bar1 = curToken.get('bar1_value'),
+ bar2 = curToken.get('bar2_value'),
+ bar3 = curToken.get('bar3_value'),
+ ac, acField, thac0, thac0Field, hp, hpField,
+ effectAbility = abilityLookup( fields.effectlib, effect+macro, tid ),
+ macroBody = effectAbility.action;
+ [ac,acField] = getTokenValues(curToken,fields.Token_AC,fields.AC,fields.MonsterAC);
+ [thac0,thac0Field] = getTokenValues(curToken,fields.Token_Thac0,fields.Thac0_base,fields.MonsterThac0);
+ [hp,hpField] = getTokenValues(curToken,fields.Token_HP,fields.HP);
+
+ if (!macroBody) {
+ sendDebug('Not found effectMacro ' + effect + macro);
+ return;
+ } else {
+ macroBody = macroBody.replace( /\^\^cname\^\^/gi , cname )
+ .replace( /\^\^tname\^\^/gi , tname )
+ .replace( /\^\^cid\^\^/gi , cid )
+ .replace( /\^\^tid\^\^/gi , tid )
+ .replace( /\^\^bar1_current\^\^/gi , bar1 )
+ .replace( /\^\^bar2_current\^\^/gi , bar2 )
+ .replace( /\^\^bar3_current\^\^/gi , bar3 )
+ .replace( /\^\^ac\^\^/gi , ac.current )
+ .replace( /\^\^thac0\^\^/gi , thac0.current )
+ .replace( /\^\^hp\^\^/gi , hp.current )
+ .replace( /\^\^ac_max\^\^/gi , ac.max )
+ .replace( /\^\^thac0_max\^\^/gi , thac0.max )
+ .replace( /\^\^hp_max\^\^/gi , hp.max )
+ .replace( /\^\^token_ac\^\^/gi , acField.current )
+ .replace( /\^\^token_thac0\^\^/gi , thac0Field.current )
+ .replace( /\^\^token_hp\^\^/gi , hpField.current )
+ .replace( /\^\^token_ac_max\^\^/gi , acField.max )
+ .replace( /\^\^token_thac0_max\^\^/gi , thac0Field.max )
+ .replace( /\^\^token_hp_max\^\^/gi , hpField.max )
+ .replace( /\^\^duration\^\^/gi , rounds );
+ setTimeout(() => sendChat('',macroBody,null,{noarchive:!flags.archive, use3d:false}),50);
+
+ }
+ }
+
+ /**
+ * Send API command to chat
+ */
+ var sendRmAPI = function(msg) {
+ if (!msg) {
+ sendDebug('sendRmAPI: no msg');
+ return undefined;
+ }
+ sendDebug('sendRmAPI: msg is ' + msg );
+ sendChat('',msg,null,{noarchive:!flags.archive, use3d:false});
+ };
+
+ /**
+ * Fake message is fake!
+ */
+ var sendFeedback = function(msg) {
+
+ var content = '/w GM '
+ + ''
+ + ' '
+ + ' '
+ + msg;
+
+ sendChat(fields.feedbackName,content,null,{noarchive:!flags.archive, use3d:false});
+ };
+
+ /**
+ * Sends a response to the player, or to the GM if the playerid
+ * is invalid.
+ */
+ var sendResponse = function(pid,msg,as,img) {
+ if (!pid || !msg)
+ {return null;}
+ var player = getObj('player',pid),
+ to;
+ if (player) {
+ to = '/w "' + player.get('_displayname') + '" ';
+ } else {
+ // RED: v3.003 softened the treatment of invalid playerIDs
+ // RED: as a bug in the Transmogrifier seems to create these
+ // throw('could not find player: ' + to);
+ sendDebug('sendResponse: invalid pid passed');
+ sendError('Could not find player: ' + pid);
+ to = '/w gm ';
+ }
+ var content = to
+ + ''
+ + ' '
+ + ' '
+ + msg;
+ // RED: v1.203 corrected, as the call to sendChat() seemed to have wrong number
+ // RED: of parameters and not to work
+ sendChat((as ? as:fields.feedbackName),content,null,{noarchive:!flags.archive, use3d:false});
+ };
+
+ var sendResponseError = function(pid,msg,as,img) {
+ sendResponse(pid,''+msg+'',as,img);
+ };
+
+ /**
+ * Send an error
+ */
+ var sendError = function(msg) {
+ sendFeedback(''+msg+'');
+ };
+
+ /**
+ * Send an error caught by try/catch
+ */
+
+ sendCatchError = function(apiName,msg,e,cmdStr='') {
+ var postCatchMsg = function(apiName,msg,e,cmdStr) {
+ if (!msg || !msg.content) {msg= {};msg.content = ''};
+ if (!cmdStr) cmdStr = msg.content;
+ log(apiName + ' error: ' + e.name + ', ' + e.message + ' when processing command ' + cmdStr);
+ let who=(getObj('player',msg.playerid)||{get:()=>'API'}).get('_displayname');
+ sendChat(apiName,`/w gm `+
+ ``+
+ ` There was an error while trying to run ${who}'s command: `+
+ ` ${cmdStr}
`+
+ ` Please send me this information so I can make sure this doesn't happen again (triple click for easy select in most browsers.): `+
+ ` `+
+ JSON.stringify({msg:msg, version:version, stack: e.stack, API_Meta})+
+ ` `+
+ ` `
+ )
+ };
+ setTimeout(postCatchMsg,500,apiName,msg,e,cmdStr);
+ };
+ /**
+ * RED: v1.207 Send a debugging message if the debugging flag is set
+ */
+ var sendDebug = function(msg) {
+ if (!!state.roundMaster.debug) {
+ var player = getObj('player',state.roundMaster.debug),
+ to;
+ if (player) {
+ to = '/w "' + player.get('_displayname') + '" ';
+ } else
+ {throw ('sendDebug could not find player');}
+ if (!msg)
+ {msg = 'No debug msg';}
+ sendChat('RM Debug',to + ''+msg+'',null,{noarchive:!flags.archive, use3d:false});
+ };
+ };
+
+ var doSetDebug = function(args,senderId) {
+ var player = getObj('player',senderId),
+ playerName;
+ if (player) {
+ playerName = player.get('_displayname');
+ }
+ else
+ {throw ('doSetDebug could not find player: ' + args);}
+ if (!!args && args.toLowerCase() != 'off') {
+ state.roundMaster.debug = senderId;
+ sendResponse(senderId,'Debug set to ' + playerName);
+ sendDebug('Debugging turned on');
+ } else {
+ sendResponse(senderId,'Debugging turned off');
+ state.roundMaster.debug = false;
+ }
+ };
+
+ /**
+ * Handle chat message event
+ * RED: v1.213 Updated to allow multiple actions per call
+ * This allows procedural/linear processing of activity and overcomes
+ * some of the limitations of Roll20 asynchronous processing
+ */
+ var handleChatMessage = function(msg) {
+ var args = processInlinerolls(msg),
+ senderId = msg.playerid,
+ selected = msg.selected,
+ isGM = (playerIsGM(senderId) || state.roundMaster.debug === senderId),
+ t = 2;
+
+ var checkPlayersLive = function( charCS ) {
+ let playerID, controlledBy = (!charCS ? '' : charCS.get('controlledby'));
+ if (controlledBy.length > 0) {
+ controlledBy = controlledBy.split(',');
+ let viewerID = (state.roundMaster && state.roundMaster.viewer && state.roundMaster.viewer.is_set) ? (state.roundMaster.viewer.pid || null) : null;
+ let players = controlledBy.filter(id => id != viewerID);
+ if (players.length) {
+ playerID = _.find( controlledBy, function(playerID) {
+ players = findObjs({_type: 'player', _id: playerID, _online: true});
+ return (players && players.length > 0);
+ });
+ };
+ };
+ return playerID;
+ };
+
+ var fixSenderId = function( args, selected, senderId ) {
+
+ let playerID = args[0] || (selected && selected.length ? selected[0]._id : senderId),
+ playerObj = getObj('player',playerID);
+ if (!playerObj) playerID = checkPlayersLive( getObj('character',args[0]) );
+ if (!playerID) {
+ let tokenObj = getObj('graphic',args[0]);
+ if (tokenObj) playerID = checkPlayersLive( getObj('character',tokenObj.get('represents')) );
+ }
+ return playerID || senderId;
+ };
+
+ var execCmd = function( e, selected, senderId, isGM ) {
+ var arg = e, i=arg.indexOf(' '), cmd, argString;
+ sendDebug('Processing arg: '+arg);
+
+ cmd = (i<0 ? arg : arg.substring(0,i)).trim().toLowerCase();
+ argString = (i<0 ? '' : arg.substring(i+1).trim());
+ arg = argString.split('|');
+
+ try {
+ switch (cmd) {
+ case 'addfav':
+ if (isGM) doAddFavorite(argString);
+ break;
+ case 'gm-addstatus':
+ doAddStatus(arg,selected,senderId);
+ break;
+ case 'addstatus':
+ if (isGM) doAddStatus(arg,selected,senderId)
+ else doPlayerAddStatus(arg,selected,senderId);
+ break;
+ case 'addtargetstatus':
+ // RED: v1.204 Added --addtargetstatus so that spells can be
+ // cast by players on other player's tokens and on monsters
+ if (isGM) doAddTargetStatus(arg,senderId);
+ // RED: v1.204 Added --addtargetstatus so that spells can be
+ // cast by players on other player's tokens and on monsters
+ // If player calls, DM is given option to refuse.
+ else doPlayerTargetStatus(arg, senderId);
+ break;
+ case 'addtotracker':
+ // RED: v1.190 allow players access to addToTracker to allow adding
+ // RED: multiple entries for 3/2, 2, ... attacks per round etc
+ // RED: v1.201 Added the ability to add additional lines
+ // into the turn tracker
+ doAddToTracker(arg,senderId);
+ break;
+ case 'aoe':
+ // RED: v3.018 add function to display the area of effect of
+ // a spell or other action by dropping a cross-hair or arrow
+ // token on the map at the origin/centre
+ doSetAOE(arg,selected,senderId);
+ break;
+ case 'applyfav':
+ if (isGM) doApplyFavorite(argString,selected);
+ break;
+ case 'clean':
+ // RED: v1.208 unknown conditions may be corrupting the token 'statusmarkers'
+ // string, leaving stranded markers. This should clean them.
+ if (isGM) doCleanTokens(selected);
+ break;
+ case 'clear':
+ // RED: v1.202 moved -clear to down the bottom so parameter set
+ // commands would be found first
+ if (isGM) doClearTurnorder();
+ break;
+ case 'clearonclose':
+ // RED: v1.201 added ability to set flags via commands
+ if (isGM) doSetClearOnClose(arg);
+ break;
+ case 'clearonround':
+ // RED: v1.201 added ability to set flags via commands
+ if (isGM) doSetClearOnRound(arg);
+ break;
+ case 'dancer':
+ // RED: v5.050 create effects for a generalised dancing weapon
+ if (isGM) doTakeDancerInhand(arg);
+ break;
+ case 'debug':
+ // RED: v1.207 allow anyone to set debug and who to send debug messages to
+ doSetDebug(argString,senderId);
+ break;
+ case 'deletestatus':
+ doRemoveStatus(argString,selected,false,false);
+ break;
+ case 'delglobalstatus':
+ doRemoveStatus(argString,selected,false,true);
+ break;
+ case 'deltargetstatus':
+ doDelTargetStatus(argString,false);
+ break;
+ case 'dispmarker':
+ if (isGM) doDisplayMarkers(argString);
+ break;
+ case 'dispmultistatusconfig':
+ if (isGM) doDisplayMultiStatusConfig(argString);
+ break;
+ case 'dispstatusconfig':
+ if (isGM) doDisplayStatusConfig(argString);
+ break;
+ case 'disptokenconfig':
+ if (isGM) doDisplayTokenConfig(argString);
+ break;
+ case 'disptokenstatus':
+ doDisplayTokenStatus(arg,selected,senderId,isGM);
+ break;
+ case 'echo':
+ doEcho(argString);
+ break;
+ case 'edit':
+ if (isGM) doMultiEditTokenStatus(selected);
+ break;
+ case 'edit_multi_status':
+ if (isGM) doEditMultiStatus(argString);
+ break;
+ case 'edit_status':
+ if (isGM) doEditStatus(argString);
+ break;
+ case 'effect':
+ runEffect(arg);
+ break;
+ case 'gm-target':
+ case 'target-nosave':
+ doTarget(arg,senderId,selected,false,state.roundMaster.nosave);
+ break;
+ case 'help':
+ if (isGM) showHelp();
+ break;
+ case 'hsq':
+ case 'handshake':
+ doHsQueryResponse(arg);
+ break;
+ case 'listfav':
+ if (isGM) doDisplayFavConfig();
+ break;
+ case 'marker':
+ if (isGM) doDirectMarkerApply(argString);
+ break;
+ case 'movable-aoe':
+ // RED: v3.026 add function to display a movable area of effect of
+ // a spell or other action by dropping a cross-hair or arrow
+ // token on the map at the origin/centre
+ doSetAOE(arg,selected,senderId,true);
+ break;
+ case 'movestatus':
+ if (isGM) doMoveStatus(selected);
+ break;
+ case 'nosave':
+ if (isGM) {
+ if (argString.length) {
+ state.roundMaster.nosave = argString.toLowerCase().includes('on');
+ } else {
+ state.roundMaster.nosave = !state.roundMaster.nosave;
+ }
+ sendFeedback( '&{template:default}{{name=Configuration}}{{Magical effects not requiring saving throws will '+(state.roundMaster.nosave ? 'not' : 'still')+' require GM confirmation}}' );
+ }
+ break;
+ case 'options':
+ if (isGM) doSetOptions(arg);
+ break;
+ case 'pause':
+ if (isGM) doPauseTracker();
+ break;
+ case 'relay':
+ doRelay(argString,senderId);
+ break;
+ case 'removefromtracker':
+ // RED: v1.202 Added the removeFromTracker function to allow the DM
+ // RED: to clean up the turn order if needed
+ // RED: v1.203 allow players access to removeFromTracker to
+ // assist clean initiative selection
+ doRemoveFromTracker(argString,selected);
+ break;
+ case 'removeglobalstatus':
+ // RED: v5.053 Added ability to remove a list of statuses from
+ // all tokens in a campaign
+ doRemoveStatus(argString,selected,true,true);
+ break;
+ case 'removestatus':
+ // RED: v1.210 allow players to remove statuses e.g. when
+ // spell durations end (mostly via macros)
+ doRemoveStatus(argString,selected,true,false);
+ break;
+ case 'removetargetstatus':
+ doDelTargetStatus(argString,true);
+ break;
+ case 'reset':
+ if (isGM) doResetTurnorder(argString);
+ break;
+ case 'rotatetracker':
+ if (isGM) {
+ if (argString.length) {
+ state.roundMaster.rotation = argString.toLowerCase().includes('on');
+ } else {
+ state.roundMaster.rotation = !state.roundMaster.rotation;
+ }
+ flags.animating = state.roundMaster.rotation;
+ animateTracker();
+ }
+ break;
+ case 'listmarkers':
+ case 's_marker':
+ if (isGM) doShowMarkers();
+ break;
+ case 'sort':
+ // RED: v1.202 Added the ability to re-sort the turnorder after
+ // the start of the round, & reset the round to start
+ if (isGM) doSort();
+ break;
+ case 'sortorder':
+ // RED: v1.201 added ability to set flags via commands
+ if (isGM) doSetSort(arg);
+ break;
+ case 'start':
+ if (isGM) doStartTracker(arg);
+ break;
+ case 'stop':
+ if (isGM) doStopTracker();
+ break;
+ case 'state-extract':
+ if (isGM) doExtractState(senderId);
+ break;
+ case 'state-load':
+ if (isGM) doLoadState(arg,senderId);
+ break;
+ case 'target':
+ doTarget(arg,senderId,selected);
+ break;
+ case 'target-save':
+ doTarget(arg,senderId,selected,true);
+ break;
+ case 'tokenaccess':
+ doGrantTokenAccess( arg, senderId );
+ break;
+ case 'update-db':
+ case 'extract-db':
+ if (isGM) doUpdateEffectsDB(arg);
+ break;
+ case 'handout':
+ case 'handouts':
+ if (isGM) updateHandouts(false,senderId);
+ break;
+ case 'viewer':
+ // RED: v3.011 allow a player to be set as a "viewer" that will see what the
+ // token at the top of the turn order sees
+ doSetViewer(argString,senderId);
+ break;
+ case 'remdm':
+ //temp cmd
+ filterObjs( obj => {
+ if (obj.get('_type') !== 'character') return false;
+ obj.set('controlledby',obj.get('controlledby').split(',').filter(pid => pid !== senderId).join(','));
+ });
+ break;
+ case 'setsight':
+ //temp cmd
+ let obj = getObj('graphic',arg[0]);
+ if (obj) {
+ log('setsight: setting '+obj.get('name')+' light_hassight to '+(arg[1].toLowerCase() === 'true'))
+ obj.set('has_bright_light_vision',(arg[1].toLowerCase() === 'true'));
+ } else {
+ log('setsight: invalid object id');
+ }
+ break;
+ default:
+ sendFeedback('Invalid command " '+msg.content+' "');
+ showHelp();
+ break;
+ }
+ } catch (err) {
+ log('RoundMaster handleChatMsg: JavaScript '+err.name+': '+err.message+' while processing command '+cmd+' '+argString);
+ sendDebug('RoundMaster handleChatMsg: JavaScript '+err.name+': '+err.message+' while processing command '+cmd+' '+argString);
+ sendCatchError('RoundMaster',msg_orig[senderId],err);
+ }
+ return;
+ }
+
+ msg_orig[senderId] = msg;
+
+ // Make sure libTokenMarkers exists, and has the functions that are expected
+ if('undefined' === typeof libTokenMarkers
+ || (['getStatus','getStatuses','getOrderedList'].find(k=>
+ !libTokenMarkers.hasOwnProperty(k) || 'function' !== typeof libTokenMarkers[k]
+ ))
+ ) {
+ if (flags.notifyLibErr) {
+ flags.notifyLibErr = !flags.notifyLibErr;
+ setTimeout( () => flags.notifyLibErr = !flags.notifyLibErr, 10000 );
+ // notify of the missing library
+ sendChat('',`/w gm Missing dependency: libTokenMarkers `);
+ }
+ return;
+ };
+ if (msg.type === 'api' && args.indexOf('!eot') === 0) {
+ doPlayerAdvanceTurn(senderId);
+ return;
+ }
+ if (msg.type === 'api' && !_.isUndefined(undoList[senderId])) {
+ undoList[senderId] = grantTokenAccess( senderId, null, false, undoList[senderId] );
+ }
+
+ if (msg.type !=='api' || (args.indexOf('!rounds') !== 0 && args.indexOf('!tj') !== 0))
+ {return;}
+
+ sendDebug('roundMaster called with '+args);
+
+ senderId = msg.playerid;
+ args = args.split(' --');
+ let senderMod = args.shift().split(' ');
+ if (senderMod.length > 1) senderId = fixSenderId( [senderMod[1]], selected, senderId );
+
+ if (_.isUndefined(senderId) || _.isUndefined(getObj('player',senderId))) {
+ sendDebug('senderId undefined, looking for GM');
+ if (_.isUndefined(senderId = findTheGM())) {
+ sendDebug('Unable to findTheGM');
+ return;
+ } else {
+ sendDebug('found the GM');
+ isGM = true;
+ }
+ } else {
+ sendDebug('senderId is defined as ' + getObj('player',senderId).get('_displayname') + ' who is '+(playerIsGM(senderId)? '' : 'not')+' GM');
+ };
+
+ _.each(args, function(e) {
+ setTimeout( execCmd, (1*t++), e, selected, senderId, isGM );
+ });
+ };
+
+ /**
+ * Handle a token being added to the tabletop: run an token_name'-add' effect macro
+ */
+
+ var handleAddGraphic = function(obj) {
+
+ try {
+// log('rounds handleAddGraphic: called');
+ if (obj.get('type') != 'graphic' || obj.get('subtype') != 'token') {return;}
+
+ sendAPImacro( obj, '', obj.get('name'), 0, '-add' );
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleAddGraphic()');
+ }
+ };
+
+ /**
+ * Handle turn order change event
+ */
+ var handleChangeCampaignTurnorder = function(obj,prev) {
+ try {
+// log('rounds handleChangeCampaignTurnorder: called');
+ sendRmAPI('!init --clearmarkers');
+ handleAdvanceTurn(obj.get('turnorder'),prev.turnorder);
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleChangeCampaignTurnorder()');
+ }
+ };
+
+ var handleChangeCampaignInitativepage = function(obj,prev) {
+ try {
+// log('rounds handleChangeCampaignInitativepage: called');
+ if (obj.get('initiativepage')) {
+ prepareTurnorder(obj.get('turnorder'));
+ } else {
+ if (flags.clearonclose) {
+ doClearTurnorder();
+ doPauseTracker();
+ }
+ }
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleChangeCampaignInitativepage()');
+ }
+ };
+
+ /**
+ * Handle Graphic movement events
+ */
+ var handleChangeGraphicMovement = function(obj,prev) {
+ try {
+// log('rounds handleChangeGraphicMovement: called');
+ if (!flags.image || flags.rw_state === RW_StateEnum.STOPPED)
+ {return;}
+ var graphic = findTrackerGraphic(),
+ curToken = findCurrentTurnToken(),
+ maxsize = 0;
+
+ if (!curToken || curToken.get('_id') !== obj.get('_id'))
+ {return;}
+
+ maxsize = Math.max(parseInt(curToken.get('width')),parseInt(curToken.get('height')));
+ graphic.set('layer','gmlayer');
+ graphic.set('left',curToken.get('left'));
+ graphic.set('top',curToken.get('top'));
+ graphic.set('width',maxsize*fields.trackerImgRatio);
+ graphic.set('height',maxsize*fields.trackerImgRatio);
+ if (flags.rw_state === RW_StateEnum.ACTIVE)
+ {flags.rw_state = RW_StateEnum.FROZEN;}
+ setTimeout(function() {
+ try {
+ if (graphic) {
+ if (curToken.get('layer') === 'gmlayer') {
+ graphic.set('layer','gmlayer');
+ toBack(graphic);
+ } else {
+ graphic.set('layer','map');
+ toFront(graphic);
+ }
+ if (flags.rw_state === RW_StateEnum.FROZEN)
+ {flags.rw_state = RW_StateEnum.ACTIVE;}
+ }
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleChangeGraphicMovement()');
+ }
+ },500);
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleChangeGraphicMovement()');
+ }
+ };
+
+ /**
+ * Handle a change to the page the Player ribbon is on
+ **/
+
+ var handleChangePlayerPage = function(obj,prev) {
+ try {
+// log('rounds handleChangePlayerPage: called');
+ var page = getObj('page',Campaign().get('playerpageid')),
+ tokens = findObjs({ _pageid: page.id, _type: 'graphic' });
+ if (!!tokens) {
+ tokens = _.toArray(tokens);
+ doMoveStatus( tokens );
+ }
+ return;
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleChangePlayerPage()');
+ }
+ }
+
+ /**
+ * Handle a token being added to a page. Check if this is the
+ * current Player page and, if so, check if any effect markers
+ * should be applied to it.
+ */
+
+ var handleChangeToken = function(obj,prev) {
+ try {
+// log('rounds handleChangeToken: called');
+ if (!obj)
+ {return;}
+
+ if (obj.get('name') == prev['name'])
+ {return;}
+
+ if (obj.get('name').toLowerCase().replace(reIgnore,'').includes('dmcrosshair')) {
+ doSetAOE([obj.id], findTheGM());
+ } else {
+ if (prev['name'].length > 0 && obj.get('_subtype') == 'token' && !obj.get('isdrawing')) {
+ doPushStatus( obj.id, prev['name'], ((prev['represents'] && prev['represents'].length>0) ? prev['represents'] : obj.get('represents')) );
+ }
+ if (obj.get('_pageid') == Campaign().get('playerpageid')) {
+ var tokens = [];
+ tokens[0] = obj;
+ doMoveStatus( tokens );
+ }
+ }
+ return;
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleChangeToken()');
+ }
+ }
+
+ /**
+ * Handle the event when a token is removed from a page.
+ * Move any live effects on it to any other similar token.
+ * If no similar tokens exist, end the effects, calling the
+ * relevant effect macros.
+ */
+
+ var handleDestroyToken = function(obj,prev) {
+ try {
+// log('rounds handleDestroyToken: called');
+ var oldID = obj.id,
+ oldName = obj.get('name'),
+ oldRepresents = obj.get('represents'),
+ oldStatusMarkers = obj.get('statusmarkers'),
+ oldEffects = state.roundMaster.effects[oldID],
+ effectAbility,
+ newToken,
+ newEffects,
+ newStatusMarkers,
+ charCS,
+ removedStatus,
+ toRemove = [];
+
+ if (!oldEffects || oldEffects.length == 0) {
+ return;
+ };
+
+ newToken = _.find( findObjs({
+ _pageid: Campaign().get('playerpageid'),
+ _type: 'graphic',
+ name: oldName,
+ represents: oldRepresents
+ }), function(t) {return t.id != oldID});
+ if (!newToken) {
+ newToken = _.find( findObjs({
+ _type: 'graphic',
+ name: oldName,
+ represents: oldRepresents
+ }), function(t) {return t.id != oldID});
+ };
+
+ if (newToken) {
+ // If found a match, just add the effect markers and effects
+ newEffects = getStatusEffects(newToken);
+ newStatusMarkers = newToken.get('statusmarkers');
+
+ if (oldEffects && Array.isArray(oldEffects)) {
+ if (newEffects && Array.isArray(newEffects)) {
+ newEffects = newEffects.concat(oldEffects);
+ } else {
+ newEffects = oldEffects;
+ }
+ }
+ if (newEffects) {
+ setStatusEffects(newToken,newEffects);
+ }
+
+ if (newStatusMarkers && newStatusMarkers.length > 0) {
+ newStatusMarkers += ',' + oldStatusMarkers;
+ } else {
+ newStatusMarkers = oldStatusMarkers;
+ }
+ if (newStatusMarkers) {
+ newToken.set('statusmarkers',newStatusMarkers);
+ }
+
+ } else {
+ // Can't use calls to the normal functions, as obj no longer exists
+ _.each(oldEffects, function(e) {
+ // If the Effects library exists, run any effect-end macro on this character
+ // Can't call sendAPImacro as obj no longer exists in Campaign
+ charCS = getObj( 'character', oldRepresents );
+ if (charCS) {
+ var cname = charCS.get('name'),
+ bar1 = obj.get('bar1_value'),
+ bar2 = obj.get('bar2_value'),
+ bar3 = obj.get('bar3_value'),
+ ac, acField, thac0,thac0Field, hp, hpField,
+ effectAbility = abilityLookup( fields.effectlib, e.name+'-end', oldID ),
+ macroBody = effectAbility.action;
+
+ [ac,acField] = getTokenValues(obj,fields.Token_AC,fields.AC,fields.MonsterAC);
+ [thac0,thac0Field] = getTokenValues(obj,fields.Token_Thac0,fields.Thac0,fields.MonsterThac0);
+ [hp,hpField] = getTokenValues(obj,fields.Token_HP,fields.HP);
+
+ if (!macroBody) {
+ sendDebug('handleDestroyToken: Not found effectMacro ' + e.name + '-end');
+ } else {
+ if (!cname) {
+ cname = oldName;
+ }
+ if (macroBody) {
+ macroBody = macroBody.replace( /\^\^cname\^\^/gi , cname )
+ .replace( /\^\^tname\^\^/gi , oldName )
+ .replace( /\^\^cid\^\^/gi , oldRepresents )
+ .replace( /\^\^tid\^\^/gi , oldID )
+ .replace( /\^\^bar1_current\^\^/gi , bar1 )
+ .replace( /\^\^bar2_current\^\^/gi , bar2 )
+ .replace( /\^\^bar3_current\^\^/gi , bar3 )
+ .replace( /\^\^ac\^\^/gi , ac.current )
+ .replace( /\^\^thac0\^\^/gi , thac0.current )
+ .replace( /\^\^hp\^\^/gi , hp.current )
+ .replace( /\^\^ac_max\^\^/gi , ac.max )
+ .replace( /\^\^thac0_max\^\^/gi , thac0.max )
+ .replace( /\^\^hp_max\^\^/gi , hp.max )
+ .replace( /\^\^token_ac\^\^/gi , acField.current )
+ .replace( /\^\^token_thac0\^\^/gi , thac0Field.current )
+ .replace( /\^\^token_hp\^\^/gi , hpField.current )
+ .replace( /\^\^token_ac_max\^\^/gi , acField.max )
+ .replace( /\^\^token_thac0_max\^\^/gi , thac0Field.max )
+ .replace( /\^\^token_hp_max\^\^/gi , hpField.max );
+ sendDebug('handleDestroyToken: macroBody is ' + macroBody );
+ sendChat('',macroBody,null,{noarchive:!flags.archive, use3d:false});
+
+ }
+ }
+ }
+ // Reduce by 1 the number of tokens that have this effect status
+ // If the effect status is no longer on any token, remove it
+ removedStatus = updateGlobalStatus(e.name,undefined,-1);
+ toRemove.push(removedStatus);
+ });
+ }
+ updateAllTokenMarkers(toRemove);
+ return;
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleDestroyToken()');
+ }
+
+ };
+
+ var handleTokenDeath = function(obj,prev) {
+ try {
+// log('rounds handleTokenDeath: called');
+ if (obj.get("status_dead")) {
+ // If the token dies and is marked as "dead" by the GM
+ // remove all active effects from the token
+ doRemoveStatus( 'all', [obj], false, false );
+ }
+ } catch (e) {
+ sendCatchError('RoundMaster',null,e,'RoundMaster handleTokenDeath()');
+ }
+ return;
+ };
+
+ /**
+ * Register and bind event handlers
+ */
+ var registerAPI = function() {
+ on('chat:message',handleChatMessage);
+ on('change:campaign:turnorder',handleChangeCampaignTurnorder);
+ on('change:campaign:initiativepage',handleChangeCampaignInitativepage);
+ on('change:campaign:playerpageid',handleChangePlayerPage);
+ on('change:graphic:top',handleChangeGraphicMovement);
+ on('change:graphic:left',handleChangeGraphicMovement);
+ on('change:graphic:layer',handleChangeGraphicMovement);
+ on('change:graphic:name',handleChangeToken);
+ on('change:graphic:statusmarkers',handleTokenDeath);
+ on('destroy:graphic',handleDestroyToken);
+ };
+
+ return {
+ init: init,
+ registerAPI: registerAPI
+ };
+
+}());
+
+on("ready", function() {
+ 'use strict';
+ RoundMaster.init();
+ RoundMaster.registerAPI();
+});
+
+{try{throw new Error('');}catch(e){API_Meta.RoundMaster.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.RoundMaster.offset);}}
diff --git a/RoundMaster/RoundMaster.js b/RoundMaster/RoundMaster.js
index d4454d183..cd1bfd813 100644
--- a/RoundMaster/RoundMaster.js
+++ b/RoundMaster/RoundMaster.js
@@ -128,14 +128,15 @@ API_Meta.RoundMaster={offset:Number.MAX_SAFE_INTEGER,lineCount:-1};
* represent number of creatures targeted with a --target multi. Fix support of ^^duration^^
* tag in effects.
* v5.056 22/06/2024 Added --state-extract & --state-load functions to support migration to JumpGate
+ * v5.057 20/09/2024 Corrected --addStatus to accept saving throw and other extensions to args.
**/
var RoundMaster = (function() {
'use strict';
- var version = 5.056,
+ var version = 5.057,
author = 'Ken L. & RED',
pending = null;
- const lastUpdate = 1719059275;
+ const lastUpdate = 1726905793;
var RW_StateEnum = Object.freeze({
ACTIVE: 0,
@@ -3966,7 +3967,7 @@ var RoundMaster = (function() {
if (!args)
{return;}
- if (args.length <4 || args.length > 6) {
+ if (args.length <4) {
sendDebug('doPlayerTargetStatus: Invalid number of arguments');
sendError('Invalid status item syntax');
return;
@@ -3994,14 +3995,14 @@ var RoundMaster = (function() {
{return;}
if (!selection) {
sendDebug('doPlayerAddStatus: Selection undefined');
- sendResponseError('Invalid selection');
+ sendResponseError(senderId,'Invalid selection');
return;
}
// RED: v1.204 extended arguments to optionally include the marker
- if (args.length <3 || args.length > 5) {
+ if (args.length <3) {
sendDebug('doPlayerAddStatus: Invalid number of arguments');
- sendResponseError('Invalid status item syntax');
+ sendResponseError(senderId,'Invalid status item syntax');
return;
}
var mod;
@@ -4035,7 +4036,7 @@ var RoundMaster = (function() {
if (isNaN(duration) || isNaN(direction)) {
sendDebug('doPlayerAddStatus: duration or direction not a number. Duration "' + duration + '", direction "' + direction + '"');
- sendResponseError('Invalid status item syntax');
+ sendResponseError(senderId,'Invalid status item syntax');
return;
}
@@ -6061,7 +6062,7 @@ var RoundMaster = (function() {
if (!dbCS || !dbCS[0]) {
log(obj.name+' not found. Creating version '+obj.version);
if (!silent) sendFeedback(obj.name+' not found. Creating version '+obj.version);
- dbCS = createObj('handout',{name:obj.name,inplayerjournals:senderId});
+ dbCS = createObj('handout',{name:obj.name,inplayerjournals:(senderId || '')});
dbCS.set('notes',obj.bio);
dbCS.set('avatar',obj.avatar);
} else {
diff --git a/RoundMaster/script.json b/RoundMaster/script.json
index 5f3f7925c..47800eb66 100644
--- a/RoundMaster/script.json
+++ b/RoundMaster/script.json
@@ -2,8 +2,8 @@
"$schema": "https://github.com/DameryDad/roll20-api-scripts/blob/RoundMasterAPI/RoundMaster/Script.json",
"name": "RoundMaster",
"script": "RoundMaster.js",
- "version": "5.056",
- "previousversions": ["3.020","3.022","3.024","3.025","3.026","3.027","3.029","4.033","4.034","4.035","4.036","4.038","4.039","4.040","4.041","4.042","4.043","4.044","4.045","4.046","4.047","4.048","5.049","5.050","5.051","5.052","5.053","5.054","5.055"],
+ "version": "5.057",
+ "previousversions": ["3.020","3.022","3.024","3.025","3.026","3.027","3.029","4.033","4.034","4.035","4.036","4.038","4.039","4.040","4.041","4.042","4.043","4.044","4.045","4.046","4.047","4.048","5.049","5.050","5.051","5.052","5.053","5.054","5.055","5.056"],
"description": "RoundMaster is an API for the Roll20 RPG-DS. Its purpose is to extend the functionality of the Turn Tracker capability already built in to Roll20. It is one of several other similar APIs available on the platform that support the Turn Tracker and manage token and character statuses related to the passing of time: the USP of this one is the full richness of its functionality including token status effect macros that make real things happen.[RoundMaster Documentation](https://wiki.roll20.net/Script:RoundMaster) \n\n### Related APIs\nThis API works best with the RPGMaster series of APIs, and most especially the InitMaster API\n[RPGMaster Documentation](https://wiki.roll20.net/RPGMaster)\n[InitMaster Documentation](https://wiki.roll20.net/Script:InitMaster) \n\n### If using with InitMaster\n* As a Macro in the DM's macro quick bar, add the command `!init --maint` to manage RoundMaster functions\n* Add the command `!init --menu` as an Ability Macros on Character Sheets of Characters, NPCs & Monsters that will use the API, and tick 'Show as Token Action'. These menus will then be available to Players controlling those sheets and give access to all common commands used in game-play.\n\n### Further Information\nRoundMaster is based on the much older TrackerJacker API, and many thanks to Ken L. for creating TrackerJacker. However, RoundMaster is a considerable fix and extension to TrackerJacker, suited to many different applications in many different RPG scenarios. On loading, RoundMaster will create handouts in the Campaign with help on all its commands and functions. It will also create a Character Sheet database of Effect macros - see the documentation in the handouts for more information.",
"authors": "Richard E. based on TrackerJacker by Ken L.",
"roll20userid": "6497708",
From bbf0c5041910f285ba3309b9a4b041d9ba517ad7 Mon Sep 17 00:00:00 2001
From: DameryDad <74715860+DameryDad@users.noreply.github.com>
Date: Sat, 21 Sep 2024 15:17:33 +0100
Subject: [PATCH 06/14] Fixed rotate-tracker state
* Fixed the tracker graphic rotation state to be the saved state on initialisation.
---
RoundMaster/5.057/RoundMaster.js | 28 ++++++++++++++++------------
RoundMaster/RoundMaster.js | 28 ++++++++++++++++------------
2 files changed, 32 insertions(+), 24 deletions(-)
diff --git a/RoundMaster/5.057/RoundMaster.js b/RoundMaster/5.057/RoundMaster.js
index cd1bfd813..796cd3c95 100644
--- a/RoundMaster/5.057/RoundMaster.js
+++ b/RoundMaster/5.057/RoundMaster.js
@@ -4800,21 +4800,27 @@ var RoundMaster = (function() {
*
* TODO make the rotation rate a field variable
*/
+
var animateTracker = function() {
- if (!flags.animating)
- {return;}
- if (flags.rw_state === RW_StateEnum.ACTIVE) {
+ if (!flags.animating) {
+ return;
+ }
+ if (flags.rw_state == RW_StateEnum.ACTIVE) {
+// log('doAnimateTracker: Active');
if (state.roundMaster.rotation) {
var graphic = findTrackerGraphic();
- graphic.set('rotation',parseInt(graphic.get('rotation'))+fields.rotation_degree);
+ graphic.set('rotation',(parseInt(graphic.get('rotation'))+parseInt(fields.rotation_degree)));
+// log('doAnimateTracker: Rotating');
}
setTimeout(function() {animateTracker();},500);
- } else if (flags.rw_state === RW_StateEnum.PAUSED
- || flags.rw_state === RW_StateEnum.FROZEN) {
- setTimeout(function() {animateTracker();},500);
+ } else if (flags.rw_state == RW_StateEnum.PAUSED
+ || flags.rw_state == RW_StateEnum.FROZEN) {
+// log('doAnimateTracker: Paused or Frozen');
+ setTimeout(function() {animateTracker();},1000);
} else {
- flags.animating = false;
+ log('doAnimateTracker: Stopped or undefined');
+// flags.animating = false;
}
};
@@ -5164,10 +5170,8 @@ var RoundMaster = (function() {
}
updateTurnorderMarker();
- if (!flags.animating) {
- flags.animating = state.roundMaster.rotation;
- animateTracker();
- }
+ flags.animating = state.roundMaster.rotation;
+ animateTracker();
};
/**
diff --git a/RoundMaster/RoundMaster.js b/RoundMaster/RoundMaster.js
index cd1bfd813..796cd3c95 100644
--- a/RoundMaster/RoundMaster.js
+++ b/RoundMaster/RoundMaster.js
@@ -4800,21 +4800,27 @@ var RoundMaster = (function() {
*
* TODO make the rotation rate a field variable
*/
+
var animateTracker = function() {
- if (!flags.animating)
- {return;}
- if (flags.rw_state === RW_StateEnum.ACTIVE) {
+ if (!flags.animating) {
+ return;
+ }
+ if (flags.rw_state == RW_StateEnum.ACTIVE) {
+// log('doAnimateTracker: Active');
if (state.roundMaster.rotation) {
var graphic = findTrackerGraphic();
- graphic.set('rotation',parseInt(graphic.get('rotation'))+fields.rotation_degree);
+ graphic.set('rotation',(parseInt(graphic.get('rotation'))+parseInt(fields.rotation_degree)));
+// log('doAnimateTracker: Rotating');
}
setTimeout(function() {animateTracker();},500);
- } else if (flags.rw_state === RW_StateEnum.PAUSED
- || flags.rw_state === RW_StateEnum.FROZEN) {
- setTimeout(function() {animateTracker();},500);
+ } else if (flags.rw_state == RW_StateEnum.PAUSED
+ || flags.rw_state == RW_StateEnum.FROZEN) {
+// log('doAnimateTracker: Paused or Frozen');
+ setTimeout(function() {animateTracker();},1000);
} else {
- flags.animating = false;
+ log('doAnimateTracker: Stopped or undefined');
+// flags.animating = false;
}
};
@@ -5164,10 +5170,8 @@ var RoundMaster = (function() {
}
updateTurnorderMarker();
- if (!flags.animating) {
- flags.animating = state.roundMaster.rotation;
- animateTracker();
- }
+ flags.animating = state.roundMaster.rotation;
+ animateTracker();
};
/**
From 1a2efe0081dfdf82701252246baae15820d272fd Mon Sep 17 00:00:00 2001
From: timmaugh
Date: Tue, 8 Oct 2024 09:11:11 -0400
Subject: [PATCH 07/14] Plugger v1.0.10
Bug fix on 0-length strings within tick enclosures.
---
Plugger/1.0.10/Plugger.js | 677 ++++++++++++++++++++++++++++++++++++++
Plugger/Plugger.js | 45 +--
Plugger/script.json | 5 +-
3 files changed, 704 insertions(+), 23 deletions(-)
create mode 100644 Plugger/1.0.10/Plugger.js
diff --git a/Plugger/1.0.10/Plugger.js b/Plugger/1.0.10/Plugger.js
new file mode 100644
index 000000000..e21afb5d4
--- /dev/null
+++ b/Plugger/1.0.10/Plugger.js
@@ -0,0 +1,677 @@
+/*
+=========================================================
+Name : Plugger
+GitHub : https://github.com/TimRohr22/Cauldron/tree/master/Plugger
+Roll20 Contact : timmaugh
+Version : 1.0.10
+Last Update : 8 OCT 2024
+=========================================================
+*/
+var API_Meta = API_Meta || {};
+API_Meta.Plugger = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 };
+{
+ try { throw new Error(''); } catch (e) { API_Meta.Plugger.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (13)); }
+}
+
+const Plugger = (() => {
+ const apiproject = 'Plugger';
+ const version = '1.0.10';
+ const schemaVersion = 0.1;
+ API_Meta[apiproject].version = version;
+ const vd = new Date(1728392407761);
+ const versionInfo = () => {
+ log(`\u0166\u0166 ${apiproject} v${API_Meta[apiproject].version}, ${vd.getFullYear()}/${vd.getMonth() + 1}/${vd.getDate()} \u0166\u0166 -- offset ${API_Meta[apiproject].offset}`);
+ if (!state.hasOwnProperty(apiproject) || state[apiproject].version !== schemaVersion) {
+ log(` > Updating ${apiproject} Schema to v${schemaVersion} <`);
+ switch (state[apiproject] && state[apiproject].version) {
+
+ case 0.1:
+ /* break; // intentional dropthrough */ /* falls through */
+
+ case 'UpdateSchemaVersion':
+ state[apiproject].version = schemaVersion;
+ break;
+
+ default:
+ state[apiproject] = {
+ version: schemaVersion,
+ };
+ break;
+ }
+ }
+ };
+ const logsig = () => {
+ // initialize shared namespace for all signed projects, if needed
+ state.torii = state.torii || {};
+ // initialize siglogged check, if needed
+ state.torii.siglogged = state.torii.siglogged || false;
+ state.torii.sigtime = state.torii.sigtime || Date.now() - 3001;
+ if (!state.torii.siglogged || Date.now() - state.torii.sigtime > 3000) {
+ const logsig = '\n' +
+ ' _____________________________________________ ' + '\n' +
+ ' )_________________________________________( ' + '\n' +
+ ' )_____________________________________( ' + '\n' +
+ ' ___| |_______________| |___ ' + '\n' +
+ ' |___ _______________ ___| ' + '\n' +
+ ' | | | | ' + '\n' +
+ ' | | | | ' + '\n' +
+ ' | | | | ' + '\n' +
+ ' | | | | ' + '\n' +
+ ' | | | | ' + '\n' +
+ '______________|_|_______________|_|_______________' + '\n' +
+ ' ' + '\n';
+ log(`${logsig}`);
+ state.torii.siglogged = true;
+ state.torii.sigtime = Date.now();
+ }
+ return;
+ };
+
+ const nestlog = (stmt, ilvl = 0, logcolor = '', boolog = false) => {
+ if (isNaN(ilvl)) {
+ ilvl = 0;
+ log(`Next statement fed a NaN value for the indentation.`);
+ }
+ if ((state[apiproject] && state[apiproject].logging === true) || boolog) {
+ let l = `${Array(ilvl + 1).join("==")}${stmt}`;
+ if (logcolor) {
+ // l = /:/.test(l) ? `${l.replace(/:/, ':')}` : `${l}`;
+ }
+ log(l);
+ }
+ };
+
+ const escapeRegExp = (string) => { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); };
+ const assertstart = rx => new RegExp(`^${rx.source}`, rx.flags);
+ const getfirst = (cmd, ...args) => {
+ // pass in objects of form: {type: 'text', rx: /regex/}
+ // return object of form : {regex exec object with property 'type': 'text'}
+
+ let ret = {};
+ let r;
+ args.find(a => {
+ r = a.rx.exec(cmd);
+ if (r && (!ret.length || r.index < ret.index)) {
+ ret = Object.assign(r, { type: a.type });
+ }
+ a.lastIndex = 0;
+ }, ret);
+ return ret;
+ };
+
+ // REGEX STATEMENTS =====================================
+ const evalrx = /(\()?{&\s*eval(?:\((?[^)]+)\)){0,1}\s*}((?<=\({&\s*eval(?:\(([^)]+)\)){0,1}\s*})\)|\1)\s*/i,
+ evalendrx = /(\()?{&\s*\/\s*eval\s*}((?<=\({&\s*\/\s*eval\s*})\)|\1)/i;
+
+ // TAG RX SETS REGISTRY =================================
+ const tagrxset = {
+ 'eval': { opentag: evalrx, endtag: evalendrx }
+ };
+
+ // TOKEN MARKERS ========================================
+ const eostm = { rx: /$/, type: 'eos' },
+ evaltm = { rx: evalrx, type: 'eval' },
+ evalendtm = { rx: evalendrx, type: 'evalend' };
+
+ // END TOKEN REGISTRY ===================================
+ const endtokenregistry = {
+ main: [eostm],
+ eval: [evalendtm],
+ };
+
+ const tokenizeOps = (msg, msgstate, status, notes) => {
+ class TextToken {
+ constructor() {
+ this.type = 'text';
+ this.escape = '';
+ this.value = '';
+ }
+ }
+ class PlugEvalToken {
+ constructor() {
+ this.type = 'eval';
+ this.contents = [];
+ }
+ }
+
+ const getTextToken = (c) => {
+ let logcolor = 'lawngreen';
+ nestlog(`TEXT INPUT: ${c.cmd}`, c.indent, logcolor, msgstate.logging);
+ let markers = [];
+ c.looptype = c.looptype || '';
+ switch (c.looptype) {
+ case 'eval':
+ default:
+ markers = [evaltm, evalendtm, eostm];
+ break;
+ }
+ let res = getfirst(c.cmd, ...markers);
+ let index = res.index;
+ let token = new TextToken();
+ token.value = c.cmd.slice(0, index);
+ nestlog(`TEXT KEEPS: ${token.value}`, c.indent, logcolor, msgstate.logging);
+ return { token: token, index: index };
+ };
+ const getPlugEvalToken = (c) => {
+ // receives object in the form of:
+ // {cmd: command line slice, indent: #, overallindex: #, looptype: text}
+ let logcolor = 'yellow';
+ let index = 0;
+ let evalopenres = tagrxset[c.looptype].opentag.exec(c.cmd);
+ if (evalopenres) {
+ nestlog(`${c.looptype.toUpperCase()} TOKEN INPUT: ${c.cmd}`, c.indent, logcolor, msgstate.logging);
+ let token = new PlugEvalToken();
+ token.escape = evalopenres.groups && evalopenres.groups.escape && evalopenres.groups.escape.length ? evalopenres.groups.escape : '';
+ let index = evalopenres[0].length;
+
+ // content and nested evals
+ nestlog(`BUILDING CONTENT: ${c.cmd.slice(index)}`, c.indent + 1, 'lightseagreen', msgstate.logging);
+ let contentres = evalval({ cmd: c.cmd.slice(index), indent: c.indent + 1, type: c.looptype, overallindex: c.overallindex + index, looptype: c.looptype });
+ if (contentres.error) return contentres;
+ token.contents = contentres.tokens;
+ index += contentres.index;
+ nestlog(`ENDING CONTENT: ${c.cmd.slice(index)}`, c.indent + 1, 'lightseagreen', msgstate.logging);
+
+ // closing bracket of eval tag
+ let evalendres = tagrxset[c.looptype].endtag.exec(c.cmd.slice(index));
+ if (!evalendres) {
+ status.push('unresolved');
+ notes.push(`Unexpected token at ${c.overallindex + index}. Expected end of ${c.looptype.toUpperCase()} structure ('{& eval}'), but saw: ${c.cmd.slice(index, index + 10)}`);
+ return { error: `Unexpected token at ${c.overallindex + index}. Expected end of ${c.looptype.toUpperCase()} structure ('{& eval}'), but saw: ${c.cmd.slice(index, index + 10)}` };
+ }
+ index += evalendres[0].length;
+ nestlog(`${c.looptype.toUpperCase()} TOKEN OUTPUT: ${JSON.stringify(token)}`, c.indent, logcolor, msgstate.logging);
+ return { token: token, index: index };
+ } else {
+ status.push('unresolved');
+ notes.push(`Unexpected token at ${c.overallindex + index}. Expected an ${c.looptype.toUpperCase()} structure, but saw: ${c.cmd.slice(index, index + 10)}`);
+ return { error: `Unexpected token at ${c.overallindex + index}. Expected an ${c.looptype.toUpperCase()} structure, but saw: ${c.cmd.slice(index, index + 10)}` };
+ }
+ };
+ const evalval = c => {
+ // expects an object in the form of:
+ // { cmd: text, indent: #, overallindex: #, type: text, overallindex: #, looptype: text }
+ let tokens = []; // main output array
+ let logcolor = 'aqua';
+ let loopstop = false;
+ let tokenres = {};
+ let index = 0;
+ let loopindex = 0;
+ nestlog(`${c.looptype.toUpperCase()} BEGINS`, c.indent, logcolor, msgstate.logging);
+ while (!loopstop) {
+ loopindex = index;
+ if (assertstart(tagrxset[c.looptype].opentag).test(c.cmd.slice(index))) {
+ status.push('changed');
+ tokenres = getPlugEvalToken({ cmd: c.cmd.slice(index), indent: c.indent + 1, overallindex: c.overallindex + index, looptype: c.looptype });
+ } else {
+ tokenres = getTextToken({ cmd: c.cmd.slice(index), indent: c.indent + 1, overallindex: c.overallindex + index, looptype: c.looptype });
+ }
+ if (tokenres) {
+ if (tokenres.error) { return tokenres; }
+ tokens.push(tokenres.token);
+ index += tokenres.index;
+ }
+ if (loopindex === index) { // nothing detected, loop never ends
+ return { error: `Unexpected token at ${c.overallindex + index}.` };
+ }
+ loopstop = (getfirst(c.cmd.slice(index), ...endtokenregistry[c.type]).index === 0);
+ }
+ nestlog(`${c.looptype.toUpperCase()} ENDS`, c.indent, logcolor, msgstate.logging);
+ return { tokens: tokens, index: index };
+ };
+
+ return evalval({ cmd: msg.content, indent: 0, type: 'main', overallindex: 0, looptype: 'eval' });
+ };
+
+ const reconstructOps = (o, msg, msgstate, status, notes) => {
+ const runPlugin = c => {
+ const evalstmtrx = /^\s*(? | |