diff --git a/Assemble/1.0.1/Assemble.js b/Assemble/1.0.1/Assemble.js
new file mode 100644
index 000000000..0924e446d
--- /dev/null
+++ b/Assemble/1.0.1/Assemble.js
@@ -0,0 +1,746 @@
+/*
+=========================================================
+Name : Assemble
+GitHub :
+Roll20 Contact : timmaugh
+Version : 1.0.1
+Last Update : 5 DEC 2025
+=========================================================
+*/
+var API_Meta = API_Meta || {};
+API_Meta.Assemble = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 };
+{ try { throw new Error(''); } catch (e) { API_Meta.Assemble.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (12)); } }
+
+const Assemble = (() => { // eslint-disable-line no-unused-vars
+ const apiproject = 'Assemble';
+ const version = '1.0.1';
+ const schemaVersion = 0.1;
+ API_Meta[apiproject].version = version;
+ const vd = new Date(1764944902848);
+ const apilogo = `https://i.imgur.com/zcfdU8n.png`;
+
+ 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}`);
+ };
+ 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 checkInstall = () => {
+ if (!state.hasOwnProperty(apiproject) || state[apiproject].version !== schemaVersion) {
+ log(` > Updating ${apiproject} Schema to v${schemaVersion} <`);
+ switch (state[apiproject] && state[apiproject].version) {
+
+ case 0.1:
+ /* falls through */
+
+ case 'UpdateSchemaVersion':
+ state[apiproject].version = schemaVersion;
+ break;
+
+ default:
+ state[apiproject] = {
+ settings: {},
+ defaults: {},
+ version: schemaVersion
+ }
+ break;
+ }
+ }
+ };
+ let stateReady = false;
+ const assureState = () => {
+ if (!stateReady) {
+ checkInstall();
+ stateReady = true;
+ }
+ };
+ const manageState = { // eslint-disable-line no-unused-vars
+ reset: () => state[apiproject].settings = _.clone(state[apiproject].defaults),
+ clone: () => { return _.clone(state[apiproject].settings); },
+ set: (p, v) => state[apiproject].settings[p] = v,
+ get: (p) => { return state[apiproject].settings[p]; }
+ };
+
+ // ==================================================
+ // PRESENTATION
+ // ==================================================
+ let html = {};
+ let HE = () => { }; // eslint-disable-line no-unused-vars
+ const theme = {
+ primaryColor: '#23223F',
+ primaryTextColor: '#232323',
+ primaryTextBackground: '#ededed',
+ secondaryColor: '#607D8B',
+ baseFontFamily: 'Arial',
+ baseFontSize: '10pt',
+ headerFontFamily: 'Contrail One',
+ headerFontSize: '1.3em',
+ headerColor: 'white',
+ dangerColor: `#781718`, // red
+ infoColor: `#1A6675`, // carribean
+ safeColor: `#3E7A46`, // fern
+
+ }
+ const localCSS = {
+ containerCSS: {
+ 'margin-left': '-8px',
+ 'width': 'unset',
+ 'position': 'relative',
+ 'top': '-20px',
+ 'display': 'block'
+ },
+ msgheader: {
+ 'background-color': theme.primaryColor,
+ 'color': theme.headerColor,
+ 'font-size': theme.headerFontSize,
+ 'font-family': theme.headerFontFamily,
+ 'padding-left': '4px'
+ },
+ msgbody: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgfooter: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgheadercontent: {
+ 'display': 'table-cell',
+ 'vertical-align': 'middle',
+ 'padding': '4px 8px 4px 6px'
+ },
+ msgheaderlogodiv: {
+ 'display': 'table-cell',
+ 'max-height': '30px',
+ 'margin-right': '8px',
+ 'margin-top': '4px',
+ 'vertical-align': 'middle'
+ },
+ logoimg: {
+ 'background-color': 'transparent',
+ 'float': 'left',
+ 'border': 'none',
+ 'max-height': '30px'
+ },
+ boundingcss: {
+ 'background-color': theme.primaryTextBackground
+ },
+ inlineEmphasis: {
+ 'font-weight': 'bold'
+ },
+ btncss: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'text-decoration': 'none',
+ },
+ menubtn: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'width': '13px',
+ 'height': '14px',
+ 'line-height': '14px',
+ 'text-decoration': 'none',
+ 'text-align': 'center',
+ },
+ buttonPictos: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'margin': '0px 2px',
+ 'line-height': '14px',
+ 'font-family': 'pictos',
+ 'font-size': '12px',
+ 'text-align': 'center',
+ 'width': '13px',
+ 'height': '14px',
+ 'vertical-align': 'middle',
+ 'margin-top': '-2px',
+ 'text-decoration': 'none',
+ },
+ secondaryColor: {
+ 'background-color': theme.secondaryColor
+ },
+ danger: {
+ 'background-color': theme.dangerColor
+ },
+ safe: {
+ 'background-color': theme.safeColor
+ },
+ info: {
+ 'background-color': theme.infoColor
+ },
+ tipContainer: {
+ 'overflow': 'hidden',
+ 'width': '100%',
+ 'border': 'none',
+ 'max-width': '250px',
+ 'display': 'block'
+ },
+ tipBounding: {
+ 'border-radius': '10px',
+ 'border': '2px solid #000000',
+ 'display': 'table-cell',
+ 'width': '100%',
+ 'overflow': 'hidden',
+ 'font-family': theme.baseFontFamily,
+ 'font-size': theme.baseFontSize
+ },
+ tipHeaderLine: {
+ 'overflow': 'hidden',
+ 'display': 'table',
+ 'background-color': theme.primaryColor,
+ 'width': '100%'
+ },
+ tipLogoSpan: {
+ 'display': 'table-cell',
+ 'overflow': 'hidden',
+ 'vertical-align': 'middle',
+ 'width': '40px'
+ },
+ tipLogoImg: {
+ 'margin-left': '3px',
+ 'background-image': `url('${apilogo}')`,
+ 'background-repeat': 'no-repeat',
+ 'backgound-size': 'contain',
+ 'width': '37px',
+ 'height': '37px',
+ 'display': 'inline-block',
+ 'background-size': '35px',
+ 'vertical-align': 'middle'
+ },
+ tipContentLine: {
+ 'overflow': 'hidden',
+ 'display': 'table',
+ 'background-color': theme.primaryTextBackground,
+ 'width': '100%'
+ },
+ tipContent: {
+ 'display': 'table-cell',
+ 'overflow': 'hidden',
+ 'padding': '5px 8px',
+ 'text-align': 'left',
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ tipHeaderTitle: {
+ 'display': 'table-cell',
+ 'overflow': 'hidden',
+ 'padding': '5px 8px',
+ 'text-align': 'left',
+ 'vertical-align': 'middle',
+ 'color': theme.headerColor,
+ 'font-size': theme.headerFontSize,
+ 'font-family': theme.headerFontFamily
+ },
+ textleft: {
+ 'text-align': 'left'
+ },
+ textright: {
+ 'text-align': 'right'
+ },
+ textcenter: {
+ 'text-align': 'center'
+ }
+ }
+ const msgbox = ({
+ msg: msg = '',
+ title: title = '',
+ headercss: headercss = localCSS.msgheader,
+ bodycss: bodycss = localCSS.msgbody,
+ footercss: footercss = localCSS.msgfooter,
+ sendas: sendas = 'Assemble',
+ whisperto: whisperto = '',
+ footer: footer = '',
+ btn: btn = '',
+ } = {}) => {
+ if (title) title = html.div(html.div(html.img(apilogo, 'Assemble Logo', localCSS.logoimg), localCSS.msgheaderlogodiv) + html.div(title, localCSS.msgheadercontent), {});
+ Messenger.MsgBox({ msg: msg, title: title, bodycss: bodycss, sendas: sendas, whisperto: whisperto, footer: footer, btn: btn, headercss: headercss, footercss: footercss, boundingcss: localCSS.boundingcss, containercss: localCSS.containerCSS, noarchive: true });
+ };
+ const getTip = (label, header = 'Info', contents, contentcss = {}) => {
+ let contentCSS = { ...localCSS.tipContent, ...contentcss };
+ return html.tip(
+ label,
+ html.span( // container
+ html.span( // bounding
+ html.span( // header line
+ html.span( // left (logo)
+ html.span('', localCSS.tipLogoImg),
+ localCSS.tipLogoSpan) +
+ html.span( // right (content)
+ header,
+ localCSS.tipHeaderTitle),
+ localCSS.msgheader, localCSS.tipHeaderLine) +
+ html.span( // content line
+ html.span( // content cell
+ contents,
+ contentCSS),
+ localCSS.tipContentLine),
+ localCSS.tipBounding),
+ localCSS.tipContainer),
+ { 'display': 'inline-block' }
+ );
+ };
+
+ const getWhisperTo = (who) => who.toLowerCase() === 'api'
+ ? 'gm'
+ : who.toLowerCase() === 'all'
+ ? ''
+ : who.replace(/\s\(gm\)$/i, '');
+ // ==================================================
+ // UTILITIES
+ // ==================================================
+ const escapeRegExp = (string) => { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); };
+ const getAgnostic = query => {
+ let o = findObjs({ type: 'character' }).filter(c => c.get('name') === query || c.id === query)[0] ||
+ findObjs({ type: 'player', displayname: query })[0];
+ if (o) {
+ o.speakerType = o.get('type');
+ o.localName = o.get('name') || o.get('displayname');
+ o.chatSpeaker = o.speakerType + '|' + o.id;
+ } else if (query.toLowerCase() === 'all') {
+ o = { id: undefined, type: 'all', localName: 'all', speakerType: 'all', chatSpeaker: 'all', get: () => { return 'all'; } };
+ } else {
+ o = { id: undefined, type: 'gm', localName: 'gm', speakerType: 'gm', chatSpeaker: 'gm', get: () => { return 'gm'; } };
+ }
+ return o;
+ };
+ const getTheSpeaker = msg => {
+ let speaking;
+ if (['API', ''].includes(msg.who)) {
+ speaking = { id: undefined, type: 'API', localName: 'API', speakerType: 'API', chatSpeaker: 'API', get: () => { return 'API'; } };
+ } else {
+ let characters = findObjs({ type: 'character' });
+ characters.forEach(c => { if (c.get('name') === msg.who) speaking = c; });
+
+ if (speaking) {
+ speaking.speakerType = "character";
+ speaking.localName = speaking.get("name");
+ } else {
+ speaking = getObj('player', msg.playerid);
+ speaking.speakerType = "player";
+ speaking.localName = speaking.get("displayname");
+ }
+ speaking.chatSpeaker = speaking.speakerType + '|' + speaking.id;
+ }
+ return speaking;
+ };
+
+ const isNum = (v) => +v === +v;
+
+ const getChar = (query, pid) => { // find a character where query is an identifying piece of information (id, name, or token id)
+ let character;
+ if (typeof query !== 'string') return character;
+ let qrx = new RegExp(escapeRegExp(query), 'i');
+ let charsIControl = findObjs({ type: 'character' });
+ charsIControl = playerIsGM(pid) || manageState.get('playerscanids') ? charsIControl : charsIControl.filter(c => {
+ return c.get('controlledby').split(',').reduce((m, p) => {
+ return m || p === 'all' || p === pid;
+ }, false)
+ });
+ character = charsIControl.filter(c => c.id === query)[0] ||
+ charsIControl.filter(c => c.id === (getObj('graphic', query) || { get: () => { return '' } }).get('represents'))[0] ||
+ charsIControl.filter(c => c.get('name') === query)[0] ||
+ charsIControl.filter(c => {
+ qrx.lastIndex = 0;
+ return qrx.test(c.get('name'));
+ })[0];
+ return character;
+ };
+ const getToken = (info, pgid = '') => {
+ let token = findObjs({ type: 'graphic', subtype: 'token', id: info })[0] ||
+ findObjs({ type: 'graphic', subtype: 'card', id: info })[0] ||
+ findObjs({ type: 'graphic', subtype: 'token', name: info, pageid: pgid })[0] ||
+ findObjs({ type: 'graphic', subtype: 'token', pageid: pgid })
+ .filter(t => t.get('represents').length && findObjs({ type: 'character', id: t.get('represents') })[0].get('name') === info)[0];
+ if (!token) {
+ let tokensOfName = findObjs({ type: 'graphic', subtype: 'token', name: info });
+ if (tokensOfName.length === 1) {
+ token = tokensOfName[0];
+ }
+ }
+ return token;
+ };
+ const getAttr = (aname, char, pid) => {
+ if (!(aname && aname.length) || !char) return;
+ if (!char.id) char = getChar(char, pid);
+ return findObjs({ type: 'attribute', characterid: char.id })
+ .filter(a => a.get('name') === aname)[0];
+ };
+ const obtainAttr = (aname, char, pid, create = true) => {
+ if (!(aname && aname.length) || !char) return;
+ if (!char.id) char = getChar(char, pid);
+ let attr = getAttr(aname, char, pid);
+ if (!attr && create) attr = createObj('attribute', { characterid: char.id, name: aname });
+ return attr;
+ };
+
+ // ==================================================
+ // DEFINITIONS & TABLES
+ // ==================================================
+ const posValues = ['yes', 'y', 'true', 't', 'keith', 'yep'];
+ const maxAttrPrefix = `Assemble Attr: `;
+
+ // ==================================================
+ // HANDLE INPUT
+ // ==================================================
+ const handleInput = (msg) => {
+ /**
+ * !assemble --group|[=+-] --delim| --count|[=+-]# --keep|[id,name,charid] --mode|[=,+,-] --target| --target|
+ * !assemble --menu|character
+ * !assemble --swapdelim| --group|
+ */
+ if (msg.type !== 'api' || !/^!assemble\b/.test(msg.content)) { return; }
+
+ let args = msg.content
+ .split(/\s+--/)
+ .slice(1)
+ .map(a => a.split(/[#=|](.*)/))
+ .map(a => [a[0], /^(['`"])(.*)\1$/.test(a[1]) ? /^(['`"])(.*)\1$/.exec(a[1])[2] : a[1]])
+ .filter(a => a[1] && a[1].length);
+
+ let theSpeaker = getTheSpeaker(msg);
+
+ let argObj = {
+ delim: ',',
+ keep: 'id',
+ mode: '=', //overwrite
+ char: undefined,
+ attr: 'targets',
+ count: 0,
+ targets: [],
+ menu: [],
+ swapdelim: undefined,
+ report: true,
+ unique: true,
+ who: theSpeaker
+ };
+ let modeArray = ['=', '+', '-'];
+ let rxRet;
+ let explicitDelim = false;
+ let explicitKeep = false;
+ let explicitUnique = false;
+ args.forEach(a => {
+ switch (a[0]) {
+ case 'delim':
+ if (a[1].length) {
+ explicitDelim = true;
+ argObj.delim = a[1];
+ }
+ break;
+ case 'keep':
+ if (['id', 'name', 'charid'].includes(a[1].toLowerCase())) {
+ explicitKeep = true;
+ argObj.keep = a[1].toLowerCase();
+ }
+ break;
+ case 'mode':
+ if (modeArray.includes(a[1])) { argObj.mode = a[1]; }
+ break;
+ case 'group':
+ if (a[1].length) {
+ rxRet = /^([+=-])?(?:([^|\r\n]+)\|)?([^|\r\n]+)$/.exec(a[1]);
+ if (rxRet[1]) argObj.mode = rxRet[1];
+ if (rxRet[2]) argObj.char = getChar(rxRet[2], msg.playerid);
+ if (rxRet[3]) argObj.attr = rxRet[3];
+ }
+ break;
+ case 'count':
+ if (a[1].length) {
+ rxRet = /^([+=-])?(\d+)$/.exec(a[1]);
+ if (rxRet && rxRet[1]) { argObj.mode = rxRet[1]; }
+ if (rxRet && rxRet[2] && isNum(rxRet[2])) { argObj.count = parseInt(rxRet[2]); }
+ }
+ break;
+ case 'target':
+ if (a[1].length) { argObj.targets.push(a[1]); }
+ break;
+ case 'menu':
+ argObj.menu = getChar(a[1], msg.playerid);
+ break;
+ case 'swapdelim':
+ if (a[1].length) {
+ argObj.swapdelim = a[1];
+ }
+ break;
+ case 'report':
+ if (!posValues.includes(a[1])) {
+ argObj.report = false;
+ }
+ break;
+ case 'unique':
+ if (!posValues.includes(a[1])) {
+ argObj.unique = false;
+ explicitUnique = true;
+ }
+ break;
+ case 'who':
+ if (a[1].length) {
+ argObj.who = getAgnostic(a[1]);
+ }
+ break;
+ default:
+ }
+ });
+
+
+ // CONFIRM CHARACTER AVAILABILITY =======================================
+ if ((argObj.count || argObj.targets.length) && (!argObj.char || !argObj.char.id)) {
+ if (argObj.who.speakerType === 'character') {
+ argObj.char = theSpeaker;
+ } else {
+ msgbox({
+ title: 'No Character Found',
+ msg: 'Either no character was supplied or no character matching the supplied criteria could be found.',
+ whisperto: getWhisperTo(theSpeaker.localName)
+ });
+ return;
+ }
+ }
+
+ // APPLY DATA TO STORAGE ATTRIBUTE ============================================
+ const retFuncs = {
+ id: (t) => t.id,
+ name: (t) => t.get('name'),
+ charid: (t) => t.get('represents')
+ };
+ if (argObj.char && argObj.char.id && argObj.attr && argObj.attr.length) {
+
+ let attr = obtainAttr(argObj.attr, argObj.char, msg.playerid);
+ let data;
+
+ if (!explicitDelim && attr.get('max').length && /\(delim:([^)]+)\)/.test(attr.get('max'))) {
+ argObj.delim = /\(delim:([^)]+)\)/.exec(attr.get('max'))[1];
+ }
+ if (!explicitKeep && attr.get('max').length && /\(keep:(id|name|charid)\)/i.test(attr.get('max'))) {
+ argObj.keep = /\(keep:([^)]+)\)/.exec(attr.get('max'))[1].toLowerCase();
+ }
+ if (!explicitUnique && attr.get('max').length && /\(unique:([^)]+)\)/i.test(attr.get('max'))) {
+ argObj.unique = /\(unique:([^)]+)\)/.exec(attr.get('max'))[1].toLowerCase();
+ }
+
+ if (argObj.swapdelim) {
+ data = [...(argObj.unique ? new Set([...attr.get('current').split(argObj.delim)]) : attr.get('current').split(argObj.delim))].filter(d => d && d.length);
+ argObj.delim = argObj.swapdelim;
+ attr.set({ current: data.join(argObj.delim), max: `${maxAttrPrefix}(delim:${argObj.delim})(keep:${argObj.keep})` });
+ msgbox({
+ title: 'Delimiter Swapped',
+ msg: `Changed delimiter for ${data.length} entries.`,
+ btn: Messenger.Button({ type: '!', elem: `!assemble --menu|${argObj.char.get('name')}`, label: 'Menu', css: [localCSS.btncss] }),
+ whisperto: getWhisperTo(argObj.who.localName)
+ });
+ }
+
+ data = [...(argObj.unique ? new Set(argObj.targets) : argObj.targets)]
+ .map(t => getToken(t))
+ .filter(t => t && t.id)
+ .map(t => retFuncs[argObj.keep](t));
+
+ if (data.length) {
+ switch (argObj.mode) {
+ case '+':
+ data = [...(argObj.unique ? new Set([...attr.get('current').split(argObj.delim), ...data]) : [...attr.get('current').split(argObj.delim), ...data])].filter(d => d && d.length);
+ break;
+ case '-':
+ data = attr.get('current').split(argObj.delim).filter(d => !data.includes(d));
+ break;
+ case '=':
+ default:
+ }
+
+ attr.set({ current: data.join(argObj.delim), max: `${maxAttrPrefix}(delim:${argObj.delim})(keep:${argObj.keep})(unique:${argObj.unique})` });
+
+ if (argObj.report) {
+ msgbox({
+ title: 'Process Completed',
+ msg: `${argObj.mode === '+' ? 'Added' :
+ argObj.mode === '-' ? 'Removed' : 'Overwrote with'} ${data.length} entries. Current contents are shown below.` +
+ html.tag('hr', '', { 'border-top-color': theme.primaryColor }) +
+ HE(attr.get('current')),
+ btn: Messenger.Button({ type: '!', elem: `!assemble --menu|${argObj.char.get('name')}`, label: 'Menu', css: [localCSS.btncss] }),
+ whisperto: getWhisperTo(argObj.who.localName)
+ });
+ }
+ }
+
+ // SEND NEW TARGETING MESSAGE =========================================
+ if (argObj.count) {
+
+ let actionText = {
+ '=': 'replace the contents of',
+ '+': 'be added to',
+ '-': 'be removed from'
+ };
+ let reconArgs = {
+ group: `--group|${argObj.char.get('name')}|${argObj.attr}`,
+ toArgs: (o = reconArgs) => Object.keys(o).filter(a => a !== 'toArgs').map(k => o[k]).join(' ')
+ };
+ if (argObj.delim !== ',') { reconArgs.delim = `--delim|'${argObj.delim}'`; }
+ if (argObj.keep !== 'id') { reconArgs.keep = `--keep|${argObj.keep}`; }
+ if (argObj.mode !== '=') { reconArgs.mode = `--mode|${argObj.mode}`; }
+ if (!argObj.report) { reconArgs.report = `--report|no`; }
+ if (!argObj.unique) { reconArgs.unique = `--unique|no`; }
+ reconArgs.targets = Array(argObj.count).fill().map((a, i) => `--target|@{target|Target ${i + 1}|token_id}`).join(' ');
+
+ let outbound = `Click the button to choose the ${argObj.count} target${argObj.count > 1 ? 's' : ''} that will ` +
+ `${actionText[argObj.mode]} the ${argObj.attr} attribute on ${argObj.char.get('name')}.`;
+ let btn = Messenger.Button({ label: ' Assemble! ', elem: `!assemble ${reconArgs.toArgs()}`, type: 'api', css: localCSS.btncss });
+ msgbox({ title: 'Targets Assemble!', msg: outbound, whisperto: getWhisperTo(argObj.who.localName), btn: btn });
+
+ }
+ }
+
+ // SEND MENU =========================================
+ if (argObj.menu && argObj.menu.id) {
+ let menuAttrs = findObjs({type: 'attribute', characterid: argObj.menu.id })
+ .filter(a => (new RegExp(`^${escapeRegExp(maxAttrPrefix)}`)).test(a.get('max')));
+
+ let hdr = html.tr(
+ html.th('ATTR', localCSS.textleft) + html.th('ACTIONS') // + html.th('REM') + html.th('OVER') + html.th('DELIM')
+ );
+ let rows = menuAttrs.map(a => {
+ let locDelim = a.get('max').length && /\(delim:([^)]+)\)/.test(a.get('max'))
+ ? argObj.delim = /\(delim:([^)]+)\)/.exec(a.get('max'))[1]
+ : argObj.delim;
+ let cmdStart = `!assemble --group|${argObj.menu.get('name')}|${a.get('name')} --delim|'${argObj.delim}' --keep|${argObj.keep} --unique|${argObj.unique}`;
+ return html.tr(
+ html.td(getTip(a.get('name'), `${a.get('current').split(argObj.delim).length} Entries`, HE(a.get('current'))), localCSS.textleft) +
+ html.td(
+ Messenger.Button({ type: '!', elem: `${cmdStart} --mode|+ --target|@{target|Target 1|token_id}`, label: html.tip('+1', 'Add one'), css: [localCSS.menubtn, localCSS.safe] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --count|+?{How many targets|1|2|3|4|5|6|7|8|9|10}`, label: html.tip('+?', 'Add by query'), css: [localCSS.menubtn, localCSS.safe] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --mode|- --target|@{target|Target 1|token_id}`, label: html.tip('-1', 'Remove one'), css: [localCSS.menubtn, localCSS.danger] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --count|-?{How many targets|1|2|3|4|5|6|7|8|9|10}`, label: html.tip('-?', 'Remove by query'), css: [localCSS.menubtn, localCSS.danger] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --count|=?{How many targets|1|2|3|4|5|6|7|8|9|10}`, label: html.tip('=?', 'Overwrite by query'), css: [localCSS.menubtn, localCSS.secondaryColor] }) +
+ Messenger.Button({ type: '!', elem: `!assemble --group|${argObj.menu.get('name')}|${a.get('name')} --swapdelim|'?{Enter new delimiter|${locDelim}}'`, label: html.tip('y', 'Swap delimiter'), css: [localCSS.buttonPictos] })
+ )
+ );
+ });
+ let ftr = html.tr(
+ html.tdcs(Messenger.Button({
+ type: '!',
+ elem: `!assemble --group|${argObj.menu.get('name')}|?{Name for attribute} --delim|'?{List delimiter|,}' --keep|?{Save info|Token ID,id|Name,name|Character ID,charid} --unique|?{Allow Duplicates?|No,yes|Yes,no} --count|=?{How many targets?|1|2|3|4|5|6|7|8|9|10}`,
+ label: 'Create New',
+ css: [localCSS.btncss]
+ }), 5, {'text-align': 'right'})
+ )
+ let tbl = html.table(hdr+rows.join('')+ftr,localCSS.textcenter);
+ msgbox({ title: argObj.menu.get('name'), msg: tbl, whisperto: getWhisperTo(argObj.who.localName) });
+ }
+ };
+
+ const registerEventHandlers = () => {
+ on('chat:message', handleInput);
+ };
+
+ const checkDependencies = (deps) => {
+ /* pass array of objects like
+ { name: 'ModName', version: '#.#.#' || '', mod: ModName || undefined, checks: [ [ExposedItem, type], [ExposedItem, type] ] }
+ */
+ const dependencyEngine = (deps) => {
+ const versionCheck = (mv, rv) => {
+ let modv = [...mv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ let reqv = [...rv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ return reqv.reduce((m, v, i) => {
+ if (m.pass || m.fail) return m;
+ if (i < 3) {
+ if (parseInt(modv[i]) > parseInt(reqv[i])) m.pass = true;
+ else if (parseInt(modv[i]) < parseInt(reqv[i])) m.fail = true;
+ } else {
+ // all betas are considered below the release they are attached to
+ if (reqv[i] === 0 && modv[i] === 0) m.pass = true;
+ else if (modv[i] === 0) m.pass = true;
+ else if (reqv[i] === 0) m.fail = true;
+ else if (parseInt(modv[i].slice(1)) >= parseInt(reqv[i].slice(1))) m.pass = true;
+ }
+ return m;
+ }, { pass: false, fail: false }).pass;
+ };
+
+ let result = { passed: true, failures: {}, optfailures: {} };
+ deps.forEach(d => {
+ let failObj = d.optional ? result.optfailures : result.failures;
+ if (!d.mod) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = 'Not found';
+ return;
+ }
+ if (d.version && d.version.length) {
+ if (!(API_Meta[d.name].version && API_Meta[d.name].version.length && versionCheck(API_Meta[d.name].version, d.version))) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = `Incorrect version. Required v${d.version}. ${API_Meta[d.name].version && API_Meta[d.name].version.length ? `Found v${API_Meta[d.name].version}` : 'Unable to tell version of current.'}`;
+ return;
+ }
+ }
+ d.checks.reduce((m, c) => {
+ if (!m.passed) return m;
+ let [pname, ptype] = c;
+ if (!d.mod.hasOwnProperty(pname) || typeof d.mod[pname] !== ptype) {
+ if (!d.optional) m.passed = false;
+ failObj[d.name] = `Incorrect version.`;
+ }
+ return m;
+ }, result);
+ });
+ return result;
+ };
+ let depCheck = dependencyEngine(deps);
+ let failures = '', contents = '', msg = '';
+ if (Object.keys(depCheck.optfailures).length) { // optional components were missing
+ failures = Object.keys(depCheck.optfailures).map(k => `• ${k} : ${depCheck.optfailures[k]}`).join(' ');
+ contents = `${apiproject} utilizies one or more other scripts for optional features, and works best with those scripts installed. You can typically find these optional scripts in the 1-click Mod Library: ${failures}`;
+ msg = `
`;
+ sendChat(apiproject, `/w gm ${msg}`);
+ }
+ if (!depCheck.passed) {
+ failures = Object.keys(depCheck.failures).map(k => `• ${k} : ${depCheck.failures[k]}`).join(' ');
+ contents = `${apiproject} requires other scripts to work. Please use the 1-click Mod Library to correct the listed problems: ${failures}`;
+ msg = ``;
+ sendChat(apiproject, `/w gm ${msg}`);
+ return false;
+ }
+ return true;
+ };
+
+ on('ready', () => {
+ versionInfo();
+ logsig();
+ let reqs = [
+ {
+ name: 'Messenger',
+ version: `1.0.2`,
+ mod: typeof Messenger !== 'undefined' ? Messenger : undefined,
+ checks: [['Button', 'function'], ['MsgBox', 'function'], ['HE', 'function'], ['Html', 'function'], ['Css', 'function']]
+ }
+ ];
+ if (!checkDependencies(reqs)) return;
+ html = Messenger.Html();
+ HE = Messenger.HE;
+
+ assureState();
+ registerEventHandlers();
+ });
+ return {};
+})();
+
+{ try { throw new Error(''); } catch (e) { API_Meta.Assemble.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Assemble.offset); } }
diff --git a/Assemble/Assemble.js b/Assemble/Assemble.js
new file mode 100644
index 000000000..0924e446d
--- /dev/null
+++ b/Assemble/Assemble.js
@@ -0,0 +1,746 @@
+/*
+=========================================================
+Name : Assemble
+GitHub :
+Roll20 Contact : timmaugh
+Version : 1.0.1
+Last Update : 5 DEC 2025
+=========================================================
+*/
+var API_Meta = API_Meta || {};
+API_Meta.Assemble = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 };
+{ try { throw new Error(''); } catch (e) { API_Meta.Assemble.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (12)); } }
+
+const Assemble = (() => { // eslint-disable-line no-unused-vars
+ const apiproject = 'Assemble';
+ const version = '1.0.1';
+ const schemaVersion = 0.1;
+ API_Meta[apiproject].version = version;
+ const vd = new Date(1764944902848);
+ const apilogo = `https://i.imgur.com/zcfdU8n.png`;
+
+ 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}`);
+ };
+ 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 checkInstall = () => {
+ if (!state.hasOwnProperty(apiproject) || state[apiproject].version !== schemaVersion) {
+ log(` > Updating ${apiproject} Schema to v${schemaVersion} <`);
+ switch (state[apiproject] && state[apiproject].version) {
+
+ case 0.1:
+ /* falls through */
+
+ case 'UpdateSchemaVersion':
+ state[apiproject].version = schemaVersion;
+ break;
+
+ default:
+ state[apiproject] = {
+ settings: {},
+ defaults: {},
+ version: schemaVersion
+ }
+ break;
+ }
+ }
+ };
+ let stateReady = false;
+ const assureState = () => {
+ if (!stateReady) {
+ checkInstall();
+ stateReady = true;
+ }
+ };
+ const manageState = { // eslint-disable-line no-unused-vars
+ reset: () => state[apiproject].settings = _.clone(state[apiproject].defaults),
+ clone: () => { return _.clone(state[apiproject].settings); },
+ set: (p, v) => state[apiproject].settings[p] = v,
+ get: (p) => { return state[apiproject].settings[p]; }
+ };
+
+ // ==================================================
+ // PRESENTATION
+ // ==================================================
+ let html = {};
+ let HE = () => { }; // eslint-disable-line no-unused-vars
+ const theme = {
+ primaryColor: '#23223F',
+ primaryTextColor: '#232323',
+ primaryTextBackground: '#ededed',
+ secondaryColor: '#607D8B',
+ baseFontFamily: 'Arial',
+ baseFontSize: '10pt',
+ headerFontFamily: 'Contrail One',
+ headerFontSize: '1.3em',
+ headerColor: 'white',
+ dangerColor: `#781718`, // red
+ infoColor: `#1A6675`, // carribean
+ safeColor: `#3E7A46`, // fern
+
+ }
+ const localCSS = {
+ containerCSS: {
+ 'margin-left': '-8px',
+ 'width': 'unset',
+ 'position': 'relative',
+ 'top': '-20px',
+ 'display': 'block'
+ },
+ msgheader: {
+ 'background-color': theme.primaryColor,
+ 'color': theme.headerColor,
+ 'font-size': theme.headerFontSize,
+ 'font-family': theme.headerFontFamily,
+ 'padding-left': '4px'
+ },
+ msgbody: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgfooter: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgheadercontent: {
+ 'display': 'table-cell',
+ 'vertical-align': 'middle',
+ 'padding': '4px 8px 4px 6px'
+ },
+ msgheaderlogodiv: {
+ 'display': 'table-cell',
+ 'max-height': '30px',
+ 'margin-right': '8px',
+ 'margin-top': '4px',
+ 'vertical-align': 'middle'
+ },
+ logoimg: {
+ 'background-color': 'transparent',
+ 'float': 'left',
+ 'border': 'none',
+ 'max-height': '30px'
+ },
+ boundingcss: {
+ 'background-color': theme.primaryTextBackground
+ },
+ inlineEmphasis: {
+ 'font-weight': 'bold'
+ },
+ btncss: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'text-decoration': 'none',
+ },
+ menubtn: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'width': '13px',
+ 'height': '14px',
+ 'line-height': '14px',
+ 'text-decoration': 'none',
+ 'text-align': 'center',
+ },
+ buttonPictos: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'margin': '0px 2px',
+ 'line-height': '14px',
+ 'font-family': 'pictos',
+ 'font-size': '12px',
+ 'text-align': 'center',
+ 'width': '13px',
+ 'height': '14px',
+ 'vertical-align': 'middle',
+ 'margin-top': '-2px',
+ 'text-decoration': 'none',
+ },
+ secondaryColor: {
+ 'background-color': theme.secondaryColor
+ },
+ danger: {
+ 'background-color': theme.dangerColor
+ },
+ safe: {
+ 'background-color': theme.safeColor
+ },
+ info: {
+ 'background-color': theme.infoColor
+ },
+ tipContainer: {
+ 'overflow': 'hidden',
+ 'width': '100%',
+ 'border': 'none',
+ 'max-width': '250px',
+ 'display': 'block'
+ },
+ tipBounding: {
+ 'border-radius': '10px',
+ 'border': '2px solid #000000',
+ 'display': 'table-cell',
+ 'width': '100%',
+ 'overflow': 'hidden',
+ 'font-family': theme.baseFontFamily,
+ 'font-size': theme.baseFontSize
+ },
+ tipHeaderLine: {
+ 'overflow': 'hidden',
+ 'display': 'table',
+ 'background-color': theme.primaryColor,
+ 'width': '100%'
+ },
+ tipLogoSpan: {
+ 'display': 'table-cell',
+ 'overflow': 'hidden',
+ 'vertical-align': 'middle',
+ 'width': '40px'
+ },
+ tipLogoImg: {
+ 'margin-left': '3px',
+ 'background-image': `url('${apilogo}')`,
+ 'background-repeat': 'no-repeat',
+ 'backgound-size': 'contain',
+ 'width': '37px',
+ 'height': '37px',
+ 'display': 'inline-block',
+ 'background-size': '35px',
+ 'vertical-align': 'middle'
+ },
+ tipContentLine: {
+ 'overflow': 'hidden',
+ 'display': 'table',
+ 'background-color': theme.primaryTextBackground,
+ 'width': '100%'
+ },
+ tipContent: {
+ 'display': 'table-cell',
+ 'overflow': 'hidden',
+ 'padding': '5px 8px',
+ 'text-align': 'left',
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ tipHeaderTitle: {
+ 'display': 'table-cell',
+ 'overflow': 'hidden',
+ 'padding': '5px 8px',
+ 'text-align': 'left',
+ 'vertical-align': 'middle',
+ 'color': theme.headerColor,
+ 'font-size': theme.headerFontSize,
+ 'font-family': theme.headerFontFamily
+ },
+ textleft: {
+ 'text-align': 'left'
+ },
+ textright: {
+ 'text-align': 'right'
+ },
+ textcenter: {
+ 'text-align': 'center'
+ }
+ }
+ const msgbox = ({
+ msg: msg = '',
+ title: title = '',
+ headercss: headercss = localCSS.msgheader,
+ bodycss: bodycss = localCSS.msgbody,
+ footercss: footercss = localCSS.msgfooter,
+ sendas: sendas = 'Assemble',
+ whisperto: whisperto = '',
+ footer: footer = '',
+ btn: btn = '',
+ } = {}) => {
+ if (title) title = html.div(html.div(html.img(apilogo, 'Assemble Logo', localCSS.logoimg), localCSS.msgheaderlogodiv) + html.div(title, localCSS.msgheadercontent), {});
+ Messenger.MsgBox({ msg: msg, title: title, bodycss: bodycss, sendas: sendas, whisperto: whisperto, footer: footer, btn: btn, headercss: headercss, footercss: footercss, boundingcss: localCSS.boundingcss, containercss: localCSS.containerCSS, noarchive: true });
+ };
+ const getTip = (label, header = 'Info', contents, contentcss = {}) => {
+ let contentCSS = { ...localCSS.tipContent, ...contentcss };
+ return html.tip(
+ label,
+ html.span( // container
+ html.span( // bounding
+ html.span( // header line
+ html.span( // left (logo)
+ html.span('', localCSS.tipLogoImg),
+ localCSS.tipLogoSpan) +
+ html.span( // right (content)
+ header,
+ localCSS.tipHeaderTitle),
+ localCSS.msgheader, localCSS.tipHeaderLine) +
+ html.span( // content line
+ html.span( // content cell
+ contents,
+ contentCSS),
+ localCSS.tipContentLine),
+ localCSS.tipBounding),
+ localCSS.tipContainer),
+ { 'display': 'inline-block' }
+ );
+ };
+
+ const getWhisperTo = (who) => who.toLowerCase() === 'api'
+ ? 'gm'
+ : who.toLowerCase() === 'all'
+ ? ''
+ : who.replace(/\s\(gm\)$/i, '');
+ // ==================================================
+ // UTILITIES
+ // ==================================================
+ const escapeRegExp = (string) => { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); };
+ const getAgnostic = query => {
+ let o = findObjs({ type: 'character' }).filter(c => c.get('name') === query || c.id === query)[0] ||
+ findObjs({ type: 'player', displayname: query })[0];
+ if (o) {
+ o.speakerType = o.get('type');
+ o.localName = o.get('name') || o.get('displayname');
+ o.chatSpeaker = o.speakerType + '|' + o.id;
+ } else if (query.toLowerCase() === 'all') {
+ o = { id: undefined, type: 'all', localName: 'all', speakerType: 'all', chatSpeaker: 'all', get: () => { return 'all'; } };
+ } else {
+ o = { id: undefined, type: 'gm', localName: 'gm', speakerType: 'gm', chatSpeaker: 'gm', get: () => { return 'gm'; } };
+ }
+ return o;
+ };
+ const getTheSpeaker = msg => {
+ let speaking;
+ if (['API', ''].includes(msg.who)) {
+ speaking = { id: undefined, type: 'API', localName: 'API', speakerType: 'API', chatSpeaker: 'API', get: () => { return 'API'; } };
+ } else {
+ let characters = findObjs({ type: 'character' });
+ characters.forEach(c => { if (c.get('name') === msg.who) speaking = c; });
+
+ if (speaking) {
+ speaking.speakerType = "character";
+ speaking.localName = speaking.get("name");
+ } else {
+ speaking = getObj('player', msg.playerid);
+ speaking.speakerType = "player";
+ speaking.localName = speaking.get("displayname");
+ }
+ speaking.chatSpeaker = speaking.speakerType + '|' + speaking.id;
+ }
+ return speaking;
+ };
+
+ const isNum = (v) => +v === +v;
+
+ const getChar = (query, pid) => { // find a character where query is an identifying piece of information (id, name, or token id)
+ let character;
+ if (typeof query !== 'string') return character;
+ let qrx = new RegExp(escapeRegExp(query), 'i');
+ let charsIControl = findObjs({ type: 'character' });
+ charsIControl = playerIsGM(pid) || manageState.get('playerscanids') ? charsIControl : charsIControl.filter(c => {
+ return c.get('controlledby').split(',').reduce((m, p) => {
+ return m || p === 'all' || p === pid;
+ }, false)
+ });
+ character = charsIControl.filter(c => c.id === query)[0] ||
+ charsIControl.filter(c => c.id === (getObj('graphic', query) || { get: () => { return '' } }).get('represents'))[0] ||
+ charsIControl.filter(c => c.get('name') === query)[0] ||
+ charsIControl.filter(c => {
+ qrx.lastIndex = 0;
+ return qrx.test(c.get('name'));
+ })[0];
+ return character;
+ };
+ const getToken = (info, pgid = '') => {
+ let token = findObjs({ type: 'graphic', subtype: 'token', id: info })[0] ||
+ findObjs({ type: 'graphic', subtype: 'card', id: info })[0] ||
+ findObjs({ type: 'graphic', subtype: 'token', name: info, pageid: pgid })[0] ||
+ findObjs({ type: 'graphic', subtype: 'token', pageid: pgid })
+ .filter(t => t.get('represents').length && findObjs({ type: 'character', id: t.get('represents') })[0].get('name') === info)[0];
+ if (!token) {
+ let tokensOfName = findObjs({ type: 'graphic', subtype: 'token', name: info });
+ if (tokensOfName.length === 1) {
+ token = tokensOfName[0];
+ }
+ }
+ return token;
+ };
+ const getAttr = (aname, char, pid) => {
+ if (!(aname && aname.length) || !char) return;
+ if (!char.id) char = getChar(char, pid);
+ return findObjs({ type: 'attribute', characterid: char.id })
+ .filter(a => a.get('name') === aname)[0];
+ };
+ const obtainAttr = (aname, char, pid, create = true) => {
+ if (!(aname && aname.length) || !char) return;
+ if (!char.id) char = getChar(char, pid);
+ let attr = getAttr(aname, char, pid);
+ if (!attr && create) attr = createObj('attribute', { characterid: char.id, name: aname });
+ return attr;
+ };
+
+ // ==================================================
+ // DEFINITIONS & TABLES
+ // ==================================================
+ const posValues = ['yes', 'y', 'true', 't', 'keith', 'yep'];
+ const maxAttrPrefix = `Assemble Attr: `;
+
+ // ==================================================
+ // HANDLE INPUT
+ // ==================================================
+ const handleInput = (msg) => {
+ /**
+ * !assemble --group|[=+-] --delim| --count|[=+-]# --keep|[id,name,charid] --mode|[=,+,-] --target| --target|
+ * !assemble --menu|character
+ * !assemble --swapdelim| --group|
+ */
+ if (msg.type !== 'api' || !/^!assemble\b/.test(msg.content)) { return; }
+
+ let args = msg.content
+ .split(/\s+--/)
+ .slice(1)
+ .map(a => a.split(/[#=|](.*)/))
+ .map(a => [a[0], /^(['`"])(.*)\1$/.test(a[1]) ? /^(['`"])(.*)\1$/.exec(a[1])[2] : a[1]])
+ .filter(a => a[1] && a[1].length);
+
+ let theSpeaker = getTheSpeaker(msg);
+
+ let argObj = {
+ delim: ',',
+ keep: 'id',
+ mode: '=', //overwrite
+ char: undefined,
+ attr: 'targets',
+ count: 0,
+ targets: [],
+ menu: [],
+ swapdelim: undefined,
+ report: true,
+ unique: true,
+ who: theSpeaker
+ };
+ let modeArray = ['=', '+', '-'];
+ let rxRet;
+ let explicitDelim = false;
+ let explicitKeep = false;
+ let explicitUnique = false;
+ args.forEach(a => {
+ switch (a[0]) {
+ case 'delim':
+ if (a[1].length) {
+ explicitDelim = true;
+ argObj.delim = a[1];
+ }
+ break;
+ case 'keep':
+ if (['id', 'name', 'charid'].includes(a[1].toLowerCase())) {
+ explicitKeep = true;
+ argObj.keep = a[1].toLowerCase();
+ }
+ break;
+ case 'mode':
+ if (modeArray.includes(a[1])) { argObj.mode = a[1]; }
+ break;
+ case 'group':
+ if (a[1].length) {
+ rxRet = /^([+=-])?(?:([^|\r\n]+)\|)?([^|\r\n]+)$/.exec(a[1]);
+ if (rxRet[1]) argObj.mode = rxRet[1];
+ if (rxRet[2]) argObj.char = getChar(rxRet[2], msg.playerid);
+ if (rxRet[3]) argObj.attr = rxRet[3];
+ }
+ break;
+ case 'count':
+ if (a[1].length) {
+ rxRet = /^([+=-])?(\d+)$/.exec(a[1]);
+ if (rxRet && rxRet[1]) { argObj.mode = rxRet[1]; }
+ if (rxRet && rxRet[2] && isNum(rxRet[2])) { argObj.count = parseInt(rxRet[2]); }
+ }
+ break;
+ case 'target':
+ if (a[1].length) { argObj.targets.push(a[1]); }
+ break;
+ case 'menu':
+ argObj.menu = getChar(a[1], msg.playerid);
+ break;
+ case 'swapdelim':
+ if (a[1].length) {
+ argObj.swapdelim = a[1];
+ }
+ break;
+ case 'report':
+ if (!posValues.includes(a[1])) {
+ argObj.report = false;
+ }
+ break;
+ case 'unique':
+ if (!posValues.includes(a[1])) {
+ argObj.unique = false;
+ explicitUnique = true;
+ }
+ break;
+ case 'who':
+ if (a[1].length) {
+ argObj.who = getAgnostic(a[1]);
+ }
+ break;
+ default:
+ }
+ });
+
+
+ // CONFIRM CHARACTER AVAILABILITY =======================================
+ if ((argObj.count || argObj.targets.length) && (!argObj.char || !argObj.char.id)) {
+ if (argObj.who.speakerType === 'character') {
+ argObj.char = theSpeaker;
+ } else {
+ msgbox({
+ title: 'No Character Found',
+ msg: 'Either no character was supplied or no character matching the supplied criteria could be found.',
+ whisperto: getWhisperTo(theSpeaker.localName)
+ });
+ return;
+ }
+ }
+
+ // APPLY DATA TO STORAGE ATTRIBUTE ============================================
+ const retFuncs = {
+ id: (t) => t.id,
+ name: (t) => t.get('name'),
+ charid: (t) => t.get('represents')
+ };
+ if (argObj.char && argObj.char.id && argObj.attr && argObj.attr.length) {
+
+ let attr = obtainAttr(argObj.attr, argObj.char, msg.playerid);
+ let data;
+
+ if (!explicitDelim && attr.get('max').length && /\(delim:([^)]+)\)/.test(attr.get('max'))) {
+ argObj.delim = /\(delim:([^)]+)\)/.exec(attr.get('max'))[1];
+ }
+ if (!explicitKeep && attr.get('max').length && /\(keep:(id|name|charid)\)/i.test(attr.get('max'))) {
+ argObj.keep = /\(keep:([^)]+)\)/.exec(attr.get('max'))[1].toLowerCase();
+ }
+ if (!explicitUnique && attr.get('max').length && /\(unique:([^)]+)\)/i.test(attr.get('max'))) {
+ argObj.unique = /\(unique:([^)]+)\)/.exec(attr.get('max'))[1].toLowerCase();
+ }
+
+ if (argObj.swapdelim) {
+ data = [...(argObj.unique ? new Set([...attr.get('current').split(argObj.delim)]) : attr.get('current').split(argObj.delim))].filter(d => d && d.length);
+ argObj.delim = argObj.swapdelim;
+ attr.set({ current: data.join(argObj.delim), max: `${maxAttrPrefix}(delim:${argObj.delim})(keep:${argObj.keep})` });
+ msgbox({
+ title: 'Delimiter Swapped',
+ msg: `Changed delimiter for ${data.length} entries.`,
+ btn: Messenger.Button({ type: '!', elem: `!assemble --menu|${argObj.char.get('name')}`, label: 'Menu', css: [localCSS.btncss] }),
+ whisperto: getWhisperTo(argObj.who.localName)
+ });
+ }
+
+ data = [...(argObj.unique ? new Set(argObj.targets) : argObj.targets)]
+ .map(t => getToken(t))
+ .filter(t => t && t.id)
+ .map(t => retFuncs[argObj.keep](t));
+
+ if (data.length) {
+ switch (argObj.mode) {
+ case '+':
+ data = [...(argObj.unique ? new Set([...attr.get('current').split(argObj.delim), ...data]) : [...attr.get('current').split(argObj.delim), ...data])].filter(d => d && d.length);
+ break;
+ case '-':
+ data = attr.get('current').split(argObj.delim).filter(d => !data.includes(d));
+ break;
+ case '=':
+ default:
+ }
+
+ attr.set({ current: data.join(argObj.delim), max: `${maxAttrPrefix}(delim:${argObj.delim})(keep:${argObj.keep})(unique:${argObj.unique})` });
+
+ if (argObj.report) {
+ msgbox({
+ title: 'Process Completed',
+ msg: `${argObj.mode === '+' ? 'Added' :
+ argObj.mode === '-' ? 'Removed' : 'Overwrote with'} ${data.length} entries. Current contents are shown below.` +
+ html.tag('hr', '', { 'border-top-color': theme.primaryColor }) +
+ HE(attr.get('current')),
+ btn: Messenger.Button({ type: '!', elem: `!assemble --menu|${argObj.char.get('name')}`, label: 'Menu', css: [localCSS.btncss] }),
+ whisperto: getWhisperTo(argObj.who.localName)
+ });
+ }
+ }
+
+ // SEND NEW TARGETING MESSAGE =========================================
+ if (argObj.count) {
+
+ let actionText = {
+ '=': 'replace the contents of',
+ '+': 'be added to',
+ '-': 'be removed from'
+ };
+ let reconArgs = {
+ group: `--group|${argObj.char.get('name')}|${argObj.attr}`,
+ toArgs: (o = reconArgs) => Object.keys(o).filter(a => a !== 'toArgs').map(k => o[k]).join(' ')
+ };
+ if (argObj.delim !== ',') { reconArgs.delim = `--delim|'${argObj.delim}'`; }
+ if (argObj.keep !== 'id') { reconArgs.keep = `--keep|${argObj.keep}`; }
+ if (argObj.mode !== '=') { reconArgs.mode = `--mode|${argObj.mode}`; }
+ if (!argObj.report) { reconArgs.report = `--report|no`; }
+ if (!argObj.unique) { reconArgs.unique = `--unique|no`; }
+ reconArgs.targets = Array(argObj.count).fill().map((a, i) => `--target|@{target|Target ${i + 1}|token_id}`).join(' ');
+
+ let outbound = `Click the button to choose the ${argObj.count} target${argObj.count > 1 ? 's' : ''} that will ` +
+ `${actionText[argObj.mode]} the ${argObj.attr} attribute on ${argObj.char.get('name')}.`;
+ let btn = Messenger.Button({ label: ' Assemble! ', elem: `!assemble ${reconArgs.toArgs()}`, type: 'api', css: localCSS.btncss });
+ msgbox({ title: 'Targets Assemble!', msg: outbound, whisperto: getWhisperTo(argObj.who.localName), btn: btn });
+
+ }
+ }
+
+ // SEND MENU =========================================
+ if (argObj.menu && argObj.menu.id) {
+ let menuAttrs = findObjs({type: 'attribute', characterid: argObj.menu.id })
+ .filter(a => (new RegExp(`^${escapeRegExp(maxAttrPrefix)}`)).test(a.get('max')));
+
+ let hdr = html.tr(
+ html.th('ATTR', localCSS.textleft) + html.th('ACTIONS') // + html.th('REM') + html.th('OVER') + html.th('DELIM')
+ );
+ let rows = menuAttrs.map(a => {
+ let locDelim = a.get('max').length && /\(delim:([^)]+)\)/.test(a.get('max'))
+ ? argObj.delim = /\(delim:([^)]+)\)/.exec(a.get('max'))[1]
+ : argObj.delim;
+ let cmdStart = `!assemble --group|${argObj.menu.get('name')}|${a.get('name')} --delim|'${argObj.delim}' --keep|${argObj.keep} --unique|${argObj.unique}`;
+ return html.tr(
+ html.td(getTip(a.get('name'), `${a.get('current').split(argObj.delim).length} Entries`, HE(a.get('current'))), localCSS.textleft) +
+ html.td(
+ Messenger.Button({ type: '!', elem: `${cmdStart} --mode|+ --target|@{target|Target 1|token_id}`, label: html.tip('+1', 'Add one'), css: [localCSS.menubtn, localCSS.safe] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --count|+?{How many targets|1|2|3|4|5|6|7|8|9|10}`, label: html.tip('+?', 'Add by query'), css: [localCSS.menubtn, localCSS.safe] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --mode|- --target|@{target|Target 1|token_id}`, label: html.tip('-1', 'Remove one'), css: [localCSS.menubtn, localCSS.danger] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --count|-?{How many targets|1|2|3|4|5|6|7|8|9|10}`, label: html.tip('-?', 'Remove by query'), css: [localCSS.menubtn, localCSS.danger] }) +
+ Messenger.Button({ type: '!', elem: `${cmdStart} --count|=?{How many targets|1|2|3|4|5|6|7|8|9|10}`, label: html.tip('=?', 'Overwrite by query'), css: [localCSS.menubtn, localCSS.secondaryColor] }) +
+ Messenger.Button({ type: '!', elem: `!assemble --group|${argObj.menu.get('name')}|${a.get('name')} --swapdelim|'?{Enter new delimiter|${locDelim}}'`, label: html.tip('y', 'Swap delimiter'), css: [localCSS.buttonPictos] })
+ )
+ );
+ });
+ let ftr = html.tr(
+ html.tdcs(Messenger.Button({
+ type: '!',
+ elem: `!assemble --group|${argObj.menu.get('name')}|?{Name for attribute} --delim|'?{List delimiter|,}' --keep|?{Save info|Token ID,id|Name,name|Character ID,charid} --unique|?{Allow Duplicates?|No,yes|Yes,no} --count|=?{How many targets?|1|2|3|4|5|6|7|8|9|10}`,
+ label: 'Create New',
+ css: [localCSS.btncss]
+ }), 5, {'text-align': 'right'})
+ )
+ let tbl = html.table(hdr+rows.join('')+ftr,localCSS.textcenter);
+ msgbox({ title: argObj.menu.get('name'), msg: tbl, whisperto: getWhisperTo(argObj.who.localName) });
+ }
+ };
+
+ const registerEventHandlers = () => {
+ on('chat:message', handleInput);
+ };
+
+ const checkDependencies = (deps) => {
+ /* pass array of objects like
+ { name: 'ModName', version: '#.#.#' || '', mod: ModName || undefined, checks: [ [ExposedItem, type], [ExposedItem, type] ] }
+ */
+ const dependencyEngine = (deps) => {
+ const versionCheck = (mv, rv) => {
+ let modv = [...mv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ let reqv = [...rv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ return reqv.reduce((m, v, i) => {
+ if (m.pass || m.fail) return m;
+ if (i < 3) {
+ if (parseInt(modv[i]) > parseInt(reqv[i])) m.pass = true;
+ else if (parseInt(modv[i]) < parseInt(reqv[i])) m.fail = true;
+ } else {
+ // all betas are considered below the release they are attached to
+ if (reqv[i] === 0 && modv[i] === 0) m.pass = true;
+ else if (modv[i] === 0) m.pass = true;
+ else if (reqv[i] === 0) m.fail = true;
+ else if (parseInt(modv[i].slice(1)) >= parseInt(reqv[i].slice(1))) m.pass = true;
+ }
+ return m;
+ }, { pass: false, fail: false }).pass;
+ };
+
+ let result = { passed: true, failures: {}, optfailures: {} };
+ deps.forEach(d => {
+ let failObj = d.optional ? result.optfailures : result.failures;
+ if (!d.mod) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = 'Not found';
+ return;
+ }
+ if (d.version && d.version.length) {
+ if (!(API_Meta[d.name].version && API_Meta[d.name].version.length && versionCheck(API_Meta[d.name].version, d.version))) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = `Incorrect version. Required v${d.version}. ${API_Meta[d.name].version && API_Meta[d.name].version.length ? `Found v${API_Meta[d.name].version}` : 'Unable to tell version of current.'}`;
+ return;
+ }
+ }
+ d.checks.reduce((m, c) => {
+ if (!m.passed) return m;
+ let [pname, ptype] = c;
+ if (!d.mod.hasOwnProperty(pname) || typeof d.mod[pname] !== ptype) {
+ if (!d.optional) m.passed = false;
+ failObj[d.name] = `Incorrect version.`;
+ }
+ return m;
+ }, result);
+ });
+ return result;
+ };
+ let depCheck = dependencyEngine(deps);
+ let failures = '', contents = '', msg = '';
+ if (Object.keys(depCheck.optfailures).length) { // optional components were missing
+ failures = Object.keys(depCheck.optfailures).map(k => `• ${k} : ${depCheck.optfailures[k]}`).join(' ');
+ contents = `${apiproject} utilizies one or more other scripts for optional features, and works best with those scripts installed. You can typically find these optional scripts in the 1-click Mod Library: ${failures}`;
+ msg = ``;
+ sendChat(apiproject, `/w gm ${msg}`);
+ }
+ if (!depCheck.passed) {
+ failures = Object.keys(depCheck.failures).map(k => `• ${k} : ${depCheck.failures[k]}`).join(' ');
+ contents = `${apiproject} requires other scripts to work. Please use the 1-click Mod Library to correct the listed problems: ${failures}`;
+ msg = ``;
+ sendChat(apiproject, `/w gm ${msg}`);
+ return false;
+ }
+ return true;
+ };
+
+ on('ready', () => {
+ versionInfo();
+ logsig();
+ let reqs = [
+ {
+ name: 'Messenger',
+ version: `1.0.2`,
+ mod: typeof Messenger !== 'undefined' ? Messenger : undefined,
+ checks: [['Button', 'function'], ['MsgBox', 'function'], ['HE', 'function'], ['Html', 'function'], ['Css', 'function']]
+ }
+ ];
+ if (!checkDependencies(reqs)) return;
+ html = Messenger.Html();
+ HE = Messenger.HE;
+
+ assureState();
+ registerEventHandlers();
+ });
+ return {};
+})();
+
+{ try { throw new Error(''); } catch (e) { API_Meta.Assemble.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Assemble.offset); } }
diff --git a/Assemble/script.json b/Assemble/script.json
new file mode 100644
index 000000000..dbacfb6e7
--- /dev/null
+++ b/Assemble/script.json
@@ -0,0 +1,14 @@
+{
+ "name": "Assemble",
+ "script": "Assemble.js",
+ "version": "1.0.1",
+ "description": "# Assemble\r\nThis API is designed to automate the selecting and usage of targeting groups, allowing for more streamlined abilities to be created. It fills the gap on Roll20 where the number of targeting constructions required in a command line (i.e., @{target|token_id} ) cannot both be provided and have those constructions resolve in the same message. In other words, this helps streamline the process of gathering @{target}s so that other command lines can use the collected lists. More information is in the [forum thread](https://app.roll20.net/forum/post/12577859/script-assemble).",
+ "authors": "timmaugh",
+ "roll20userid": "5962076",
+ "useroptions": [],
+ "dependencies": [],
+ "modifies": {
+ },
+ "conflicts": [],
+ "previousversions": []
+}
\ No newline at end of file
diff --git a/SelectManager/1.1.11/SelectManager.js b/SelectManager/1.1.11/SelectManager.js
new file mode 100644
index 000000000..120419faa
--- /dev/null
+++ b/SelectManager/1.1.11/SelectManager.js
@@ -0,0 +1,1156 @@
+/*
+=========================================================
+Name : SelectManager
+GitHub : https://github.com/TimRohr22/Cauldron/tree/master/SelectManager
+Roll20 Contact : timmaugh && The Aaron
+Version : 1.1.11
+Last Update : 13 OCT 2025
+=========================================================
+*/
+var API_Meta = API_Meta || {};
+API_Meta.SelectManager = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 };
+{ try { throw new Error(''); } catch (e) { API_Meta.SelectManager.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (12)); } }
+
+const SelectManager = (() => { //eslint-disable-line no-unused-vars
+ // ==================================================
+ // VERSION
+ // ==================================================
+ const apiproject = 'SelectManager';
+ const version = '1.1.11';
+ const schemaVersion = 0.4;
+ const apilogo = 'https://i.imgur.com/ewyOzMU.png';
+ const apilogoalt = 'https://i.imgur.com/3U8c9rE.png'
+ API_Meta[apiproject].version = version;
+ const vd = new Date(1760358544555);
+ 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:
+ state[apiproject].settings = {
+ playerscanids: false
+ };
+ if (state[apiproject].hasOwnProperty('autoinsert')) state[apiproject].settings.autoinsert = [...state[apiproject].autoinsert];
+ else state[apiproject].settings.autoinsert = ['selected'];
+ state[apiproject].defaults = {
+ autoinsert: ['selected'],
+ playerscanids: false
+ };
+ delete state[apiproject].autoinsert;
+ /* falls through */
+ case 0.2:
+ state[apiproject].settings.knownsenders = ['CRL'];
+ state[apiproject].defaults.knownsenders = ['CRL'];
+ /* falls through */
+ case 0.3:
+ state[apiproject].settings.show04message = true;
+ state[apiproject].defaults.show04message = true;
+ /* falls through */
+ case 'UpdateSchemaVersion':
+ state[apiproject].version = schemaVersion;
+ break;
+
+ default:
+ state[apiproject] = {
+ version: schemaVersion,
+ settings: {
+ autoinsert: ['selected'],
+ playerscanids: false,
+ knownsenders: ['CRL'],
+ show03message: true
+ },
+ defaults: {
+ autoinsert: ['selected'],
+ playerscanids: false,
+ knownsenders: ['CRL'],
+ show03message: true
+ }
+ };
+ break;
+ }
+ }
+ };
+ const manageState = { // eslint-disable-line no-unused-vars
+ reset: () => state[apiproject].settings = _.clone(state[apiproject].defaults),
+ set: (p, v) => state[apiproject].settings[p] = v,
+ get: (p) => { return state[apiproject].settings[p]; }
+ };
+
+ 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 generateUUID = (() => {
+ let a = 0;
+ let b = [];
+
+ return () => {
+ let c = (new Date()).getTime() + 0;
+ let f = 7;
+ let e = new Array(8);
+ let d = c === a;
+ a = c;
+ for (; 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 escapeRegExp = (string) => { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); };
+ const RX = (() => {
+ const esRE = (s) => s.replace(/(\\|\/|\[|\]|\(|\)|\{|\}|\?|\+|\*|\||\.|\^|\$)/g, '\\$1');
+ const entities = {
+ '*': { detect: /\*/, rx: /\*/, rep: '.*?' },
+ '?': { detect: /\?/, rx: /\?/, rep: '.' },
+ // '?': { detect: /.\?/, rx: /(.)\?/, rep: '$1?'}
+ };
+ const rxkeys = (k) => entities[k].detect.source;
+ const getSource = (s) => {
+ let rxsource = '';
+ let rxflags = '';
+ let ret;
+ const rxpattern = /^\/(?.*?)\/(?(?:g|i|m|s|u|y){0,6})$/i;
+ if (rxpattern.test(s)) {
+ ret = rxpattern.exec(s);
+ rxsource = ret.groups.source;
+ rxflags = ret.groups.flags || '';
+ } else {
+ rxsource = ['^',
+ ...s.split(new RegExp(`(${Object.keys(entities).map(rxkeys).join('|')})`))
+ .map(p => {
+ return Object.keys(entities).reduce((m, k) => {
+ let rx = new RegExp(`^${entities[k].rx.source}$`);
+ if (typeof m === 'undefined' && rx.test(p)) {
+ m = p.replace(rx, entities[k].rep);
+ }
+ return m;
+ }, undefined) || esRE(p);
+ }),
+ '$'
+ ].join('');
+ rxflags = 'gi';
+ }
+ return new RegExp(rxsource, rxflags);
+ };
+ return getSource;
+ })();
+ const playersCanUseIDs = () => manageState.get('playerscanids');
+ const getTheSpeaker = msg => {
+ let speaking;
+ if (['API', ''].includes(msg.who)) {
+ speaking = { id: undefined, type: 'API', localName: 'API', speakerType: 'API', chatSpeaker: 'API', get: () => { return 'API'; } };
+ } else {
+ let characters = findObjs({ type: 'character' });
+ characters.forEach(c => { if (c.get('name') === msg.who) speaking = c; });
+
+ if (speaking) {
+ speaking.speakerType = "character";
+ speaking.localName = speaking.get("name");
+ } else {
+ speaking = getObj('player', msg.playerid);
+ speaking.speakerType = "player";
+ speaking.localName = speaking.get("displayname");
+ }
+ speaking.chatSpeaker = speaking.speakerType + '|' + speaking.id;
+ }
+
+ return speaking;
+ };
+ const playerCanControl = (obj, playerid = 'any') => {
+ const playerInControlledByList = (list, playerid) => list.includes('all') || list.includes(playerid) || ('any' === playerid && list.length);
+ let players = obj.get('controlledby')
+ .split(/,/)
+ .filter(s => s.length);
+
+ if (playerInControlledByList(players, playerid)) {
+ return true;
+ }
+
+ if ('' !== obj.get('represents')) {
+ players = (getObj('character', obj.get('represents')) || { get: function () { return ''; } })
+ .get('controlledby').split(/,/)
+ .filter(s => s.length);
+ return playerInControlledByList(players, playerid);
+ }
+ return false;
+ };
+ const getPageForPlayer = (playerid) => {
+ let player = getObj('player', playerid);
+ if (playerIsGM(playerid)) {
+ return player.get('lastpage') || Campaign().get('playerpageid');
+ }
+
+ let psp = Campaign().get('playerspecificpages');
+ if (psp[playerid]) {
+ return psp[playerid];
+ }
+
+ return Campaign().get('playerpageid');
+ };
+ const getTokens = (query, pid, owner = true) => {
+ if (pid === 'API') pid = preservedMsgObj[maintrigger].playerid;
+ let pageid = getPageForPlayer(pid);
+ let qrx = RX(query);
+ let alltokens = [...findObjs({ type: 'graphic', pageid: pageid }), ...findObjs({ type: 'text', pageid: pageid }), ...findObjs({ type: 'path', pageid: pageid })]
+ .filter(t => t.get('layer') === 'objects' || playerIsGM(pid));
+ if (owner) {
+ alltokens = alltokens.filter(t => playerIsGM(pid) || playersCanUseIDs() || playerCanControl(t, pid));
+ }
+ let tokens = [(alltokens.filter(t => t.id === query)[0] ||
+ alltokens.filter(t => t.get('name') === query)[0])]
+ .filter(t => t);
+ if (!tokens.length) {
+ tokens = alltokens.filter(t => {
+ qrx.lastIndex = 0;
+ return qrx.test(typeof t.get('name') === 'undefined' ? '' : t.get('name'));
+ });
+ }
+ return tokens;
+ };
+
+ let html = {};
+ let css = {}; // eslint-disable-line no-unused-vars
+ let HE = () => { }; // eslint-disable-line no-unused-vars
+ const theme = {
+ primaryColor: '#E66B00',
+ primaryTextColor: '#232323',
+ primaryTextBackground: '#ededed'
+ }
+ const localCSS = {
+ msgheader: {
+ 'background-color': theme.primaryColor,
+ 'color': 'white',
+ 'font-size': '1.2em',
+ 'padding-left': '4px'
+ },
+ msgbody: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgfooter: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgheadercontent: {
+ 'display': 'table-cell',
+ 'vertical-align': 'middle',
+ 'padding': '4px 8px 4px 6px'
+ },
+ msgheaderlogodiv: {
+ 'display': 'table-cell',
+ 'max-height': '30px',
+ 'margin-right': '8px',
+ 'margin-top': '4px',
+ 'vertical-align': 'middle'
+ },
+ logoimg: {
+ 'background-color': 'transparent',
+ 'float': 'left',
+ 'border': 'none',
+ 'max-height': '30px'
+ },
+ boundingcss: {
+ 'background-color': theme.primaryTextBackground
+ },
+ inlineEmphasis: {
+ 'font-weight': 'bold'
+ },
+ button: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'min-width': '25px',
+ 'padding': '6px 8px'
+ }
+ }
+ const msgbox = ({
+ msg: msg = '',
+ title: title = '',
+ headercss: headercss = localCSS.msgheader,
+ bodycss: bodycss = localCSS.msgbody,
+ footercss: footercss = localCSS.msgfooter,
+ sendas: sendas = 'SelectManager',
+ whisperto: whisperto = '',
+ footer: footer = '',
+ btn: btn = '',
+ } = {}) => {
+ if (title) title = html.div(html.div(html.img(apilogoalt, 'SelectManager Logo', localCSS.logoimg), localCSS.msgheaderlogodiv) + html.div(title, localCSS.msgheadercontent), {});
+ Messenger.MsgBox({ msg: msg, title: title, bodycss: bodycss, sendas: sendas, whisperto: whisperto, footer: footer, btn: btn, headercss: headercss, footercss: footercss, boundingcss: localCSS.boundingcss, noarchive: true });
+ };
+
+ const getWhisperTo = (who) => who.toLowerCase() === 'api' ? 'gm' : who.replace(/\s\(gm\)$/i, '');
+ const handleConfig = msg => {
+ if (msg.type !== 'api' || !/^!smconfig/.test(msg.content)) return;
+ let recipient = getWhisperTo(msg.who);
+ if (!playerIsGM(msg.playerid)) {
+ msgbox({ title: 'GM Rights Required', msg: 'You must be a GM to perform that operation', whisperto: recipient });
+ return;
+ }
+ let cfgrx = /^(\+|-)(selected|who|playerid|playerscanids|acknowledge(\d+))$/i;
+ let changeObj = {
+ '+': 'enabled',
+ '-': 'disabled',
+ 'a': 'acknowledged'
+ };
+ let res;
+ let cfgTrack = {};
+ let message;
+ if (/^!smconfig\s+[^\s]/.test(msg.content)) {
+ msg.content.split(/\s+/).slice(1).forEach(a => {
+ res = cfgrx.exec(a);
+ if (!res) return;
+ if (res[2].toLowerCase() === 'playerscanids') {
+ manageState.set('playerscanids', (res[1] === '+'));
+ cfgTrack[res[2]] = res[1];
+ } else if (['selected', 'who', 'playerid'].includes(res[2].toLowerCase())) {
+ if (res[1] === '+') {
+ manageState.set('autoinsert', [...new Set([...manageState.get('autoinsert'), res[2].toLowerCase()])]);
+ cfgTrack[res[2]] = res[1];
+ } else {
+ manageState.set('autoinsert', manageState.get('autoinsert').filter(e => e !== res[2].toLowerCase()));
+ cfgTrack[res[2]] = res[1];
+ }
+ } else if (/^acknowledge\d+$/i.test(res[2])) {
+ manageState.set(`show${res[3]}message`, false);
+ cfgTrack[`Schema ${res[3]} Message`] = 'a';
+ }
+ });
+ let changes = Object.keys(cfgTrack).map(k => `${html.span(k, localCSS.inlineEmphasis)}: ${changeObj[cfgTrack[k]]}`).join(' ');
+ msgbox({ title: `SelectManager Config Changed`, msg: `You have made the following changes to the SelectManager configuration: ${changes}`, whisperto: recipient });
+ } else {
+ cfgTrack.playerscanids = `${html.span('playerscanids', localCSS.inlineEmphasis)}: ${manageState.get('playerscanids') ? 'enabled' : 'disabled'}`;
+ cfgTrack.autoinsert = ['selected', 'who', 'playerid'].map(k => `${html.span(k, localCSS.inlineEmphasis)}: ${manageState.get('autoinsert').includes(k) ? 'enabled' : 'disabled'}`).join(' ');
+ message = `SelectManager is currently configured as follows: ${cfgTrack.playerscanids} ${cfgTrack.autoinsert}`;
+ msgbox({ title: 'SelectManager Configuration', msg: message, whisperto: recipient });
+ }
+ };
+
+ const issueVersionUpdateMessages = () => {
+ let allCommands = [...findObjs({ type: 'macro' }), ...findObjs({ type: 'ability' })];
+
+ const show04Message = () => {
+ let affected = allCommands.filter(o => {
+ let cmd = o.get('action');
+ let locSelrx = /{&\s*(?:select|inject)\s+([^}]+?)\s*}/gi;
+ let found = false;
+ let res;
+ let items;
+ while (!found && (res = locSelrx.exec(cmd)) && res) {
+ found = !!(res[1].split(/\s*,\s*/)
+ .filter(item => oldmarkerrx.test(item)).length);
+ // .filter(item => /^(\+|-)/.test(item) && !/^(\+|-)(@.*|#.*|\*.*|((bar|max)(1|2|3){1})|((aura|color)(1|2){0,1})|layer|tip|gmnotes|type|pc|npc|pt|side)(\s|<|>|=|~|!|$)/.test(item)).length);
+ }
+ return found;
+ });
+
+ if (affected.length) {
+ let listAffected = affected.map(a => `${a.get('name')} (${a.get('type') === 'ability' ? `ability for ${getObj('character', a.get('characterid')).get('name')}` : 'macro'}) `).join('');
+ let message = html.p(`A small portion of SelectManager syntax is changing. A previous update made it possible to use status markers (either their presence or value) as a ` +
+ `condition for virtually selecting that token. For instance, testing a token for the presence of a status marker named "noble" would look like:+noble `) +
+ html.p(`This syntax allowed for "collisions" -- a situation where a marker might bear the name of one of the other keywords SelectManager looks for as ways to test the tokens: aura, bar1, npc, etc. ` +
+ `For instance, if you were playing in a game that had a status marker named "npc", then would the syntax +npc refer to the presence of the marker, or to the internal test ` +
+ `SelectManager uses to determine if a token is an npc?`) +
+ html.p(`With the v1.1.8 update, SelectManager can now use a similar syntax to test a token for the presence of character tags, increasing the possibility of these collisions (i.e., a tag ` +
+ `and a marker both named "noble"). Because of this, the syntax to test for a status marker is getting an update to allow for greater specificity. Going forward, ` +
+ `to test for a status marker on a token, you should simply preface the marker name with an asterisk (*) immediately following the "+" (for "should have") or "-" ` +
+ `(for "should not have"):+*noble +*noble > 2 `) +
+ html.p(`The previous syntax is still available for now, but is no longer supported and will be removed at some point in the future. You should take a moment to update commands ` +
+ `in your game that utilize the previous construction (without an asterisk). A quick scan of character abilities and macros in this game shows that the following list ` +
+ `might be commands where you have utilized the previous syntax:` +
+ ``);
+ //const button = ({ elem: elem = '', label: label = '', char: char = '', type: type = '%', css: css = Messenger.Css.button } = {}) => {
+
+ let button = Messenger.Button({ elem: `!smconfig +acknowledge04`, type: '!', label: `Don't Show Again`, css: localCSS.button, noarchive: true });
+ msgbox({ title: 'SelectManager Syntax Update', msg: message, whisperto: 'gm', btn: button });
+
+ // TODO: make sure chat message has opt-out for not getting the message again
+ } else {
+ manageState.set('show04message', false);
+ }
+ };
+
+ const messageSettings = {
+ show04message: show04Message
+ };
+
+ Object.keys(messageSettings).forEach(k => {
+ if (manageState.get(k)) { messageSettings[k](); }
+ });
+ };
+
+ const maintrigger = `${apiproject}-main`;
+ let preservedMsgObj = {
+ [maintrigger]: { selected: undefined, who: '', playerid: '' }
+ };
+
+ const condensereturn = (funcret, status, notes) => {
+ funcret.runloop = (status.includes('changed') || status.includes('unresolved'));
+ if (status.length) {
+ funcret.status = status.reduce((m, v) => {
+ switch (m) {
+ case 'unchanged':
+ m = v;
+ break;
+ case 'changed':
+ m = v === 'unresolved' ? v : m;
+ break;
+ case 'unresolved':
+ break;
+ }
+ return m;
+ });
+ }
+ funcret.notes = notes.join(' ');
+ return funcret;
+ };
+ const uniqueArrayByProp = (array, prop = 'id') => {
+ const set = new Set;
+ return array
+ .filter(o => typeof o !== 'undefined' && !set.has(o[prop]) && set.add(o[prop]));
+ };
+ let oldmarkerrx;
+ const decomposeStatuses = (list = '') => {
+ return list.split(/\s*,\s*/g).filter(s => s.length)
+ .reduce((m, s) => {
+ let origst = libTokenMarkers.getStatus(s.slice(0, /(@\d+$|:)/.test(s) ? /(@\d+$|:)/.exec(s).index : s.length));
+ let st = _.clone(origst);
+ if (!st) return m;
+ st.num = /^.+@0*(\d+)/.test(s) ? /^.+@0*(\d+)/.exec(s)[1] : '';
+ st.html = origst.getHTML();
+ st.url = st.url || '';
+ m.push(st);
+ return m;
+ }, []);
+ };
+ class StatusBlock {
+ constructor({ token: token = {}, msgId: msgId = generateUUID() } = {}) {
+ this.token = token;
+ this.msgId = msgId;
+ this.statuses = (decomposeStatuses(token.get('statusmarkers')) || []).reduce((m, s) => {
+ m[s.name] = m[s.name] || []
+ m[s.name].push(Object.assign({}, s, { is: 'yes' }));
+ return m;
+ }, {});
+ }
+ }
+
+ const tokenStatuses = {};
+ const getStatus = (token, query, msgId) => {
+ let rxret, status, index, modindex, statusblock;
+ if (!token) return;
+ // token = simpleObj(token);
+ // if (token && !token.hasOwnProperty('id')) token.id = token._id;
+ if (!tokenStatuses.hasOwnProperty(token.id) || tokenStatuses[token.id].msgId !== msgId) {
+ tokenStatuses[token.id] = new StatusBlock({ token: token, msgId: msgId });
+ }
+ rxret = /(?.+?)(?:\?(?\d+|all\+?))?$/.exec(query);
+ [status, index] = [rxret.groups.marker, rxret.groups.index];
+ if (!index) {
+ modindex = 1;
+ } else if (['all', 'all+'].includes(index.toLowerCase())) {
+ modindex = index.toLowerCase();
+ } else {
+ modindex = Number(index);
+ }
+ statusblock = tokenStatuses[token.id].statuses[status];
+ if (!statusblock || !statusblock.length) {
+ return { is: 'no', count: '0' };
+ };
+ switch (index) {
+ case 'all':
+ return statusblock.reduce((m, sm) => {
+ m.num = `${m.num || ''}${sm.num}`;
+ m.tag = m.tag || sm.tag;
+ m.url = m.url || sm.url;
+ m.html = m.html || sm.html;
+ m.is = 'yes';
+ m.count = m.count || statusblock.length;
+ return m;
+ }, {});
+ case 'all+':
+ return statusblock.reduce((m, sm) => {
+ m.num = `${Number(m.num || 0) + Number(sm.num)}`;
+ m.tag = m.tag || sm.tag;
+ m.url = m.url || sm.url;
+ m.html = m.html || sm.html;
+ m.is = 'yes';
+ m.count = m.count || statusblock.length;
+ return m;
+ }, {});
+ default:
+ if (statusblock.length >= modindex) {
+ return Object.assign({}, statusblock[modindex - 1], { count: index ? '1' : statusblock.length });
+ } else {
+ return { is: 'no', 'count': '0' };
+ }
+ }
+ };
+
+ const checkTicks = (s, check = ["'", "`", '"']) => {
+ if (typeof s !== 'string') return s;
+ return ((s.charAt(0) === s.charAt(s.length - 1)) && check.includes(s.charAt(0))) ? s.slice(1, s.length - 1) : s;
+ };
+ const isPlayerToken = (obj = { get: () => { return undefined; } }, pc = false) => {
+ let players;
+ if (!pc) {
+ players = obj.get('controlledby')
+ .split(/,/)
+ .filter(s => s.length);
+
+ if (players.includes('all') || players.filter((p) => !playerIsGM(p)).length) {
+ return true;
+ }
+ }
+
+ if ('' !== obj.get('represents')) {
+ players = (getObj('character', obj.get('represents')) || { get: function () { return ''; } })
+ .get('controlledby')
+ .split(/,/)
+ .filter(s => s.length);
+ return !!(players.includes('all') || players.filter((p) => !playerIsGM(p)).length);
+ }
+ return false;
+ };
+ const isNPC = (obj = { get: () => { return ''; } }) => {
+ let control = (
+ obj.get('represents') && obj.get('represents').length
+ ? getObj('character', obj.get('represents') || { get: function () { return ''; } })
+ : obj
+ )
+ .get('controlledby').split(/,/);
+ if (!control.length) return true;
+ return !control.filter(s => s.length && !playerIsGM(s)).length;
+ };
+ const isParty = (obj = { get: () => { return ''; } }) => {
+ let char = (
+ obj.get('represents') && obj.get('represents').length
+ ? getObj('character', obj.get('represents') || { get: function () { return ''; } })
+ : obj
+ );
+ return char.get('inParty');
+ };
+ const internalTestLib = {
+ 'int': (v) => +v === +v && parseInt(parseFloat(v, 10), 10) == v,
+ 'num': (v) => +v === +v,
+ 'tru': (v) => v == true
+ };
+ const typeProcessor = {
+ '=': (t) => t[0] == t[1],
+ '!=': (t) => t[0] != t[1],
+ '~': (t) => t[0].includes(t[1]),
+ '!~': (t) => !t[0].includes(t[1]),
+ '>': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) > (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ '>=': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) >= (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ '<': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) < (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ '<=': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) <= (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ 'in': (t) => {
+ let array = (/^\[?([^\]]+)\]?$/.exec(t[1])[1] || '').split(/\s*,\s*/);
+ return array.includes(t[0]);
+ }
+ }
+
+ const evaluateCriteria = (c, t, msgId) => {
+ let comp = [];
+ let tksetting;
+ let test = c.test;
+ let attrret = 'current'; // current or max
+ let attrval;
+ let attrres;
+ switch (c.type) {
+ case 'bar':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`bar${['1', '2', '3', '4'].includes(c.ident) ? c.ident : '1'}_value`), c.value];
+ }
+ break;
+ case 'max':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`bar${['1', '2', '3', '4'].includes(c.ident) ? c.ident : '1'}_max`), c.value];
+ }
+ break;
+ case 'aura':
+ if (test && test.length && c.value && !isNaN(c.value) && typeProcessor.hasOwnProperty(test)) { // testing radius of aura
+ tksetting = t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_radius`);
+ if (tksetting && tksetting.length) {
+ comp = [tksetting, c.value];
+ }
+ } else { // testing presence of aura
+ tksetting = t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_radius`);
+ comp = [tksetting && tksetting.length > 0, true];
+ test = '=';
+ }
+ break;
+ case 'color':
+ if (typeProcessor.hasOwnProperty(test)) {
+ tksetting = t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_radius`);
+ if (tksetting && tksetting.length) {
+ comp = [t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_color`), c.value];
+ }
+ }
+ break;
+ case 'gmnotes':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`gmnotes`), c.value];
+ }
+ break;
+ case 'tip':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`tooltip`), c.value];
+ }
+ break;
+ case 'layer':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`layer`), c.value];
+ }
+ break;
+ case 'marker':
+ tksetting = getStatus(t, c.ident, msgId);
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [tksetting.num, c.value];
+ } else { // testing presence of marker
+ test = '=';
+ comp = [tksetting.is === 'yes', true];
+ }
+ break;
+ case 'tag':
+ if (t.get('represents') && t.get('represents').length) {
+ let char = getObj('character', t.get('represents'));
+ if (char) { // testing presence of attribute
+ tksetting = JSON.parse(char.get('tags'));
+ test = '=';
+ comp = [tksetting.includes(c.ident), true];
+ }
+ }
+ break;
+ case 'attribute':
+ if (t.get('represents') && t.get('represents').length) {
+ attrres = /^(?[^.|#?]+?)(?:(?:\.|\?|#|\|)(?current|cur|c|max|m))?\s*$/i.exec(c.ident);
+ if (attrres.groups && attrres.groups.attrval && attrres.groups.attrval.length && ['max', 'm'].includes(attrres.groups.attrval)) {
+ attrret = 'max';
+ }
+ if (typeProcessor.hasOwnProperty(test)) {
+ attrval = (findObjs({ type: 'attribute', characterid: t.get('represents') }).filter(a => a.get('name') === attrres.groups.attr)[0] || { get: () => { return '' } }).get(attrret) || '';
+ comp = [attrval, c.value];
+ } else { // testing presence of attribute
+ test = '=';
+ comp = [findObjs({ type: 'attribute', characterid: t.get('represents') }).filter(a => a.get('name') === attrres.groups.attr).length > 0, true];
+ }
+ }
+ break;
+ case 'type':
+ if (typeProcessor.hasOwnProperty(test)) {
+ if (c.value === 'graphic') {
+ tksetting = t.get('type');
+ } else {
+ tksetting = t.get('type') === 'graphic' ? t.get('subtype') : t.get('type');
+ }
+ comp = [tksetting, c.value];
+ }
+ break;
+ case 'pc':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token' && t.get('layer') === 'objects') {
+ test = '=';
+ comp = [isPlayerToken(t, true), true];
+ }
+ break;
+ case 'npc':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token') {
+ test = '=';
+ comp = [isNPC(t), true];
+ }
+ break;
+ case 'pt':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token' && t.get('layer') === 'objects') {
+ test = '=';
+ comp = [isPlayerToken(t, true), false];
+ }
+ break;
+ case 'side':
+ if (typeProcessor.hasOwnProperty(test) && t.get('type') === 'graphic') {
+ tksetting = t.get('currentSide');
+ comp = [tksetting, c.value];
+ }
+ break;
+ case 'party':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token' && t.get('layer') === 'objects') {
+ test = '=';
+ comp = [isParty(t), true];
+ }
+ break;
+
+ break;
+ default:
+ return false;
+ }
+ if (!comp.length) return false;
+ let result = typeProcessor[test](comp);
+ return c.musthave ? result : !result;
+ };
+
+ class Criteria {
+ constructor({
+ type: type = '',
+ musthave: musthave = '',
+ ident: ident = '',
+ test: test = '',
+ value: value = ''
+ } = {}) {
+ this.type = type;
+ this.musthave = musthave;
+ this.ident = ident;
+ this.test = test;
+ this.value = value;
+ }
+ }
+ const injectrx = /(\()?{&\s*inject\s+([^}]+?)\s*}((?<=\({&\s*inject\s+([^}]+?)\s*})\)|\1)/gi;
+ const selectrx = /(\()?{&\s*select\s+([^}]+?)\s*}((?<=\({&\s*select\s+([^}]+?)\s*})\)|\1)/gi;
+ const criteriarx = /^(?\+|-)(?@|\*|#)?(?[^\s><=!~]+)(?:\s*$|\s*(?>=|<=|~|!~|=|!=|<|>|in(?=\s+\[[^\]]+\]))\s*(?.+)$)/;
+ const typeitemrx = /^(?bar|max|aura|color|layer|tip|gmnotes|type|pc|npc|pt|side|party)(?1|2|3|4)?(?<=(?:bar|max)\d|(?:aura|color)[1,2]|(?:layer|tip|gmnotes|type|pc|npc|pt|side|party))$/i;
+ const inject = (msg, status, msgId/*, notes*/) => {
+ const layerCriteria = (criteria) => {
+ return criteria.filter(c => c.type === 'layer').length ? true : false;
+ };
+ const caseLibrary = [
+ { rx: /^(\+|-)[^\s]+\s+in\s+\[$/i, terminator: ']' }
+ ];
+ const getGroups = (cmd, index = 0, groups = []) => {
+ const getNextGroup = (cmd, terminator = ',') => {
+ let s = '';
+ let bstop = false;
+ while (index <= cmd.length - 1 && !bstop) {
+ if (cmd.charAt(index) === terminator) {
+ if (terminator !== ',') {
+ s = `${s}${terminator}`;
+ index++;
+ }
+ bstop = true;
+ } else {
+ if (s.length || cmd.charAt(index) !== ' ') {
+ s = `${s}${cmd.charAt(index)}`;
+ }
+ index++;
+ for (const c of caseLibrary) {
+ c.rx.lastIndex = 0;
+ if (c.rx.test(s)) {
+ s = `${s}${getNextGroup(cmd, c.terminator)}`;
+ }
+ }
+ }
+ }
+ return s;
+ };
+ while (index <= cmd.length - 1) {
+ groups.push(getNextGroup(cmd));
+ index++;
+ }
+ return groups;
+ };
+ const unpackGroups = (array) => {
+ return array
+ .map(l => getTokens(l, msg.playerid))
+ .reduce((m, group) => {
+ m = [...m, ...group];
+ return m;
+ }, [])
+ .filter(t => typeof t !== 'undefined');
+ };
+ const replaceOps = (rx, rxtype) => {
+ rx.lastIndex = 0;
+ msg.content = msg.content.replace(rx, (m, padding, group) => {
+ if (rxtype === 'inject') {
+ msg.selected = msg.selected || [];
+ } else if (rxtype === 'select') {
+ msg.selected = [];
+ }
+ let identifiers = getGroups(group)
+ .reduce((m, v) => {
+ if (criteriarx.test(v) && !findObjs({ id: v }).length) {
+ let critres = criteriarx.exec(v);
+ let newcriteria = new Criteria({ musthave: (critres.groups.musthave === '+'), test: (critres.groups.test || ''), value: checkTicks((critres.groups.value || '')) });
+ if (critres.groups.attr && critres.groups.attr === '@') {
+ newcriteria.type = 'attribute';
+ newcriteria.ident = (critres.groups.typeitem || '');
+ } else if (critres.groups.attr && critres.groups.attr === '*') {
+ newcriteria.type = 'marker';
+ newcriteria.ident = (critres.groups.typeitem || '');
+ } else if (critres.groups.attr && critres.groups.attr === '#') {
+ newcriteria.type = 'tag';
+ newcriteria.ident = (critres.groups.typeitem || '');
+ } else if (typeitemrx.test(critres.groups.typeitem)) {
+ let ti_res = typeitemrx.exec(critres.groups.typeitem);
+ newcriteria.type = ti_res.groups.type;
+ newcriteria.ident = ti_res.groups.ident;
+ } else if (oldmarkerrx.test(v)) {
+ newcriteria.type = 'marker';
+ newcriteria.ident = critres.groups.typeitem;
+ } else {
+ m.selections.push(v);
+ }
+ m.criteria.push(newcriteria);
+ } else {
+ m.selections.push(v);
+ }
+ return m;
+ }, { criteria: [], selections: [] });
+ if (playerIsGM(msg.playerid) && !layerCriteria(identifiers.criteria)) {
+ identifiers.criteria.push(new Criteria({ type: 'layer', musthave: true, test: '=', value: 'objects' }));
+ }
+ identifiers.selections = uniqueArrayByProp(unpackGroups(identifiers.selections), 'id')
+ .filter(t => {
+ return identifiers.criteria.every(c => evaluateCriteria(c, t, msgId));
+ });
+
+ msg.selected = identifiers.selections
+ .map(t => { return { '_id': t.id, '_type': t.get('type') }; })
+ .reduce((m, t) => {
+ if (!m.map(mt => mt._id).includes(t._id)) {
+ m.push(t);
+ }
+ return m;
+ }, msg.selected);
+
+ status.push('changed');
+ return '';
+ });
+ };
+ let retResult = false;
+ // handle selections
+ if (selectrx.test(msg.content)) {
+ retResult = true;
+ replaceOps(selectrx, 'select');
+ }
+ // handle injections
+ if (injectrx.test(msg.content)) {
+ retResult = true;
+ replaceOps(injectrx, 'inject');
+ }
+ if (msg.selected && !msg.selected.length) delete msg.selected;
+ return retResult;
+ };
+
+ const dispatchForSelected = (trigger, i) => {
+ if (preservedMsgObj[trigger].selected.length > i) {
+ sendChat(preservedMsgObj[trigger].chatSpeaker, `!${trigger}${i} ${preservedMsgObj[trigger].dsmsg.replace(/{&\s*i\s*((\+|-)\s*([\d]+)){0,1}}/gi, ((m, g1, op, val) => { return !g1 ? i : op === '-' ? parseInt(i) - parseInt(val) : parseInt(i) + parseInt(val); }))}`);
+ }
+ if (preservedMsgObj[trigger].selected.length <= i + 1) {
+ setTimeout(() => { delete preservedMsgObj[trigger] }, 10000);
+ }
+ };
+ const fsrx = /(^!forselected(--|\+\+|\+-|-\+|\+|-|)(?:\((.)\)){0,1}(-silent)?\s+!?).+/i;
+ const forselected = (msg, apitrigger) => {
+ apitrigger = `${apiproject}${generateUUID()}`;
+ if (!(preservedMsgObj[maintrigger].selected && preservedMsgObj[maintrigger].selected.length)) {
+ let fsres = fsrx.exec(msg.content);
+ if (fsres && !fsres[4]) { // account for silent output
+ msgbox({ msg: `No selected tokens to use for that command. Please select some tokens then try again.`, title: `NO TOKENS`, whisperto: getWhisperTo(preservedMsgObj[maintrigger].who) });
+ }
+ return;
+ }
+ preservedMsgObj[apitrigger] = {
+ selected: [...(preservedMsgObj[maintrigger].selected || [])],
+ who: preservedMsgObj[maintrigger].who,
+ playerid: preservedMsgObj[maintrigger].playerid,
+ dsmsg: ''
+ };
+ preservedMsgObj[apitrigger].chatSpeaker = getTheSpeaker(preservedMsgObj[apitrigger]).chatSpeaker;
+ let fsres = fsrx.exec(msg.content);
+ switch (fsres[2] || '++') {
+ case '+-':
+ preservedMsgObj[apitrigger].replaceid = true;
+ preservedMsgObj[apitrigger].replacename = false;
+ break;
+ case '-':
+ case '-+':
+ preservedMsgObj[apitrigger].replaceid = false;
+ preservedMsgObj[apitrigger].replacename = true;
+ preservedMsgObj[apitrigger].nametoreplace = findObjs({ id: preservedMsgObj[apitrigger].selected[0]._id })[0].get('name');
+ break;
+ case '--':
+ preservedMsgObj[apitrigger].replaceid = false;
+ preservedMsgObj[apitrigger].replacename = false;
+ break;
+ case '+':
+ case '++':
+ default:
+ preservedMsgObj[apitrigger].replaceid = true;
+ preservedMsgObj[apitrigger].replacename = true;
+ preservedMsgObj[apitrigger].nametoreplace = findObjs({ id: preservedMsgObj[apitrigger].selected[0]._id })[0].get('name');
+ break;
+ }
+ msg.content = msg.content.replace(/ \n/g, ' ');
+ preservedMsgObj[apitrigger].dsmsg = msg.content.slice(fsres[1].length);
+ if (fsres[3]) {
+ preservedMsgObj[apitrigger].dsmsg = preservedMsgObj[apitrigger].dsmsg.replace(new RegExp(escapeRegExp(fsres[3]), 'g'), '');
+ }
+ dispatchForSelected(apitrigger, 0);
+ //preservedMsgObj[apitrigger].selected.forEach((t, i) => {
+ // sendChat(chatSpeaker, `!${apitrigger}${i} ${dsmsg.replace(/{&\s*i\s*((\+|-)\s*([\d]+)){0,1}}/gi, ((m, g1, op, val) => { return !g1 ? i : op === '-' ? parseInt(i) - parseInt(val) : parseInt(i) + parseInt(val); }))}`);
+ //});
+ //setTimeout(() => { delete preservedMsgObj[apitrigger] }, 10000);
+ };
+ const trackprops = (msg) => {
+ [
+ preservedMsgObj[maintrigger].who,
+ preservedMsgObj[maintrigger].selected,
+ preservedMsgObj[maintrigger].playerid,
+ preservedMsgObj[maintrigger].inlinerolls
+ ] = [msg.who, msg.selected, msg.playerid, msg.inlinerolls];
+ };
+ const handleInput = (msg, msgstate = {}) => {
+ let funcret = { runloop: false, status: 'unchanged', notes: '' };
+ const trigrx = new RegExp(`^!(${Object.keys(preservedMsgObj).join('|')})`);
+ let apitrigger; // the apitrigger used by the message
+ if (!Object.keys(msgstate).length && scriptisplugin) return funcret;
+ let status = [];
+ let notes = [];
+ let msgId = generateUUID();
+ msg.content = msg.content.replace(/ \n/g, '({&br-sm})');
+ let injection = inject(msg, status, msgId, notes);
+ if ('API' !== msg.playerid) { // user generated message
+ trackprops(msg);
+ } else { // API generated message
+ if (injection) preservedMsgObj[maintrigger].selected = msg.selected;
+ // peel off ZeroFrame trigger, if it's there
+ if (msg.apitrigger) msg.content = msg.content.replace(msg.apitrigger, '');
+ if (trigrx.test(msg.content)) { // message has apitrigger (iterative call of forselected) so cycle-in next selected
+ apitrigger = trigrx.exec(msg.content)[1];
+ msg.content = msg.content.replace(apitrigger, '');
+ status.push('changed');
+ let nextindex = /^!(\d+)\s*/.exec(msg.content)[1];
+ msg.content = `!${msg.content.slice(nextindex.length + 2)}`;
+ nextindex = Number(nextindex);
+ msg.selected = [];
+ msg.selected.push(preservedMsgObj[apitrigger].selected[nextindex]);
+ msg.who = preservedMsgObj[apitrigger].who;
+ msg.playerid = preservedMsgObj[apitrigger].playerid;
+ // handle replacements of @{selected|token_id} and @{selected|token_name}
+ if (preservedMsgObj[apitrigger].replaceid) {
+ msg.content = msg.content.replace(apitrigger, '').replace(preservedMsgObj[apitrigger].selected[0]._id, msg.selected[0]._id);
+ }
+ if (preservedMsgObj[apitrigger].replacename && preservedMsgObj[apitrigger].nametoreplace && msg.selected[0]._type === 'graphic') {
+ msg.content = msg.content.replace(apitrigger, '').replace(preservedMsgObj[apitrigger].nametoreplace, findObjs({ id: msg.selected[0]._id })[0].get('name'));
+ }
+ // handle replacements of at{selected|prop}
+ if (typeof Fetch !== 'undefined' && typeof ZeroFrame !== 'undefined') {
+ const fetchselrx = /at\((?selected)[|.](?- [^\s[|.)]+?)(?:[|.](?
[^\s.[|]+?)){0,1}(?:\[(?[^\]]*?)]){0,1}\s*\)/gi;
+ const fetchrptgselrx = /at\((?selected)[|.](?[^\s.|]+?)[|.]\[\s*(?.+?)\s*]\s*[|.](?[^[\s).]+?)(?:[|.](?[^\s.[)]+?)){0,1}(?:\[(?[^\]]*?)]){0,1}\s*\)/gi;
+ msg.content = msg.content.replace(fetchselrx, m => {
+ status.push('changed')
+ return `@${m.slice(2)}`;
+ });
+ msg.content = msg.content.replace(fetchrptgselrx, m => {
+ status.push('changed')
+ return `*${m.slice(2)}`;
+ });
+ } else {
+ let selrx = /at{selected(?:\||\.)([^|}]+)(\|max)?}/ig;
+ let retval;
+ msg.content = msg.content.replace(selrx, (g0, g1, g2) => {
+ if (['token_id', 'token_name', 'bar1', 'bar2', 'bar3', 'bar4'].includes(g1.toLowerCase())) {
+ let tok = findObjs({ id: msg.selected[0]._id })[0];
+ if (g1.toLowerCase() === 'token_id') retval = tok.id;
+ else if (g1.toLowerCase() === 'token_name') retval = tok.get('name');
+ else retval = tok.get(`${g1}_${g2 ? 'max' : 'value'}`) || '';
+ } else {
+ let character = findObjs({ type: 'character', id: (getObj("graphic", msg.selected[0]._id) || { get: () => { return "" } }).get("represents") })[0];
+ if (!character) {
+ notes.push('No character found represented by token ${msg.selected[0]._id}');
+ status.push('unresolved');
+ retval = '';
+ } else if ('character_id' === g1.toLowerCase()) {
+ retval = character.id;
+ } else if ('character_name' === g1.toLowerCase()) {
+ retval = character.get('name');
+ }
+ status.push('changed');
+ retval(findObjs({ type: 'attribute', characterid: character.id })[0] || { get: () => { return '' } }).get(g2 ? 'max' : 'current') || '';
+ }
+ });
+ }
+ dispatchForSelected(apitrigger, nextindex + 1);
+ } else { // api generated call to another script, copy in the appropriate data
+ if (manageState.get('autoinsert').includes('selected')) {
+ if (preservedMsgObj[maintrigger].selected && preservedMsgObj[maintrigger].selected.length) {
+ msg.selected = preservedMsgObj[maintrigger].selected;
+ }
+ if (!msg.selected || (msg.selected && !msg.selected.length)) {
+ delete msg.selected;
+ }
+ }
+ if (manageState.get('autoinsert').includes('who') && !manageState.get('knownsenders').includes(msg.who)) {
+ msg.who = preservedMsgObj[maintrigger].who;
+ }
+ if (manageState.get('autoinsert').includes('playerid')) {
+ msg.playerid = preservedMsgObj[maintrigger].playerid;
+ }
+ }
+ // replace ZeroFrame trigger, if it's there
+ if (msg.apitrigger) msg.content = `!${msg.apitrigger}${msg.content.slice(1)}`;
+ }
+ msg.content = msg.content.replace(/\({&br-sm}\)/g, ' \n');
+ return condensereturn(funcret, status, notes);
+ };
+ const handleForSelected = (msg) => {
+ if (msg.type !== 'api' || !fsrx.test(msg.content)) return;
+ forselected(msg);
+ };
+ const getProp = (prop) => {
+ return preservedMsgObj[maintrigger][prop] || undefined;
+ };
+ const getSelected = () => getProp('selected');
+ const getWho = () => getProp('who');
+ const getPlayerID = () => getProp('playerid');
+
+ const checkDependencies = (deps) => {
+ /* pass array of objects like
+ { name: 'ModName', version: '#.#.#' || '', mod: ModName || undefined, checks: [ [ExposedItem, type], [ExposedItem, type] ] }
+ */
+ const dependencyEngine = (deps) => {
+ const versionCheck = (mv, rv) => {
+ let modv = [...mv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ let reqv = [...rv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ return reqv.reduce((m, v, i) => {
+ if (m.pass || m.fail) return m;
+ if (i < 3) {
+ if (parseInt(modv[i]) > parseInt(reqv[i])) m.pass = true;
+ else if (parseInt(modv[i]) < parseInt(reqv[i])) m.fail = true;
+ } else {
+ // all betas are considered below the release they are attached to
+ if (reqv[i] === 0 && modv[i] === 0) m.pass = true;
+ else if (modv[i] === 0) m.pass = true;
+ else if (reqv[i] === 0) m.fail = true;
+ else if (parseInt(modv[i].slice(1)) >= parseInt(reqv[i].slice(1))) m.pass = true;
+ }
+ return m;
+ }, { pass: false, fail: false }).pass;
+ };
+
+ let result = { passed: true, failures: {}, optfailures: {} };
+ deps.forEach(d => {
+ let failObj = d.optional ? result.optfailures : result.failures;
+ if (!d.mod) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = 'Not found';
+ return;
+ }
+ if (d.version && d.version.length) {
+ if (!(API_Meta[d.name].version && API_Meta[d.name].version.length && versionCheck(API_Meta[d.name].version, d.version))) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = `Incorrect version. Required v${d.version}. ${API_Meta[d.name].version && API_Meta[d.name].version.length ? `Found v${API_Meta[d.name].version}` : 'Unable to tell version of current.'}`;
+ return;
+ }
+ }
+ d.checks.reduce((m, c) => {
+ if (!m.passed) return m;
+ let [pname, ptype] = c;
+ if (!d.mod.hasOwnProperty(pname) || typeof d.mod[pname] !== ptype) {
+ if (!d.optional) m.passed = false;
+ failObj[d.name] = `Incorrect version.`;
+ }
+ return m;
+ }, result);
+ });
+ return result;
+ };
+ let depCheck = dependencyEngine(deps);
+ let failures = '', contents = '', msg = '';
+ if (Object.keys(depCheck.optfailures).length) { // optional components were missing
+ failures = Object.keys(depCheck.optfailures).map(k => `• ${k} : ${depCheck.optfailures[k]}`).join(' ');
+ contents = `${apiproject} utilizies one or more other scripts for optional features, and works best with those scripts installed. You can typically find these optional scripts in the 1-click Mod Library: ${failures}`;
+ msg = ``;
+ sendChat(apiproject, `/w gm ${msg}`);
+ }
+ if (!depCheck.passed) {
+ failures = Object.keys(depCheck.failures).map(k => `• ${k} : ${depCheck.failures[k]}`).join(' ');
+ contents = `${apiproject} requires other scripts to work. Please use the 1-click Mod Library to correct the listed problems: ${failures}`;
+ msg = ``;
+ sendChat(apiproject, `/w gm ${msg}`);
+ return false;
+ }
+ return true;
+ };
+
+
+ let scriptisplugin = false;
+ const selectmanager = (m, s) => handleInput(m, s);
+ on('chat:message', handleInput);
+ setTimeout(() => { on('chat:message', handleForSelected) }, 0);
+ on('ready', () => {
+ versionInfo();
+ logsig();
+ let reqs = [
+ {
+ name: 'libTokenMarkers',
+ version: `0.1.2`,
+ mod: typeof libTokenMarkers !== 'undefined' ? libTokenMarkers : undefined,
+ checks: [['getStatus', 'function'], ['getStatuses', 'function'], ['getOrderedList', 'function']]
+ },
+ {
+ name: 'Messenger',
+ version: `1.0.0`,
+ mod: typeof Messenger !== 'undefined' ? Messenger : undefined,
+ checks: [['Button', 'function'], ['MsgBox', 'function'], ['HE', 'function'], ['Html', 'function'], ['Css', 'function']]
+ }
+ ];
+ if (!checkDependencies(reqs)) return;
+ html = Messenger.Html();
+ css = Messenger.Css();
+ HE = Messenger.HE;
+
+ oldmarkerrx = new RegExp(`^(\\+|-)(${libTokenMarkers.getOrderedList().map(o => o.name).join('|')})`);
+
+ issueVersionUpdateMessages();
+
+ scriptisplugin = (typeof ZeroFrame !== `undefined`);
+ if (typeof ZeroFrame !== 'undefined') {
+ ZeroFrame.RegisterMetaOp(selectmanager, { priority: 20, handles: ['sm'] });
+ }
+ on('chat:message', handleConfig);
+ });
+
+ return { // public interface
+ GetSelected: getSelected,
+ GetWho: getWho,
+ GetPlayerID: getPlayerID
+ };
+
+})();
+{ try { throw new Error(''); } catch (e) { API_Meta.SelectManager.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.SelectManager.offset); } }
+/* */
diff --git a/SelectManager/1.1.12/SelectManager.js b/SelectManager/1.1.12/SelectManager.js
new file mode 100644
index 000000000..b1b8d03ce
--- /dev/null
+++ b/SelectManager/1.1.12/SelectManager.js
@@ -0,0 +1,1160 @@
+/*
+=========================================================
+Name : SelectManager
+GitHub : https://github.com/TimRohr22/Cauldron/tree/master/SelectManager
+Roll20 Contact : timmaugh && The Aaron
+Version : 1.1.12
+Last Update : 5 DEC 2025
+=========================================================
+*/
+var API_Meta = API_Meta || {};
+API_Meta.SelectManager = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 };
+{ try { throw new Error(''); } catch (e) { API_Meta.SelectManager.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (12)); } }
+
+const SelectManager = (() => { //eslint-disable-line no-unused-vars
+ // ==================================================
+ // VERSION
+ // ==================================================
+ const apiproject = 'SelectManager';
+ const version = '1.1.12';
+ const schemaVersion = 0.4;
+ const apilogo = 'https://i.imgur.com/ewyOzMU.png';
+ const apilogoalt = 'https://i.imgur.com/3U8c9rE.png'
+ API_Meta[apiproject].version = version;
+ const vd = new Date(1764692774615);
+ 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:
+ state[apiproject].settings = {
+ playerscanids: false
+ };
+ if (state[apiproject].hasOwnProperty('autoinsert')) state[apiproject].settings.autoinsert = [...state[apiproject].autoinsert];
+ else state[apiproject].settings.autoinsert = ['selected'];
+ state[apiproject].defaults = {
+ autoinsert: ['selected'],
+ playerscanids: false
+ };
+ delete state[apiproject].autoinsert;
+ /* falls through */
+ case 0.2:
+ state[apiproject].settings.knownsenders = ['CRL'];
+ state[apiproject].defaults.knownsenders = ['CRL'];
+ /* falls through */
+ case 0.3:
+ state[apiproject].settings.show04message = true;
+ state[apiproject].defaults.show04message = true;
+ /* falls through */
+ case 'UpdateSchemaVersion':
+ state[apiproject].version = schemaVersion;
+ break;
+
+ default:
+ state[apiproject] = {
+ version: schemaVersion,
+ settings: {
+ autoinsert: ['selected'],
+ playerscanids: false,
+ knownsenders: ['CRL'],
+ show03message: true
+ },
+ defaults: {
+ autoinsert: ['selected'],
+ playerscanids: false,
+ knownsenders: ['CRL'],
+ show03message: true
+ }
+ };
+ break;
+ }
+ }
+ };
+ const manageState = { // eslint-disable-line no-unused-vars
+ reset: () => state[apiproject].settings = _.clone(state[apiproject].defaults),
+ set: (p, v) => state[apiproject].settings[p] = v,
+ get: (p) => { return state[apiproject].settings[p]; }
+ };
+
+ 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 generateUUID = (() => {
+ let a = 0;
+ let b = [];
+
+ return () => {
+ let c = (new Date()).getTime() + 0;
+ let f = 7;
+ let e = new Array(8);
+ let d = c === a;
+ a = c;
+ for (; 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 escapeRegExp = (string) => { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); };
+ const RX = (() => {
+ const esRE = (s) => s.replace(/(\\|\/|\[|\]|\(|\)|\{|\}|\?|\+|\*|\||\.|\^|\$)/g, '\\$1');
+ const entities = {
+ '*': { detect: /\*/, rx: /\*/, rep: '.*?' },
+ '?': { detect: /\?/, rx: /\?/, rep: '.' },
+ // '?': { detect: /.\?/, rx: /(.)\?/, rep: '$1?'}
+ };
+ const rxkeys = (k) => entities[k].detect.source;
+ const getSource = (s) => {
+ let rxsource = '';
+ let rxflags = '';
+ let ret;
+ const rxpattern = /^\/(?.*?)\/(?(?:g|i|m|s|u|y){0,6})$/i;
+ if (rxpattern.test(s)) {
+ ret = rxpattern.exec(s);
+ rxsource = ret.groups.source;
+ rxflags = ret.groups.flags || '';
+ } else {
+ rxsource = ['^',
+ ...s.split(new RegExp(`(${Object.keys(entities).map(rxkeys).join('|')})`))
+ .map(p => {
+ return Object.keys(entities).reduce((m, k) => {
+ let rx = new RegExp(`^${entities[k].rx.source}$`);
+ if (typeof m === 'undefined' && rx.test(p)) {
+ m = p.replace(rx, entities[k].rep);
+ }
+ return m;
+ }, undefined) || esRE(p);
+ }),
+ '$'
+ ].join('');
+ rxflags = 'gi';
+ }
+ return new RegExp(rxsource, rxflags);
+ };
+ return getSource;
+ })();
+ const playersCanUseIDs = () => manageState.get('playerscanids');
+ const getTheSpeaker = msg => {
+ let speaking;
+ if (['API', ''].includes(msg.who)) {
+ speaking = { id: undefined, type: 'API', localName: 'API', speakerType: 'API', chatSpeaker: 'API', get: () => { return 'API'; } };
+ } else {
+ let characters = findObjs({ type: 'character' });
+ characters.forEach(c => { if (c.get('name') === msg.who) speaking = c; });
+
+ if (speaking) {
+ speaking.speakerType = "character";
+ speaking.localName = speaking.get("name");
+ } else {
+ speaking = getObj('player', msg.playerid);
+ speaking.speakerType = "player";
+ speaking.localName = speaking.get("displayname");
+ }
+ speaking.chatSpeaker = speaking.speakerType + '|' + speaking.id;
+ }
+
+ return speaking;
+ };
+ const playerCanControl = (obj, playerid = 'any') => {
+ const playerInControlledByList = (list, playerid) => list.includes('all') || list.includes(playerid) || ('any' === playerid && list.length);
+ let players = obj.get('controlledby')
+ .split(/,/)
+ .filter(s => s.length);
+
+ if (playerInControlledByList(players, playerid)) {
+ return true;
+ }
+
+ if ('' !== obj.get('represents')) {
+ players = (getObj('character', obj.get('represents')) || { get: function () { return ''; } })
+ .get('controlledby').split(/,/)
+ .filter(s => s.length);
+ return playerInControlledByList(players, playerid);
+ }
+ return false;
+ };
+ const getPageForPlayer = (playerid) => {
+ let player = getObj('player', playerid);
+ if (playerIsGM(playerid)) {
+ return player.get('lastpage') || Campaign().get('playerpageid');
+ }
+
+ let psp = Campaign().get('playerspecificpages');
+ if (psp[playerid]) {
+ return psp[playerid];
+ }
+
+ return Campaign().get('playerpageid');
+ };
+ const getTokens = (query, pid, owner = true) => {
+ if (pid === 'API') pid = preservedMsgObj[maintrigger].playerid;
+ let pageid = getPageForPlayer(pid);
+ let qrx = RX(query);
+ let alltokens = [...findObjs({ type: 'graphic', pageid: pageid }), ...findObjs({ type: 'text', pageid: pageid }), ...findObjs({ type: 'path', pageid: pageid })]
+ .filter(t => t.get('layer') === 'objects' || playerIsGM(pid));
+ if (owner) {
+ alltokens = alltokens.filter(t => playerIsGM(pid) || playersCanUseIDs() || playerCanControl(t, pid));
+ }
+ let tokens = [(alltokens.filter(t => t.id === query)[0] ||
+ alltokens.filter(t => t.get('name') === query)[0])]
+ .filter(t => t);
+ if (!tokens.length) {
+ tokens = alltokens.filter(t => {
+ qrx.lastIndex = 0;
+ return qrx.test(typeof t.get('name') === 'undefined' ? '' : t.get('name'));
+ });
+ }
+ return tokens;
+ };
+
+ let html = {};
+ let css = {}; // eslint-disable-line no-unused-vars
+ let HE = () => { }; // eslint-disable-line no-unused-vars
+ const theme = {
+ primaryColor: '#E66B00',
+ primaryTextColor: '#232323',
+ primaryTextBackground: '#ededed'
+ }
+ const localCSS = {
+ msgheader: {
+ 'background-color': theme.primaryColor,
+ 'color': 'white',
+ 'font-size': '1.2em',
+ 'padding-left': '4px'
+ },
+ msgbody: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgfooter: {
+ 'color': theme.primaryTextColor,
+ 'background-color': theme.primaryTextBackground
+ },
+ msgheadercontent: {
+ 'display': 'table-cell',
+ 'vertical-align': 'middle',
+ 'padding': '4px 8px 4px 6px'
+ },
+ msgheaderlogodiv: {
+ 'display': 'table-cell',
+ 'max-height': '30px',
+ 'margin-right': '8px',
+ 'margin-top': '4px',
+ 'vertical-align': 'middle'
+ },
+ logoimg: {
+ 'background-color': 'transparent',
+ 'float': 'left',
+ 'border': 'none',
+ 'max-height': '30px'
+ },
+ boundingcss: {
+ 'background-color': theme.primaryTextBackground
+ },
+ inlineEmphasis: {
+ 'font-weight': 'bold'
+ },
+ button: {
+ 'background-color': theme.primaryColor,
+ 'border-radius': '6px',
+ 'min-width': '25px',
+ 'padding': '6px 8px'
+ }
+ }
+ const msgbox = ({
+ msg: msg = '',
+ title: title = '',
+ headercss: headercss = localCSS.msgheader,
+ bodycss: bodycss = localCSS.msgbody,
+ footercss: footercss = localCSS.msgfooter,
+ sendas: sendas = 'SelectManager',
+ whisperto: whisperto = '',
+ footer: footer = '',
+ btn: btn = '',
+ } = {}) => {
+ if (title) title = html.div(html.div(html.img(apilogoalt, 'SelectManager Logo', localCSS.logoimg), localCSS.msgheaderlogodiv) + html.div(title, localCSS.msgheadercontent), {});
+ Messenger.MsgBox({ msg: msg, title: title, bodycss: bodycss, sendas: sendas, whisperto: whisperto, footer: footer, btn: btn, headercss: headercss, footercss: footercss, boundingcss: localCSS.boundingcss, noarchive: true });
+ };
+
+ const getWhisperTo = (who) => who.toLowerCase() === 'api' ? 'gm' : who.replace(/\s\(gm\)$/i, '');
+ const handleConfig = msg => {
+ if (msg.type !== 'api' || !/^!smconfig/.test(msg.content)) return;
+ let recipient = getWhisperTo(msg.who);
+ if (!playerIsGM(msg.playerid)) {
+ msgbox({ title: 'GM Rights Required', msg: 'You must be a GM to perform that operation', whisperto: recipient });
+ return;
+ }
+ let cfgrx = /^(\+|-)(selected|who|playerid|playerscanids|acknowledge(\d+))$/i;
+ let changeObj = {
+ '+': 'enabled',
+ '-': 'disabled',
+ 'a': 'acknowledged'
+ };
+ let res;
+ let cfgTrack = {};
+ let message;
+ if (/^!smconfig\s+[^\s]/.test(msg.content)) {
+ msg.content.split(/\s+/).slice(1).forEach(a => {
+ res = cfgrx.exec(a);
+ if (!res) return;
+ if (res[2].toLowerCase() === 'playerscanids') {
+ manageState.set('playerscanids', (res[1] === '+'));
+ cfgTrack[res[2]] = res[1];
+ } else if (['selected', 'who', 'playerid'].includes(res[2].toLowerCase())) {
+ if (res[1] === '+') {
+ manageState.set('autoinsert', [...new Set([...manageState.get('autoinsert'), res[2].toLowerCase()])]);
+ cfgTrack[res[2]] = res[1];
+ } else {
+ manageState.set('autoinsert', manageState.get('autoinsert').filter(e => e !== res[2].toLowerCase()));
+ cfgTrack[res[2]] = res[1];
+ }
+ } else if (/^acknowledge\d+$/i.test(res[2])) {
+ manageState.set(`show${res[3]}message`, false);
+ cfgTrack[`Schema ${res[3]} Message`] = 'a';
+ }
+ });
+ let changes = Object.keys(cfgTrack).map(k => `${html.span(k, localCSS.inlineEmphasis)}: ${changeObj[cfgTrack[k]]}`).join(' ');
+ msgbox({ title: `SelectManager Config Changed`, msg: `You have made the following changes to the SelectManager configuration: ${changes}`, whisperto: recipient });
+ } else {
+ cfgTrack.playerscanids = `${html.span('playerscanids', localCSS.inlineEmphasis)}: ${manageState.get('playerscanids') ? 'enabled' : 'disabled'}`;
+ cfgTrack.autoinsert = ['selected', 'who', 'playerid'].map(k => `${html.span(k, localCSS.inlineEmphasis)}: ${manageState.get('autoinsert').includes(k) ? 'enabled' : 'disabled'}`).join(' ');
+ message = `SelectManager is currently configured as follows: ${cfgTrack.playerscanids} ${cfgTrack.autoinsert}`;
+ msgbox({ title: 'SelectManager Configuration', msg: message, whisperto: recipient });
+ }
+ };
+
+ const issueVersionUpdateMessages = () => {
+ let allCommands = [...findObjs({ type: 'macro' }), ...findObjs({ type: 'ability' })];
+
+ const show04Message = () => {
+ let affected = allCommands.filter(o => {
+ let cmd = o.get('action');
+ let locSelrx = /{&\s*(?:select|inject)\s+([^}]+?)\s*}/gi;
+ let found = false;
+ let res;
+ let items;
+ while (!found && (res = locSelrx.exec(cmd)) && res) {
+ found = !!(res[1].split(/\s*,\s*/)
+ .filter(item => oldmarkerrx.test(item)).length);
+ // .filter(item => /^(\+|-)/.test(item) && !/^(\+|-)(@.*|#.*|\*.*|((bar|max)(1|2|3){1})|((aura|color)(1|2){0,1})|layer|tip|gmnotes|type|pc|npc|pt|side)(\s|<|>|=|~|!|$)/.test(item)).length);
+ }
+ return found;
+ });
+
+ if (affected.length) {
+ let listAffected = affected.map(a => `${a.get('name')} (${a.get('type') === 'ability' ? `ability for ${getObj('character', a.get('characterid')).get('name')}` : 'macro'}) `).join('');
+ let message = html.p(`A small portion of SelectManager syntax is changing. A previous update made it possible to use status markers (either their presence or value) as a ` +
+ `condition for virtually selecting that token. For instance, testing a token for the presence of a status marker named "noble" would look like:+noble `) +
+ html.p(`This syntax allowed for "collisions" -- a situation where a marker might bear the name of one of the other keywords SelectManager looks for as ways to test the tokens: aura, bar1, npc, etc. ` +
+ `For instance, if you were playing in a game that had a status marker named "npc", then would the syntax +npc refer to the presence of the marker, or to the internal test ` +
+ `SelectManager uses to determine if a token is an npc?`) +
+ html.p(`With the v1.1.8 update, SelectManager can now use a similar syntax to test a token for the presence of character tags, increasing the possibility of these collisions (i.e., a tag ` +
+ `and a marker both named "noble"). Because of this, the syntax to test for a status marker is getting an update to allow for greater specificity. Going forward, ` +
+ `to test for a status marker on a token, you should simply preface the marker name with an asterisk (*) immediately following the "+" (for "should have") or "-" ` +
+ `(for "should not have"):+*noble +*noble > 2 `) +
+ html.p(`The previous syntax is still available for now, but is no longer supported and will be removed at some point in the future. You should take a moment to update commands ` +
+ `in your game that utilize the previous construction (without an asterisk). A quick scan of character abilities and macros in this game shows that the following list ` +
+ `might be commands where you have utilized the previous syntax:` +
+ ``);
+ //const button = ({ elem: elem = '', label: label = '', char: char = '', type: type = '%', css: css = Messenger.Css.button } = {}) => {
+
+ let button = Messenger.Button({ elem: `!smconfig +acknowledge04`, type: '!', label: `Don't Show Again`, css: localCSS.button, noarchive: true });
+ msgbox({ title: 'SelectManager Syntax Update', msg: message, whisperto: 'gm', btn: button });
+
+ // TODO: make sure chat message has opt-out for not getting the message again
+ } else {
+ manageState.set('show04message', false);
+ }
+ };
+
+ const messageSettings = {
+ show04message: show04Message
+ };
+
+ Object.keys(messageSettings).forEach(k => {
+ if (manageState.get(k)) { messageSettings[k](); }
+ });
+ };
+
+ const maintrigger = `${apiproject}-main`;
+ let preservedMsgObj = {
+ [maintrigger]: { selected: undefined, who: '', playerid: '' }
+ };
+
+ const condensereturn = (funcret, status, notes) => {
+ funcret.runloop = (status.includes('changed') || status.includes('unresolved'));
+ if (status.length) {
+ funcret.status = status.reduce((m, v) => {
+ switch (m) {
+ case 'unchanged':
+ m = v;
+ break;
+ case 'changed':
+ m = v === 'unresolved' ? v : m;
+ break;
+ case 'unresolved':
+ break;
+ }
+ return m;
+ });
+ }
+ funcret.notes = notes.join(' ');
+ return funcret;
+ };
+ const uniqueArrayByProp = (array, prop = 'id') => {
+ const set = new Set;
+ return array
+ .filter(o => typeof o !== 'undefined' && !set.has(o[prop]) && set.add(o[prop]));
+ };
+ let oldmarkerrx;
+ const decomposeStatuses = (list = '') => {
+ return list.split(/\s*,\s*/g).filter(s => s.length)
+ .reduce((m, s) => {
+ let origst = libTokenMarkers.getStatus(s.slice(0, /(@\d+$|:)/.test(s) ? /(@\d+$|:)/.exec(s).index : s.length));
+ let st = _.clone(origst);
+ if (!st) return m;
+ st.num = /^.+@0*(\d+)/.test(s) ? /^.+@0*(\d+)/.exec(s)[1] : '';
+ st.html = origst.getHTML();
+ st.url = st.url || '';
+ m.push(st);
+ return m;
+ }, []);
+ };
+ class StatusBlock {
+ constructor({ token: token = {}, msgId: msgId = generateUUID() } = {}) {
+ this.token = token;
+ this.msgId = msgId;
+ this.statuses = (decomposeStatuses(token.get('statusmarkers')) || []).reduce((m, s) => {
+ m[s.name] = m[s.name] || []
+ m[s.name].push(Object.assign({}, s, { is: 'yes' }));
+ return m;
+ }, {});
+ }
+ }
+
+ const tokenStatuses = {};
+ const getStatus = (token, query, msgId) => {
+ let rxret, status, index, modindex, statusblock;
+ if (!token) return;
+ // token = simpleObj(token);
+ // if (token && !token.hasOwnProperty('id')) token.id = token._id;
+ if (!tokenStatuses.hasOwnProperty(token.id) || tokenStatuses[token.id].msgId !== msgId) {
+ tokenStatuses[token.id] = new StatusBlock({ token: token, msgId: msgId });
+ }
+ rxret = /(?.+?)(?:\?(?\d+|all\+?))?$/.exec(query);
+ [status, index] = [rxret.groups.marker, rxret.groups.index];
+ if (!index) {
+ modindex = 1;
+ } else if (['all', 'all+'].includes(index.toLowerCase())) {
+ modindex = index.toLowerCase();
+ } else {
+ modindex = Number(index);
+ }
+ statusblock = tokenStatuses[token.id].statuses[status];
+ if (!statusblock || !statusblock.length) {
+ return { is: 'no', count: '0' };
+ };
+ switch (index) {
+ case 'all':
+ return statusblock.reduce((m, sm) => {
+ m.num = `${m.num || ''}${sm.num}`;
+ m.tag = m.tag || sm.tag;
+ m.url = m.url || sm.url;
+ m.html = m.html || sm.html;
+ m.is = 'yes';
+ m.count = m.count || statusblock.length;
+ return m;
+ }, {});
+ case 'all+':
+ return statusblock.reduce((m, sm) => {
+ m.num = `${Number(m.num || 0) + Number(sm.num)}`;
+ m.tag = m.tag || sm.tag;
+ m.url = m.url || sm.url;
+ m.html = m.html || sm.html;
+ m.is = 'yes';
+ m.count = m.count || statusblock.length;
+ return m;
+ }, {});
+ default:
+ if (statusblock.length >= modindex) {
+ return Object.assign({}, statusblock[modindex - 1], { count: index ? '1' : statusblock.length });
+ } else {
+ return { is: 'no', 'count': '0' };
+ }
+ }
+ };
+
+ const checkTicks = (s, check = ["'", "`", '"']) => {
+ if (typeof s !== 'string') return s;
+ return ((s.charAt(0) === s.charAt(s.length - 1)) && check.includes(s.charAt(0))) ? s.slice(1, s.length - 1) : s;
+ };
+ const isPlayerToken = (obj = { get: () => { return undefined; } }, pc = false) => {
+ let players;
+ if (!pc) {
+ players = obj.get('controlledby')
+ .split(/,/)
+ .filter(s => s.length);
+
+ if (players.includes('all') || players.filter((p) => !playerIsGM(p)).length) {
+ return true;
+ }
+ }
+
+ if ('' !== obj.get('represents')) {
+ players = (getObj('character', obj.get('represents')) || { get: function () { return ''; } })
+ .get('controlledby')
+ .split(/,/)
+ .filter(s => s.length);
+ return !!(players.includes('all') || players.filter((p) => !playerIsGM(p)).length);
+ }
+ return false;
+ };
+ const isNPC = (obj = { get: () => { return ''; } }) => {
+ let control = (
+ obj.get('represents') && obj.get('represents').length
+ ? getObj('character', obj.get('represents') || { get: function () { return ''; } })
+ : obj
+ )
+ .get('controlledby').split(/,/);
+ if (!control.length) return true;
+ return !control.filter(s => s.length && !playerIsGM(s)).length;
+ };
+ const isParty = (obj = { get: () => { return ''; } }) => {
+ let char = (
+ obj.get('represents') && obj.get('represents').length
+ ? getObj('character', obj.get('represents') || { get: function () { return ''; } })
+ : obj
+ );
+ return char.get('inParty');
+ };
+ const internalTestLib = {
+ 'int': (v) => +v === +v && parseInt(parseFloat(v, 10), 10) == v,
+ 'num': (v) => +v === +v,
+ 'tru': (v) => v == true
+ };
+ const typeProcessor = {
+ '=': (t) => t[0] == t[1],
+ '!=': (t) => t[0] != t[1],
+ '~': (t) => t[0].includes(t[1]),
+ '!~': (t) => !t[0].includes(t[1]),
+ '>': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) > (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ '>=': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) >= (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ '<': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) < (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ '<=': (t) => (internalTestLib.num(t[0]) ? Number(t[0]) : t[0]) <= (internalTestLib.num(t[1]) ? Number(t[1]) : t[1]),
+ 'in': (t) => {
+ let array = (/^\[?([^\]]+)\]?$/.exec(t[1])[1] || '').split(/\s*,\s*/);
+ return array.includes(t[0]);
+ }
+ }
+
+ const evaluateCriteria = (c, t, msgId) => {
+ let comp = [];
+ let tksetting;
+ let test = c.test;
+ let attrret = 'current'; // current or max
+ let attrval;
+ let attrres;
+ switch (c.type) {
+ case 'bar':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`bar${['1', '2', '3', '4'].includes(c.ident) ? c.ident : '1'}_value`), c.value];
+ }
+ break;
+ case 'max':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`bar${['1', '2', '3', '4'].includes(c.ident) ? c.ident : '1'}_max`), c.value];
+ }
+ break;
+ case 'aura':
+ if (test && test.length && c.value && !isNaN(c.value) && typeProcessor.hasOwnProperty(test)) { // testing radius of aura
+ tksetting = t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_radius`);
+ if (tksetting && tksetting.length) {
+ comp = [tksetting, c.value];
+ }
+ } else { // testing presence of aura
+ tksetting = t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_radius`);
+ comp = [tksetting && tksetting.length > 0, true];
+ test = '=';
+ }
+ break;
+ case 'color':
+ if (typeProcessor.hasOwnProperty(test)) {
+ tksetting = t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_radius`);
+ if (tksetting && tksetting.length) {
+ comp = [t.get(`aura${['1', '2'].includes(c.ident) ? c.ident : '1'}_color`), c.value];
+ }
+ }
+ break;
+ case 'gmnotes':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`gmnotes`), c.value];
+ }
+ break;
+ case 'tip':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`tooltip`), c.value];
+ }
+ break;
+ case 'layer':
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [t.get(`layer`), c.value];
+ }
+ break;
+ case 'marker':
+ tksetting = getStatus(t, c.ident, msgId);
+ if (typeProcessor.hasOwnProperty(test)) {
+ comp = [tksetting.num, c.value];
+ } else { // testing presence of marker
+ test = '=';
+ comp = [tksetting.is === 'yes', true];
+ }
+ break;
+ case 'tag':
+ if (t.get('represents') && t.get('represents').length) {
+ let char = getObj('character', t.get('represents'));
+ if (char) { // testing presence of attribute
+ tksetting = JSON.parse(char.get('tags'));
+ test = '=';
+ comp = [tksetting.includes(c.ident), true];
+ }
+ }
+ break;
+ case 'attribute':
+ if (t.get('represents') && t.get('represents').length) {
+ attrres = /^(?[^.|#?]+?)(?:(?:\.|\?|#|\|)(?current|cur|c|max|m))?\s*$/i.exec(c.ident);
+ if (attrres.groups && attrres.groups.attrval && attrres.groups.attrval.length && ['max', 'm'].includes(attrres.groups.attrval)) {
+ attrret = 'max';
+ }
+ if (typeProcessor.hasOwnProperty(test)) {
+ attrval = (findObjs({ type: 'attribute', characterid: t.get('represents') }).filter(a => a.get('name') === attrres.groups.attr)[0] || { get: () => { return '' } }).get(attrret) || '';
+ comp = [attrval, c.value];
+ } else { // testing presence of attribute
+ test = '=';
+ comp = [findObjs({ type: 'attribute', characterid: t.get('represents') }).filter(a => a.get('name') === attrres.groups.attr).length > 0, true];
+ }
+ }
+ break;
+ case 'type':
+ if (typeProcessor.hasOwnProperty(test)) {
+ if (c.value === 'graphic') {
+ tksetting = t.get('type');
+ } else {
+ tksetting = t.get('type') === 'graphic' ? t.get('subtype') : t.get('type');
+ }
+ comp = [tksetting, c.value];
+ }
+ break;
+ case 'pc':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token' && t.get('layer') === 'objects') {
+ test = '=';
+ comp = [isPlayerToken(t, true), true];
+ }
+ break;
+ case 'npc':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token') {
+ test = '=';
+ comp = [isNPC(t), true];
+ }
+ break;
+ case 'pt':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token' && t.get('layer') === 'objects') {
+ test = '=';
+ comp = [isPlayerToken(t, true), false];
+ }
+ break;
+ case 'side':
+ if (typeProcessor.hasOwnProperty(test) && t.get('type') === 'graphic') {
+ tksetting = t.get('currentSide');
+ comp = [tksetting, c.value];
+ }
+ break;
+ case 'party':
+ if (t.get('type') === 'graphic' && t.get('subtype') === 'token' && t.get('layer') === 'objects') {
+ test = '=';
+ comp = [isParty(t), true];
+ }
+ break;
+
+ break;
+ default:
+ return false;
+ }
+ if (!comp.length) return false;
+ let result = typeProcessor[test](comp);
+ return c.musthave ? result : !result;
+ };
+
+ class Criteria {
+ constructor({
+ type: type = '',
+ musthave: musthave = '',
+ ident: ident = '',
+ test: test = '',
+ value: value = ''
+ } = {}) {
+ this.type = type;
+ this.musthave = musthave;
+ this.ident = ident;
+ this.test = test;
+ this.value = value;
+ }
+ }
+ // const injectrx = /(\()?{&\s*inject\s+([^}]+?)\s*}((?<=\({&\s*inject\s+([^}]+?)\s*})\)|\1)/gi;
+ // const selectrx = /(\()?{&\s*select\s+([^}]+?)\s*}((?<=\({&\s*select\s+([^}]+?)\s*})\)|\1)/gi;
+ const injectrx = /(\()?{&\s*\+?inject\s+([^}]+?)\s*}((?<=\({&\s*\+?inject\s+([^}]+?)\s*})\)|\1)/gi;
+ const selectrx = /(\()?{&\s*\+?select\s+([^}]+?)\s*}((?<=\({&\s*\+?select\s+([^}]+?)\s*})\)|\1)/gi;
+ const criteriarx = /^(?\+|-)(?@|\*|#)?(?[^\s><=!~]+)(?:\s*$|\s*(?>=|<=|~|!~|=|!=|<|>|in(?=\s+\[[^\]]+\]))\s*(?.+)$)/;
+ const typeitemrx = /^(?bar|max|aura|color|layer|tip|gmnotes|type|pc|npc|pt|side|party)(?1|2|3|4)?(?<=(?:bar|max)\d|(?:aura|color)[1,2]|(?:layer|tip|gmnotes|type|pc|npc|pt|side|party))$/i;
+ const inject = (msg, status, msgId/*, notes*/) => {
+ const layerCriteria = (criteria) => {
+ return criteria.filter(c => c.type === 'layer').length ? true : false;
+ };
+ const caseLibrary = [
+ { rx: /^(\+|-)[^\s]+\s+in\s+\[$/i, terminator: ']' }
+ ];
+ const getGroups = (cmd, index = 0, groups = []) => {
+ const getNextGroup = (cmd, terminator = ',') => {
+ let s = '';
+ let bstop = false;
+ while (index <= cmd.length - 1 && !bstop) {
+ if (cmd.charAt(index) === terminator) {
+ if (terminator !== ',') {
+ s = `${s}${terminator}`;
+ index++;
+ }
+ bstop = true;
+ } else {
+ if (s.length || cmd.charAt(index) !== ' ') {
+ s = `${s}${cmd.charAt(index)}`;
+ }
+ index++;
+ for (const c of caseLibrary) {
+ c.rx.lastIndex = 0;
+ if (c.rx.test(s)) {
+ s = `${s}${getNextGroup(cmd, c.terminator)}`;
+ }
+ }
+ }
+ }
+ return s;
+ };
+ while (index <= cmd.length - 1) {
+ groups.push(getNextGroup(cmd));
+ index++;
+ }
+ return groups;
+ };
+ const unpackGroups = (array) => {
+ return array
+ .map(l => getTokens(l, msg.playerid))
+ .reduce((m, group) => {
+ m = [...m, ...group];
+ return m;
+ }, [])
+ .filter(t => typeof t !== 'undefined');
+ };
+ let allowDupes = false;
+ const replaceOps = (rx, rxtype) => {
+ rx.lastIndex = 0;
+ msg.content = msg.content.replace(rx, (m, padding, group) => {
+ if (rxtype === 'inject') {
+ msg.selected = msg.selected || [];
+ } else if (rxtype === 'select') {
+ msg.selected = [];
+ }
+ allowDupes = allowDupes || /^(\()?{&\s*\+/.test(m);
+ let identifiers = getGroups(group)
+ .reduce((m, v) => {
+ if (criteriarx.test(v) && !findObjs({ id: v }).length) {
+ let critres = criteriarx.exec(v);
+ let newcriteria = new Criteria({ musthave: (critres.groups.musthave === '+'), test: (critres.groups.test || ''), value: checkTicks((critres.groups.value || '')) });
+ if (critres.groups.attr && critres.groups.attr === '@') {
+ newcriteria.type = 'attribute';
+ newcriteria.ident = (critres.groups.typeitem || '');
+ } else if (critres.groups.attr && critres.groups.attr === '*') {
+ newcriteria.type = 'marker';
+ newcriteria.ident = (critres.groups.typeitem || '');
+ } else if (critres.groups.attr && critres.groups.attr === '#') {
+ newcriteria.type = 'tag';
+ newcriteria.ident = (critres.groups.typeitem || '');
+ } else if (typeitemrx.test(critres.groups.typeitem)) {
+ let ti_res = typeitemrx.exec(critres.groups.typeitem);
+ newcriteria.type = ti_res.groups.type;
+ newcriteria.ident = ti_res.groups.ident;
+ } else if (oldmarkerrx.test(v)) {
+ newcriteria.type = 'marker';
+ newcriteria.ident = critres.groups.typeitem;
+ } else {
+ m.selections.push(v);
+ }
+ m.criteria.push(newcriteria);
+ } else {
+ m.selections.push(v);
+ }
+ return m;
+ }, { criteria: [], selections: [] });
+ if (playerIsGM(msg.playerid) && !layerCriteria(identifiers.criteria)) {
+ identifiers.criteria.push(new Criteria({ type: 'layer', musthave: true, test: '=', value: 'objects' }));
+ }
+ identifiers.selections = (allowDupes ? unpackGroups(identifiers.selections) : uniqueArrayByProp(unpackGroups(identifiers.selections), 'id'))
+ .filter(t => {
+ return identifiers.criteria.every(c => evaluateCriteria(c, t, msgId));
+ });
+
+ msg.selected = identifiers.selections
+ .map(t => { return { '_id': t.id, '_type': t.get('type') }; })
+ .reduce((m, t) => {
+ if (allowDupes || !m.map(mt => mt._id).includes(t._id)) {
+ m.push(t);
+ }
+ return m;
+ }, msg.selected);
+
+ status.push('changed');
+ return '';
+ });
+ };
+ let retResult = false;
+ // handle selections
+ if (selectrx.test(msg.content)) {
+ retResult = true;
+ replaceOps(selectrx, 'select');
+ }
+ // handle injections
+ if (injectrx.test(msg.content)) {
+ retResult = true;
+ replaceOps(injectrx, 'inject');
+ }
+ if (msg.selected && !msg.selected.length) delete msg.selected;
+ return retResult;
+ };
+
+ const dispatchForSelected = (trigger, i) => {
+ if (preservedMsgObj[trigger].selected.length > i) {
+ sendChat(preservedMsgObj[trigger].chatSpeaker, `!${trigger}${i} ${preservedMsgObj[trigger].dsmsg.replace(/{&\s*i\s*((\+|-)\s*([\d]+)){0,1}}/gi, ((m, g1, op, val) => { return !g1 ? i : op === '-' ? parseInt(i) - parseInt(val) : parseInt(i) + parseInt(val); }))}`);
+ }
+ if (preservedMsgObj[trigger].selected.length <= i + 1) {
+ setTimeout(() => { delete preservedMsgObj[trigger] }, 10000);
+ }
+ };
+ const fsrx = /(^!forselected(--|\+\+|\+-|-\+|\+|-|)(?:\((.)\)){0,1}(-silent)?\s+!?).+/i;
+ const forselected = (msg, apitrigger) => {
+ apitrigger = `${apiproject}${generateUUID()}`;
+ if (!(preservedMsgObj[maintrigger].selected && preservedMsgObj[maintrigger].selected.length)) {
+ let fsres = fsrx.exec(msg.content);
+ if (fsres && !fsres[4]) { // account for silent output
+ msgbox({ msg: `No selected tokens to use for that command. Please select some tokens then try again.`, title: `NO TOKENS`, whisperto: getWhisperTo(preservedMsgObj[maintrigger].who) });
+ }
+ return;
+ }
+ preservedMsgObj[apitrigger] = {
+ selected: [...(preservedMsgObj[maintrigger].selected || [])],
+ who: preservedMsgObj[maintrigger].who,
+ playerid: preservedMsgObj[maintrigger].playerid,
+ dsmsg: ''
+ };
+ preservedMsgObj[apitrigger].chatSpeaker = getTheSpeaker(preservedMsgObj[apitrigger]).chatSpeaker;
+ let fsres = fsrx.exec(msg.content);
+ switch (fsres[2] || '++') {
+ case '+-':
+ preservedMsgObj[apitrigger].replaceid = true;
+ preservedMsgObj[apitrigger].replacename = false;
+ break;
+ case '-':
+ case '-+':
+ preservedMsgObj[apitrigger].replaceid = false;
+ preservedMsgObj[apitrigger].replacename = true;
+ preservedMsgObj[apitrigger].nametoreplace = findObjs({ id: preservedMsgObj[apitrigger].selected[0]._id })[0].get('name');
+ break;
+ case '--':
+ preservedMsgObj[apitrigger].replaceid = false;
+ preservedMsgObj[apitrigger].replacename = false;
+ break;
+ case '+':
+ case '++':
+ default:
+ preservedMsgObj[apitrigger].replaceid = true;
+ preservedMsgObj[apitrigger].replacename = true;
+ preservedMsgObj[apitrigger].nametoreplace = findObjs({ id: preservedMsgObj[apitrigger].selected[0]._id })[0].get('name');
+ break;
+ }
+ msg.content = msg.content.replace(/ \n/g, ' ');
+ preservedMsgObj[apitrigger].dsmsg = msg.content.slice(fsres[1].length);
+ if (fsres[3]) {
+ preservedMsgObj[apitrigger].dsmsg = preservedMsgObj[apitrigger].dsmsg.replace(new RegExp(escapeRegExp(fsres[3]), 'g'), '');
+ }
+ dispatchForSelected(apitrigger, 0);
+ //preservedMsgObj[apitrigger].selected.forEach((t, i) => {
+ // sendChat(chatSpeaker, `!${apitrigger}${i} ${dsmsg.replace(/{&\s*i\s*((\+|-)\s*([\d]+)){0,1}}/gi, ((m, g1, op, val) => { return !g1 ? i : op === '-' ? parseInt(i) - parseInt(val) : parseInt(i) + parseInt(val); }))}`);
+ //});
+ //setTimeout(() => { delete preservedMsgObj[apitrigger] }, 10000);
+ };
+ const trackprops = (msg) => {
+ [
+ preservedMsgObj[maintrigger].who,
+ preservedMsgObj[maintrigger].selected,
+ preservedMsgObj[maintrigger].playerid,
+ preservedMsgObj[maintrigger].inlinerolls
+ ] = [msg.who, msg.selected, msg.playerid, msg.inlinerolls];
+ };
+ const handleInput = (msg, msgstate = {}) => {
+ let funcret = { runloop: false, status: 'unchanged', notes: '' };
+ const trigrx = new RegExp(`^!(${Object.keys(preservedMsgObj).join('|')})`);
+ let apitrigger; // the apitrigger used by the message
+ if (!Object.keys(msgstate).length && scriptisplugin) return funcret;
+ let status = [];
+ let notes = [];
+ let msgId = generateUUID();
+ msg.content = msg.content.replace(/ \n/g, '({&br-sm})');
+ let injection = inject(msg, status, msgId, notes);
+ if ('API' !== msg.playerid) { // user generated message
+ trackprops(msg);
+ } else { // API generated message
+ if (injection) preservedMsgObj[maintrigger].selected = msg.selected;
+ // peel off ZeroFrame trigger, if it's there
+ if (msg.apitrigger) msg.content = msg.content.replace(msg.apitrigger, '');
+ if (trigrx.test(msg.content)) { // message has apitrigger (iterative call of forselected) so cycle-in next selected
+ apitrigger = trigrx.exec(msg.content)[1];
+ msg.content = msg.content.replace(apitrigger, '');
+ status.push('changed');
+ let nextindex = /^!(\d+)\s*/.exec(msg.content)[1];
+ msg.content = `!${msg.content.slice(nextindex.length + 2)}`;
+ nextindex = Number(nextindex);
+ msg.selected = [];
+ msg.selected.push(preservedMsgObj[apitrigger].selected[nextindex]);
+ msg.who = preservedMsgObj[apitrigger].who;
+ msg.playerid = preservedMsgObj[apitrigger].playerid;
+ // handle replacements of @{selected|token_id} and @{selected|token_name}
+ if (preservedMsgObj[apitrigger].replaceid) {
+ msg.content = msg.content.replace(apitrigger, '').replace(preservedMsgObj[apitrigger].selected[0]._id, msg.selected[0]._id);
+ }
+ if (preservedMsgObj[apitrigger].replacename && preservedMsgObj[apitrigger].nametoreplace && msg.selected[0]._type === 'graphic') {
+ msg.content = msg.content.replace(apitrigger, '').replace(preservedMsgObj[apitrigger].nametoreplace, findObjs({ id: msg.selected[0]._id })[0].get('name'));
+ }
+ // handle replacements of at{selected|prop}
+ if (typeof Fetch !== 'undefined' && typeof ZeroFrame !== 'undefined') {
+ const fetchselrx = /at\((?selected)[|.](?- [^\s[|.)]+?)(?:[|.](?
[^\s.[|]+?)){0,1}(?:\[(?[^\]]*?)]){0,1}\s*\)/gi;
+ const fetchrptgselrx = /at\((?selected)[|.](?[^\s.|]+?)[|.]\[\s*(?.+?)\s*]\s*[|.](?[^[\s).]+?)(?:[|.](?[^\s.[)]+?)){0,1}(?:\[(?[^\]]*?)]){0,1}\s*\)/gi;
+ msg.content = msg.content.replace(fetchselrx, m => {
+ status.push('changed')
+ return `@${m.slice(2)}`;
+ });
+ msg.content = msg.content.replace(fetchrptgselrx, m => {
+ status.push('changed')
+ return `*${m.slice(2)}`;
+ });
+ } else {
+ let selrx = /at{selected(?:\||\.)([^|}]+)(\|max)?}/ig;
+ let retval;
+ msg.content = msg.content.replace(selrx, (g0, g1, g2) => {
+ if (['token_id', 'token_name', 'bar1', 'bar2', 'bar3', 'bar4'].includes(g1.toLowerCase())) {
+ let tok = findObjs({ id: msg.selected[0]._id })[0];
+ if (g1.toLowerCase() === 'token_id') retval = tok.id;
+ else if (g1.toLowerCase() === 'token_name') retval = tok.get('name');
+ else retval = tok.get(`${g1}_${g2 ? 'max' : 'value'}`) || '';
+ } else {
+ let character = findObjs({ type: 'character', id: (getObj("graphic", msg.selected[0]._id) || { get: () => { return "" } }).get("represents") })[0];
+ if (!character) {
+ notes.push('No character found represented by token ${msg.selected[0]._id}');
+ status.push('unresolved');
+ retval = '';
+ } else if ('character_id' === g1.toLowerCase()) {
+ retval = character.id;
+ } else if ('character_name' === g1.toLowerCase()) {
+ retval = character.get('name');
+ }
+ status.push('changed');
+ retval(findObjs({ type: 'attribute', characterid: character.id })[0] || { get: () => { return '' } }).get(g2 ? 'max' : 'current') || '';
+ }
+ });
+ }
+ dispatchForSelected(apitrigger, nextindex + 1);
+ } else { // api generated call to another script, copy in the appropriate data
+ if (manageState.get('autoinsert').includes('selected')) {
+ if (preservedMsgObj[maintrigger].selected && preservedMsgObj[maintrigger].selected.length) {
+ msg.selected = preservedMsgObj[maintrigger].selected;
+ }
+ if (!msg.selected || (msg.selected && !msg.selected.length)) {
+ delete msg.selected;
+ }
+ }
+ if (manageState.get('autoinsert').includes('who') && !manageState.get('knownsenders').includes(msg.who)) {
+ msg.who = preservedMsgObj[maintrigger].who;
+ }
+ if (manageState.get('autoinsert').includes('playerid')) {
+ msg.playerid = preservedMsgObj[maintrigger].playerid;
+ }
+ }
+ // replace ZeroFrame trigger, if it's there
+ if (msg.apitrigger) msg.content = `!${msg.apitrigger}${msg.content.slice(1)}`;
+ }
+ msg.content = msg.content.replace(/\({&br-sm}\)/g, ' \n');
+ return condensereturn(funcret, status, notes);
+ };
+ const handleForSelected = (msg) => {
+ if (msg.type !== 'api' || !fsrx.test(msg.content)) return;
+ forselected(msg);
+ };
+ const getProp = (prop) => {
+ return preservedMsgObj[maintrigger][prop] || undefined;
+ };
+ const getSelected = () => getProp('selected');
+ const getWho = () => getProp('who');
+ const getPlayerID = () => getProp('playerid');
+
+ const checkDependencies = (deps) => {
+ /* pass array of objects like
+ { name: 'ModName', version: '#.#.#' || '', mod: ModName || undefined, checks: [ [ExposedItem, type], [ExposedItem, type] ] }
+ */
+ const dependencyEngine = (deps) => {
+ const versionCheck = (mv, rv) => {
+ let modv = [...mv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ let reqv = [...rv.split('.'), ...Array(4).fill(0)].slice(0, 4);
+ return reqv.reduce((m, v, i) => {
+ if (m.pass || m.fail) return m;
+ if (i < 3) {
+ if (parseInt(modv[i]) > parseInt(reqv[i])) m.pass = true;
+ else if (parseInt(modv[i]) < parseInt(reqv[i])) m.fail = true;
+ } else {
+ // all betas are considered below the release they are attached to
+ if (reqv[i] === 0 && modv[i] === 0) m.pass = true;
+ else if (modv[i] === 0) m.pass = true;
+ else if (reqv[i] === 0) m.fail = true;
+ else if (parseInt(modv[i].slice(1)) >= parseInt(reqv[i].slice(1))) m.pass = true;
+ }
+ return m;
+ }, { pass: false, fail: false }).pass;
+ };
+
+ let result = { passed: true, failures: {}, optfailures: {} };
+ deps.forEach(d => {
+ let failObj = d.optional ? result.optfailures : result.failures;
+ if (!d.mod) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = 'Not found';
+ return;
+ }
+ if (d.version && d.version.length) {
+ if (!(API_Meta[d.name].version && API_Meta[d.name].version.length && versionCheck(API_Meta[d.name].version, d.version))) {
+ if (!d.optional) result.passed = false;
+ failObj[d.name] = `Incorrect version. Required v${d.version}. ${API_Meta[d.name].version && API_Meta[d.name].version.length ? `Found v${API_Meta[d.name].version}` : 'Unable to tell version of current.'}`;
+ return;
+ }
+ }
+ d.checks.reduce((m, c) => {
+ if (!m.passed) return m;
+ let [pname, ptype] = c;
+ if (!d.mod.hasOwnProperty(pname) || typeof d.mod[pname] !== ptype) {
+ if (!d.optional) m.passed = false;
+ failObj[d.name] = `Incorrect version.`;
+ }
+ return m;
+ }, result);
+ });
+ return result;
+ };
+ let depCheck = dependencyEngine(deps);
+ let failures = '', contents = '', msg = '';
+ if (Object.keys(depCheck.optfailures).length) { // optional components were missing
+ failures = Object.keys(depCheck.optfailures).map(k => `• ${k} : ${depCheck.optfailures[k]}`).join(' ');
+ contents = `${apiproject} utilizies one or more other scripts for optional features, and works best with those scripts installed. You can typically find these optional scripts in the 1-click Mod Library: ${failures}`;
+ msg = ``;
+ sendChat(apiproject, `/w gm ${msg}`);
+ }
+ if (!depCheck.passed) {
+ failures = Object.keys(depCheck.failures).map(k => `• ${k} : ${depCheck.failures[k]}`).join(' ');
+ contents = `${apiproject} requires other scripts to work. Please use the 1-click Mod Library to correct the listed problems: ${failures}`;
+ msg = ``;
+ sendChat(apiproject, `/w gm ${msg}`);
+ return false;
+ }
+ return true;
+ };
+
+
+ let scriptisplugin = false;
+ const selectmanager = (m, s) => handleInput(m, s);
+ on('chat:message', handleInput);
+ setTimeout(() => { on('chat:message', handleForSelected) }, 0);
+ on('ready', () => {
+ versionInfo();
+ logsig();
+ let reqs = [
+ {
+ name: 'libTokenMarkers',
+ version: `0.1.2`,
+ mod: typeof libTokenMarkers !== 'undefined' ? libTokenMarkers : undefined,
+ checks: [['getStatus', 'function'], ['getStatuses', 'function'], ['getOrderedList', 'function']]
+ },
+ {
+ name: 'Messenger',
+ version: `1.0.0`,
+ mod: typeof Messenger !== 'undefined' ? Messenger : undefined,
+ checks: [['Button', 'function'], ['MsgBox', 'function'], ['HE', 'function'], ['Html', 'function'], ['Css', 'function']]
+ }
+ ];
+ if (!checkDependencies(reqs)) return;
+ html = Messenger.Html();
+ css = Messenger.Css();
+ HE = Messenger.HE;
+
+ oldmarkerrx = new RegExp(`^(\\+|-)(${libTokenMarkers.getOrderedList().map(o => o.name).join('|')})`);
+
+ issueVersionUpdateMessages();
+
+ scriptisplugin = (typeof ZeroFrame !== `undefined`);
+ if (typeof ZeroFrame !== 'undefined') {
+ ZeroFrame.RegisterMetaOp(selectmanager, { priority: 20, handles: ['sm'] });
+ }
+ on('chat:message', handleConfig);
+ });
+
+ return { // public interface
+ GetSelected: getSelected,
+ GetWho: getWho,
+ GetPlayerID: getPlayerID
+ };
+
+})();
+{ try { throw new Error(''); } catch (e) { API_Meta.SelectManager.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.SelectManager.offset); } }
+/* */
diff --git a/SelectManager/script.json b/SelectManager/script.json
index eb007c118..4effb275a 100644
--- a/SelectManager/script.json
+++ b/SelectManager/script.json
@@ -1,7 +1,7 @@
{
"name": "SelectManager",
"script": "SelectManager.js",
- "version": "1.1.10",
+ "version": "1.1.12",
"description": "SelectManager stores the selected, who, and playerid properties from the last user-generated message (as opposed to API generated), and makes them available for another script to retrieve. This solves the problem of an API-generated message not having the original array of selected tokens, for instance. \r\rIt also provides a !forselected handle to iterate over the selected tokens, firing off an individual call to another script for each token in turn, making each the selected token.\r\rFinally, it offers a way to virtually select tokens for the message, or to inject tokens into the selected token array.\r\rFor more information, see the original thread in the API forum:\r\r[SelectManager Forum Thread](https://app.roll20.net/forum/post/9817678/script-selectmanager-update-brings-forselected-iteration-and-gives-user-new-control-to-give-selected-tokens-back-to-api-generated-messages)\r\rOr read about the full set of meta-scripts available: \r\r[Meta Toolbox Forum Thread](https://app.roll20.net/forum/post/10005695/script-set-the-meta-toolbox)",
"authors": "timmaugh, The Aaron",
"roll20userid": "5962076, 104025",
@@ -34,6 +34,8 @@
"1.1.6",
"1.1.7",
"1.1.8",
- "1.1.10"
+ "1.1.9",
+ "1.1.10",
+ "1.1.11"
]
}