diff --git a/ResourceTracker/0.5.6/ResourceTracker.js b/ResourceTracker/0.5.6/ResourceTracker.js new file mode 100644 index 000000000..9d71349e3 --- /dev/null +++ b/ResourceTracker/0.5.6/ResourceTracker.js @@ -0,0 +1,702 @@ +// ResourceTracker v0.5.6 — per-character resource tracker for Roll20 +// 0.5.6: +// • NEW: Player notification modes: all | recover | none +// -> !res config notify all|recover|none +// 'recover' = only recover messages go to players; GM always mirrored. +// • Keeps: names-only whispers (no player|id), recover shows per-item rolls, +// robust linkbar via RT_ attribute, auto-spend, ScriptCards arg +// cleaning, enable/disable, player modes, debug/diag, menus, aliases, automap. + +on('ready', () => { + const RT = (() => { + const MOD = 'ResourceTracker'; + const CMD = '!res'; + const VERSION = '0.5.6'; + + // ---------- utils ---------- + const H = (s) => `
${s}
`; + const esc = (s)=>String(s).replace(/[<>&'"]/g,c=>({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c])); + const decode = (s)=>String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,"'"); + const unq = (s)=> s ? s.replace(/^"(.*)"$/,'$1') : s; + const deUS = (s)=> s ? String(s).replace(/^_+/, '') : s; // ScriptCards underscore prefix + const deDD = (s)=> s ? String(s).replace(/^--/, '') : s; // ScriptCards leading dashes + const clean = (s)=> unq(deDD(deUS(String(s||'')))); + const toInt = (s, def)=>{ const n = parseInt(clean(s),10); return Number.isFinite(n) ? n : def; }; + const getKV = (parts, key) => { + for (const e of parts) { + const s1 = deUS(String(e||'')); const s2 = deDD(s1); + const m = s2.match(new RegExp(`^${key}[=|](.+)$`, 'i')); + if (m) return unq(m[1]); + } + return null; + }; + + // Names-only whispering + const whisperTo = (who, html) => { + const q = String.fromCharCode(34); // " + const target = (!who || who==='gm') ? 'gm' : (q + String(who).replace(/"/g,'\\"') + q); + const msg = `/w ${target} ${H(html)}`; + if (state[MOD] && state[MOD].debug) { + log(`${MOD} whisperTo -> ${msg}`); + sendChat(MOD, `/w gm ${H(`DEBUG whisper: ${esc(msg)}`)}`, null, { noarchive: true }); + } + sendChat(MOD, msg, null, { noarchive: true }); + }; + const whisperGM = (html)=> whisperTo('gm', html); + + // ---------- state ---------- + const firstRun = !state[MOD]; + const assertState = () => { + const s = (state[MOD] = state[MOD] || {}); + s.data = s.data || {}; // { [charId]: { [resName]: {cur,max,thresh,bar,rest,attrId?} } } + s.automap = s.automap || {}; // { [charId]: { [attackName]: resName } } + s.aliases = s.aliases || {}; // { [attackName]: { resource, cost } } + s.config = s.config || {}; + if (typeof s.config.recoverBreak !== 'number') s.config.recoverBreak = 2; + if (typeof s.config.autospend !== 'boolean') s.config.autospend = true; + if (!s.config.playerMode) s.config.playerMode = 'partial'; // none|partial|full + s.config.notifyMode = 'names'; // names-only delivery transport + if (!s.config.playerNotify) s.config.playerNotify = 'all'; // all|recover|none + if (typeof s.enabled !== 'boolean') s.enabled = firstRun ? false : true; + if (!s.created) s.created = (new Date()).toISOString(); + if (typeof s.debug !== 'boolean') s.debug = false; + return s; + }; + + const isGM = (pid)=>{ + const p = getObj('player', pid); + return p ? playerIsGM(p.get('_id')) : false; + }; + + // ---------- players/controllers ---------- + const controllerPlayers = (ch)=>{ + const out = { players: [], hasAll: false }; + const ctl = (ch.get('controlledby')||'') + .split(',') + .map(s=>s.trim()) + .filter(Boolean); + ctl.forEach(x=>{ + if (x === 'all') { out.hasAll = true; return; } + const p = /^[A-Za-z0-9_-]{20,}$/.test(x) ? getObj('player', x) : null; + if (p) out.players.push(p); + else out.players.push({ id: null, get: (k)=> (k==='displayname' ? x : '') }); + }); + return out; + }; + + // Notify with kind-based player filtering + const shouldPlayerSee = (kind)=>{ + const mode = assertState().config.playerNotify || 'all'; + if (mode === 'none') return false; + if (mode === 'recover') return (kind === 'recover'); + return true; // 'all' + }; + + const notifyByKind = (ch, htmlLine, playerid, kind='info') => { + const recips = new Set(); + if (shouldPlayerSee(kind)) { + const { players } = controllerPlayers(ch); + players.forEach(p=>{ const dn = p.get && p.get('displayname'); if (dn) recips.add(dn); }); + if (playerid) { const p = getObj('player', playerid); if (p) recips.add(p.get('displayname')); } + } + recips.add('gm'); + const line = `${ch.get('name')}: ${htmlLine}`; + recips.forEach(t => whisperTo(t, line)); + }; + + // ---------- character context ---------- + const getAnyTokenForChar = (cid)=>{ + const ts = findObjs({ _type:'graphic', _subtype:'token', represents: cid }) || []; + return ts[0] || null; + }; + const getAllTokensForChar = (cid)=>{ + return findObjs({ _type:'graphic', _subtype:'token', represents: cid }) || []; + }; + const getCharFromSelection = (msg)=>{ + const s = msg.selected && msg.selected[0]; + if(!s || s._type!=='graphic') return null; + const g = getObj('graphic', s._id); if(!g) return null; + const cid = g.get('represents'); if(!cid) return null; + const ch = getObj('character', cid); if(!ch) return null; + return { ch, token:g }; + }; + const parseCharOverride = (parts, msg)=>{ + let name = null, cid = null; + for (let i=2; i parseCharOverride(parts, msg) || getCharFromSelection(msg) || null; + + // ---------- data ---------- + const charData = (cid)=>{ + const s = assertState(); + s.data[cid] = s.data[cid] || {}; + return s.data[cid]; + }; + const charAuto = (cid)=>{ + const s = assertState(); + s.automap[cid] = s.automap[cid] || {}; + return s.automap[cid]; + }; + + // ---------- attributes + bars ---------- + const normAttrName = (resName)=> `RT_${String(resName||'').replace(/\s+/g,'_')}`; + const getOrCreateAttr = (cid, resName)=>{ + const name = normAttrName(resName); + let a = findObjs({ _type:'attribute', _characterid: cid, name })[0]; + if (!a) a = createObj('attribute', { _characterid: cid, name, current: 0, max: 0 }); + return a; + }; + + const ensureLinkAndSync = (token, ch, resName, r) => { + if (!token || !r || !r.bar) return; + const attr = getOrCreateAttr(ch.id, resName); + attr.set({ current: r.cur, max: r.max }); + const linkProp = `${r.bar}_link`; + const valProp = `${r.bar}_value`; + const maxProp = `${r.bar}_max`; + if (token.get(linkProp) !== attr.id) token.set(linkProp, attr.id); + token.set(valProp, r.cur); + token.set(maxProp, r.max); + r.attrId = attr.id; + }; + + // Back-compat sync; always keeps attribute current even if no token is available + const syncBar = (token, ch, resName, r) => { + if(!r) return; + try { + // Always keep attribute in sync + let attr = r.attrId ? getObj('attribute', r.attrId) : null; + if (!attr) { attr = getOrCreateAttr(ch.id, resName); r.attrId = attr.id; } + attr.set({ current: r.cur, max: r.max }); + + if (token && r.bar) { + const linkProp = `${r.bar}_link`; + const valProp = `${r.bar}_value`; + const maxProp = `${r.bar}_max`; + if (token.get(linkProp) !== r.attrId) token.set(linkProp, r.attrId); + token.set(valProp, r.cur); + token.set(maxProp, r.max); + } + } catch(e) { + if (assertState().debug) whisperGM(`syncBar error: ${esc(String(e))}`); + log(`${MOD} syncBar error: ${e.stack||e}`); + } + }; + + const resourceLine = (name, r)=>{ + const warn = (r.thresh!=null && r.cur<=r.thresh) ? ' ⚠' : ''; + const bar = r.bar ? ` • ${r.bar}` : ''; + const attr = r.attrId ? ' • attr=linked' : ''; + const theRest = r.rest && r.rest!=='none' ? ` • rest=${r.rest}` : ''; + return `• ${esc(name)}: ${r.cur}/${r.max}${warn}${bar}${attr}${theRest}`; + }; + + // ---------- permissions ---------- + const partialSubs = new Set(['use','list','menu','recover']); + const fullExtra = new Set(['create','add','set','delete','thresh','resttag','reset','linkbar']); + const playerAllowed = (playerId, sub) => { + const s = assertState(); + if (playerIsGM(playerId)) return true; + if (!s.enabled) return false; + switch (s.config.playerMode) { + case 'none': return false; + case 'partial': return partialSubs.has(sub); + case 'full': return partialSubs.has(sub) || fullExtra.has(sub); + default: return partialSubs.has(sub); + } + }; + + // ---------- roll parsing (auto-spend) ---------- + const getCharIdFromRoll = (msg)=>{ + if(!msg.content) return null; + let m = msg.content.match(/{{\s*(?:character_id|charid)\s*=\s*([-\w]+)\s*}}/i); + if(m) { const ch = getObj('character', m[1]); if(ch) return ch.id; } + m = msg.content.match(/{{\s*(?:charname|character_name|charactername)\s*=\s*([^}]+)}}/i); + if(m) { const name = decode(m[1]).trim(); const cs = findObjs({ _type:'character', name }); if(cs && cs.length) return cs[0].id; } + if(msg.playerid && msg.selected && msg.selected.length===1){ + const s = msg.selected[0]; + if(s._type==='graphic'){ + const g = getObj('graphic', s._id); + if(g){ + const cid = g.get('represents'); + const ch = cid && getObj('character', cid); + if(ch) return ch.id; + } + } + } + return null; + }; + const getAttackNameFromRoll = (msg)=>{ + if(!msg.content) return null; + const order = ['rname','name','title','weapon','spell','item','itemname']; + for(const k of order){ + const re = new RegExp(`{{\\s*${k}\\s*=\\s*([^}]+)}}`, 'i'); + const m = msg.content.match(re); + if(m){ return decode(m[1]).trim(); } + } + const m2 = msg.content.match(/\*\*([^*]+)\*\*/); + if(m2) return decode(m2[1]).trim(); + return null; + }; + const resolveSpendTarget = (cid, attackName)=>{ + const s = assertState(); + const auto = s.automap && s.automap[cid] || null; + if(auto && auto[attackName]) return { resource: auto[attackName], cost: 1 }; + const a = s.aliases && s.aliases[attackName]; + if(a && a.resource) return { resource: a.resource, cost: (Number.isFinite(a.cost) && a.cost>0)?a.cost:1 }; + return null; + }; + + return { + MOD, CMD, VERSION, + assertState, isGM, + resolveCtx, getAnyTokenForChar, getAllTokensForChar, charData, charAuto, + resourceLine, ensureLinkAndSync, + clean, toInt, getKV, syncBar, unq, + getCharIdFromRoll, getAttackNameFromRoll, resolveSpendTarget, + whisperGM, esc, + controllerPlayers, playerAllowed, notifyByKind, shouldPlayerSee + }; + })(); + + const S = RT.assertState(); + + // Startup whisper + if (S.enabled) { + log(`${RT.MOD} v${RT.VERSION} loaded (ENABLED).`); + sendChat(RT.MOD, `/w gm ${RT.MOD} v${RT.VERSION} loaded (ENABLED). Type ${RT.CMD} help.`); + } else { + log(`${RT.MOD} v${RT.VERSION} loaded (DISABLED).`); + sendChat(RT.MOD, `/w gm ${RT.MOD} v${RT.VERSION} loaded (DISABLED). Run ${RT.CMD} enable to activate.`); + } + + // Wrap runner to surface exceptions when debug is on + const runSafe = (label, fn) => { + try { fn(); } + catch(e){ + log(`${RT.MOD} ${label} error: ${e.stack||e}`); + if (state[RT.MOD] && state[RT.MOD].debug) { + sendChat(RT.MOD, `/w gm ${RT.esc(label)} error: ${RT.esc(String(e))}`); + } + } + }; + + // ---- AUTO-SPEND from sheet rolls ---- + on('chat:message', (msg)=>{ + runSafe('auto', ()=>{ + const st = RT.assertState(); + if(!st.enabled || !st.config.autospend) return; + if(!(msg.type === 'rollresult' || msg.type === 'general') || !msg.content) return; + + const cid = RT.getCharIdFromRoll(msg); + if(!cid) return; + + if(!RT.isGM(msg.playerid) && st.config.playerMode === 'none') return; + + const attackName = RT.getAttackNameFromRoll(msg); + if(!attackName) return; + + const target = RT.resolveSpendTarget(cid, attackName); + if(!target) return; + + const ch = getObj('character', cid); if(!ch) return; + + if(!RT.isGM(msg.playerid)) { + const ctl = (ch.get('controlledby')||'').split(',').map(s=>s.trim()); + if(!(ctl.includes('all') || ctl.includes(msg.playerid))) return; + } + + const token = RT.getAnyTokenForChar(cid); + const data = RT.charData(cid); + const r = data[target.resource]; + if(!r) return; // do not auto-create + r.cur = Math.max(0, Math.min(r.max, r.cur - target.cost)); + RT.syncBar(token, ch, target.resource, r); + RT.notifyByKind(ch, `${RT.esc(target.resource)} → ${r.cur}/${r.max}`, msg.playerid, 'use'); // follows playerNotify mode + }); + }); + + // ---- COMMAND HANDLER ---- + on('chat:message', (msg)=>{ + if(msg.type !== 'api' || !msg.content.startsWith(RT.CMD)) return; + runSafe('cmd', ()=>{ + const st = RT.assertState(); + const rawParts = msg.content.trim().match(/(?:[^\s"]+|"[^"]*")+/g) || []; + const sub = (RT.clean(rawParts[1])||'').toLowerCase(); + const arg1 = RT.clean(rawParts[2]); + const arg2 = RT.clean(rawParts[3]); + const parts = rawParts; + + const isGM = RT.isGM(msg.playerid); + const allowWhenDisabled = new Set(['help','?','status','enable','config','debug','diag']); + if(!st.enabled && !allowWhenDisabled.has(sub)) { + if(isGM) RT.whisperGM(`Script is DISABLED. Run ${RT.CMD} enable to activate. For settings: ${RT.CMD} status.`); + return; + } + + // HELP + if(!sub || sub==='help' || sub==='?'){ + return RT.whisperGM([ + `${RT.CMD} — v${RT.VERSION} (enabled=${st.enabled ? 'yes':'no'}, playerMode=${st.config.playerMode}, autospend=${st.config.autospend?'on':'off'}, debug=${st.debug?'on':'off'}, notify=names, playerNotify=${st.config.playerNotify})`, + `Admin — ${RT.CMD} enable | ${RT.CMD} disable | ${RT.CMD} status | ${RT.CMD} debug on|off | ${RT.CMD} config autospend on|off | ${RT.CMD} config playermode none|partial|full | ${RT.CMD} config recover <0..100> | ${RT.CMD} config notify all|recover|none`, + `GM/Player — create/use/add/set/delete/thresh/resttag/reset/list/menu/linkbar/recover; alias/automap; diag.` + ].join('
')); + } + + // DEBUG + if(sub==='debug'){ if(!isGM) return; + const v = (arg1||'').toLowerCase(); + if(!/^(on|off)$/.test(v)) return RT.whisperGM(`Usage: ${RT.CMD} debug on|off (cur=${st.debug?'on':'off'})`); + st.debug = (v==='on'); RT.whisperGM(`Debug is now ${st.debug?'ON':'OFF'}.`); return; + } + + // DIAG + if(sub==='diag'){ if(!isGM) return; + const ctx = RT.resolveCtx(msg, parts); + if(!ctx) return RT.whisperGM(`Select a represented token or add char="Name".`); + const { players, hasAll } = RT.controllerPlayers(ctx.ch); + const recips = new Set(); + players.forEach(p=>{ const dn = p.get('displayname'); if (dn) recips.add(dn); }); + recips.add('gm'); + if (msg.playerid) { const p = getObj('player', msg.playerid); if (p) recips.add(p.get('displayname')); } + RT.whisperGM(`Diag ${RT.esc(ctx.ch.get('name'))}
ControlledBy: ${players.map(p=>p.get('displayname')).join(', ')}${hasAll?' (all)':''}
Recipients now (mode=${st.config.playerNotify}): ${Array.from(recips).join(', ')}`); + RT.notifyByKind(ctx.ch, `Diag test line — you should see this if your mode allows it.`, msg.playerid, 'info'); + return; + } + + // ADMIN + if(sub==='enable'){ if(!isGM) return; st.enabled = true; RT.whisperGM('Enabled.'); return; } + if(sub==='disable'){ if(!isGM) return; st.enabled = false; RT.whisperGM('Disabled.'); return; } + if(sub==='status'){ if(!isGM) return; + return RT.whisperGM(`Status: enabled=${st.enabled?'yes':'no'}, playerMode=${st.config.playerMode}, autospend=${st.config.autospend?'on':'off'}, recoverDefault=${st.config.recoverBreak}%, notify=names, playerNotify=${st.config.playerNotify}, debug=${st.debug?'on':'off'}`); + } + if(sub==='config'){ + if(!isGM) return; + const key = (arg1||'').toLowerCase(); + if(key==='recover' || key==='recover-break'){ + const n = RT.toInt(arg2, NaN); + if(!Number.isFinite(n) || n<0 || n>100) return RT.whisperGM(`Usage: ${RT.CMD} config recover <0..100> (cur=${st.config.recoverBreak}%)`); + st.config.recoverBreak = n; RT.whisperGM(`Default recover break set to ${n}%.`); return; + } + if(key==='autospend'){ + const v = (arg2||'').toLowerCase(); if(!/^(on|off)$/.test(v)) return RT.whisperGM(`Usage: ${RT.CMD} config autospend on|off (cur=${st.config.autospend?'on':'off'})`); + st.config.autospend = (v==='on'); RT.whisperGM(`Auto-spend is now ${st.config.autospend?'ON':'OFF'}.`); return; + } + if(key==='playermode'){ + const v = (arg2||'').toLowerCase(); if(!/^(none|partial|full)$/.test(v)) return RT.whisperGM(`Usage: ${RT.CMD} config playermode none|partial|full (cur=${st.config.playerMode})`); + st.config.playerMode = v; RT.whisperGM(`Player mode set to ${v}.`); return; + } + if(key==='notify'){ + const v = (arg2||'').toLowerCase(); + if(!/^(all|recover|none)$/.test(v)) return RT.whisperGM(`Usage: ${RT.CMD} config notify all|recover|none (cur=${st.config.playerNotify})`); + st.config.playerNotify = v; + RT.whisperGM(`Player notification mode set to ${v}.`); + return; + } + return RT.whisperGM(`Usage: ${RT.CMD} config autospend on|off | ${RT.CMD} config playermode none|partial|full | ${RT.CMD} config recover 0..100 | ${RT.CMD} config notify all|recover|none`); + } + + // Character context + perms + const needChar = ()=>{ + const ctx = RT.resolveCtx(msg, parts); + if(!ctx){ RT.whisperGM(`Select a token that represents a character or add char="Name" to the command.`); return null; } + if(!RT.isGM(msg.playerid) && !RT.playerAllowed(msg.playerid, sub)) { RT.whisperGM(`Blocked: player mode '${S.config.playerMode}' disallows '${sub}'.`); return null; } + if(!RT.isGM(msg.playerid)) { + const ctl = (ctx.ch.get('controlledby')||'').split(',').map(s=>s.trim()); + if(!(ctl.includes('all') || ctl.includes(msg.playerid))) { RT.whisperGM(`Blocked: you do not control ${RT.esc(ctx.ch.get('name'))}.`); return null; } + } + return ctx; + }; + + // ---- Core verbs ---- + const requiresCharSubs = new Set(['create','use','sub','add','set','delete','thresh','resttag','reset','list','menu','linkbar','recover']); + if(requiresCharSubs.has(sub)){ + const ctx = needChar(); if(!ctx) return; + + if(sub==='create'){ if(!isGM && S.config.playerMode!=='full') return; + const name = RT.clean(rawParts[2]); + const max = RT.toInt(rawParts[3], NaN); + let curArg = null, restArg = 'none'; + for (let i = 4; i < rawParts.length; i++) { + const p0 = RT.clean(String(rawParts[i]||'')); + if (/^rest[=|]/i.test(p0)) restArg = RT.unq(p0.split(/[=|]/)[1] || 'none'); + else { const maybe = parseInt(RT.unq(p0),10); if(Number.isFinite(maybe)) curArg = maybe; } + } + if(!name || !Number.isFinite(max)) return RT.whisperGM(`Usage: ${RT.CMD} create <name> <max> [current] [rest=none|short|long] [char=...]`); + const d = RT.charData(ctx.ch.id); + const r = (d[name] = d[name] || {cur:0,max:0,thresh:null,bar:null,rest:'none'}); + r.max = Math.max(0,max); + r.cur = Math.max(0, Math.min(r.max, (curArg==null?max:curArg))); + r.rest = /^(short|long)$/i.test(restArg) ? restArg.toLowerCase() : 'none'; + RT.notifyByKind(ctx.ch, `Created ${RT.esc(name)} ${r.cur}/${r.max}${r.rest!=='none'?` (rest=${r.rest})`:''}`, msg.playerid, 'create'); + return; + } + + if(sub==='use' || sub==='sub'){ + const name = RT.clean(rawParts[2]); + const n = RT.toInt(rawParts[3], 1); + if(!name || !Number.isFinite(n)) return RT.whisperGM(`Usage: ${RT.CMD} ${sub} <name> [n=1] [char=...]`); + const d = RT.charData(ctx.ch.id); + const r = d[name]; + if(!r){ RT.whisperGM(`${ctx.ch.get('name')}: No resource named ${RT.esc(name)}.`); return; } + if(r.cur < n){ RT.notifyByKind(ctx.ch, `❌ Out of ${RT.esc(name)}! Have ${r.cur}, need ${n}.`, msg.playerid, 'use'); return; } + r.cur = Math.max(0, Math.min(r.max, r.cur - n)); + RT.syncBar(ctx.token || RT.getAnyTokenForChar(ctx.ch.id), ctx.ch, name, r); + RT.notifyByKind(ctx.ch, `${RT.esc(name)} → ${r.cur}/${r.max}`, msg.playerid, 'use'); + if(r.thresh!=null && r.cur<=r.thresh){ RT.notifyByKind(ctx.ch, `${RT.esc(name)} low: ${r.cur}/${r.max} (≤ ${r.thresh})`, msg.playerid, 'use'); } + return; + } + + if(sub==='add'){ if(!isGM && S.config.playerMode!=='full') return; + const name = RT.clean(rawParts[2]); const n = RT.toInt(rawParts[3], NaN); + if(!name || !Number.isFinite(n)) return RT.whisperGM(`Usage: ${RT.CMD} add <name> <n> [char=...]`); + const d = RT.charData(ctx.ch.id); const r = d[name] || (d[name]={cur:0,max:0,thresh:null,bar:null,rest:'none'}); + r.cur = Math.max(0, Math.min(r.max, r.cur + n)); + RT.syncBar(ctx.token || RT.getAnyTokenForChar(ctx.ch.id), ctx.ch, name, r); + RT.notifyByKind(ctx.ch, `${RT.esc(name)} → ${r.cur}/${r.max}`, msg.playerid, 'add'); return; + } + + if(sub==='set'){ if(!isGM && S.config.playerMode!=='full') return; + const name = RT.clean(rawParts[2]); const n = RT.toInt(rawParts[3], NaN); + if(!name || !Number.isFinite(n)) return RT.whisperGM(`Usage: ${RT.CMD} set <name> <n> [char=...]`); + const d = RT.charData(ctx.ch.id); const r = d[name] || (d[name]={cur:0,max:0,thresh:null,bar:null,rest:'none'}); + r.cur = Math.max(0, Math.min(r.max, n)); + RT.syncBar(ctx.token || RT.getAnyTokenForChar(ctx.ch.id), ctx.ch, name, r); + RT.notifyByKind(ctx.ch, `${RT.esc(name)} set to ${r.cur}/${r.max}`, msg.playerid, 'set'); return; + } + + if(sub==='delete'){ if(!isGM && S.config.playerMode!=='full') return; + const name = RT.clean(rawParts[2]); if(!name) return RT.whisperGM(`Usage: ${RT.CMD} delete <name> [char=...]`); + const d = RT.charData(ctx.ch.id); + if(d[name]) { delete d[name]; RT.notifyByKind(ctx.ch, `Deleted resource ${RT.esc(name)}.`, msg.playerid, 'delete'); } + else RT.notifyByKind(ctx.ch, `No resource named ${RT.esc(name)}.`, msg.playerid, 'delete'); + return; + } + + if(sub==='thresh'){ if(!isGM && S.config.playerMode!=='full') return; + const name = RT.clean(rawParts[2]); const n = RT.toInt(rawParts[3], NaN); + if(!name || !Number.isFinite(n)) return RT.whisperGM(`Usage: ${RT.CMD} thresh <name> <n> [char=...]`); + const d = RT.charData(ctx.ch.id); const r = d[name] || (d[name]={cur:0,max:0,thresh:null,bar:null,rest:'none'}); + r.thresh = Math.max(0, n); + RT.notifyByKind(ctx.ch, `${RT.esc(name)} threshold set to ≤ ${r.thresh}`, msg.playerid, 'thresh'); return; + } + + if(sub==='resttag'){ if(!isGM && S.config.playerMode!=='full') return; + const name = RT.clean(rawParts[2]); const tag = (RT.clean(rawParts[3])||'').toLowerCase(); + if(!name || !/^(none|short|long)$/.test(tag)) return RT.whisperGM(`Usage: ${RT.CMD} resttag <name> <none|short|long> [char=...]`); + const d = RT.charData(ctx.ch.id); const r = d[name] || (d[name]={cur:0,max:0,thresh:null,bar:null,rest:'none'}); + r.rest = tag; RT.notifyByKind(ctx.ch, `${RT.esc(name)} rest tag set to ${tag}.`, msg.playerid, 'resttag'); return; + } + + if(sub==='reset'){ + const which = (arg1||'').toLowerCase(); + if(!isGM && S.config.playerMode!=='full') return; + const d = RT.charData(ctx.ch.id); + if(which==='all'){ + Object.values(d).forEach(r=> r.cur = r.max); + Object.entries(d).forEach(([nm,r])=> RT.syncBar(ctx.token || RT.getAnyTokenForChar(ctx.ch.id), ctx.ch, nm, r)); + RT.notifyByKind(ctx.ch, `All resources reset to max.`, msg.playerid, 'reset'); + } else { + if(!which) return RT.whisperGM(`Usage: ${RT.CMD} reset <name|all> [char=...]`); + const r = d[which] || (d[which]={cur:0,max:0,thresh:null,bar:null,rest:'none'}); + r.cur = r.max; + RT.syncBar(ctx.token || RT.getAnyTokenForChar(ctx.ch.id), ctx.ch, which, r); + RT.notifyByKind(ctx.ch, `${RT.esc(which)} reset to ${r.cur}/${r.max}`, msg.playerid, 'reset'); + } + return; + } + + if(sub==='list'){ + if(arg1 && arg1.toLowerCase()==='all'){ if(!isGM) return; + const chunks = Object.keys(st.data).map(cid0=>{ + const ch0 = getObj('character', cid0); + const name = ch0 ? ch0.get('name') : `(deleted ${cid0})`; + return `${RT.esc(name)}
${Object.keys(st.data[cid0]||{}).sort().map(n=>RT.resourceLine(n, st.data[cid0][n])).join('
') || '(no resources)'}`; + }).join('
') || '(no characters tracked)'; + return RT.whisperGM(`All characters
${chunks}`); + } + const d = RT.charData(ctx.ch.id); + const lines = Object.keys(d).sort().map(n=>RT.resourceLine(n, d[n])).join('
') || '(no resources)'; + RT.notifyByKind(ctx.ch, `
${lines}`, msg.playerid, 'list'); + return; + } + + if(sub==='menu'){ + const d = RT.charData(ctx.ch.id); + const names = Object.keys(d).sort((a,b)=>a.localeCompare(b)); + if(!names.length) { + RT.notifyByKind(ctx.ch, `${RT.esc(ctx.ch.get('name'))}
(no resources)
[Create sample: Arrows 20](${RT.CMD} create Arrows 20 char="${ctx.ch.get('name')}" )`, msg.playerid, 'menu'); + return; + } + const rows = names.map(n=>{ + const r = d[n]; + return `${RT.esc(n)} ${r.cur}/${r.max}` + + ` [−1](${RT.CMD} use ${n} 1 char="${ctx.ch.get('name')}" ) [−5](${RT.CMD} use ${n} 5 char="${ctx.ch.get('name')}" )` + + ` [+1](${RT.CMD} add ${n} 1 char="${ctx.ch.get('name')}" ) [Reset](${RT.CMD} reset ${n} char="${ctx.ch.get('name')}" )` + + ` [Set…](${RT.CMD} set ${n} ? char="${ctx.ch.get('name')}" ) [Thresh…](${RT.CMD} thresh ${n} ? char="${ctx.ch.get('name')}" )` + + ` [RestTag…](${RT.CMD} resttag ${n} ? char="${ctx.ch.get('name')}" )`; + }); + RT.notifyByKind(ctx.ch, `${RT.esc(ctx.ch.get('name'))}
${rows.join('
')}`, msg.playerid, 'menu'); + return; + } + + if(sub==='linkbar'){ if(!isGM && S.config.playerMode!=='full') return; + const name = RT.clean(rawParts[2]); const which = (RT.clean(rawParts[3])||'').toLowerCase(); + const allFlag = (RT.clean(rawParts[4])||'').toLowerCase(); + if(!name || !/^bar[123]$/.test(which)) return RT.whisperGM(`Usage: ${RT.CMD} linkbar <name> <bar1|bar2|bar3> [all] [char=...]`); + const d = RT.charData(ctx.ch.id); const r = d[name] || (d[name]={cur:0,max:0,thresh:null,bar:null,rest:'none'}); + + r.bar = which; + const tokens = (allFlag==='all') ? RT.getAllTokensForChar(ctx.ch.id) : [ctx.token || RT.getAnyTokenForChar(ctx.ch.id)]; + RT.ensureLinkAndSync(tokens[0], ctx.ch, name, r); + if (tokens.length > 1) tokens.slice(1).forEach(t => RT.syncBar(t, ctx.ch, name, r)); + RT.notifyByKind(ctx.ch, `Linked ${RT.esc(name)} to ${which} on ${tokens.length} token(s).`, msg.playerid, 'linkbar'); + return; + } + + if(sub==='recover'){ + const name = RT.clean(rawParts[2]); const n = RT.toInt(rawParts[3], NaN); + const b = RT.getKV(parts,'break'); + const breakPct = Math.min(100, Math.max(0, parseInt(b!=null ? b : String(st.config.recoverBreak),10))); + if(!name || !Number.isFinite(n) || n<=0) return RT.whisperGM(`Usage: ${RT.CMD} recover <name> <n> [break=0..100]`); + const d = RT.charData(ctx.ch.id); const r = d[name] || (d[name]={cur:0,max:0,thresh:null,bar:null,rest:'none'}); + let recovered=0, broken=0; + const rolls = []; + for(let i=0;i (x <= breakPct ? `${x}✗` : `${x}✓`)).join(', '); + const line = [ + `Recovering ${RT.esc(name)} (break ≤ ${breakPct})`, + `Rolls: ${rollsLine}`, + `Tried: ${n}, Survived: ${recovered}, Broken: ${broken}`, + `Applied: +${applied} (now ${r.cur}/${r.max})${overflow?`, Overflow: ${overflow}`:''}` + ].join('
'); + RT.notifyByKind(ctx.ch, line, msg.playerid, 'recover'); + return; + } + } + + // alias (GM) + if(sub==='alias'){ + if(!isGM) return; + const mode = (arg1||'').toLowerCase(); + if(mode==='list'){ + const lines = Object.entries(st.aliases||{}).map(([atk, obj])=>{ + const cost = (Number.isFinite(obj.cost) && obj.cost>0) ? obj.cost : 1; + return `• "${RT.esc(atk)}" → ${RT.esc(obj.resource)} (cost=${cost})`; + }).join('
') || '(no global aliases)'; + return RT.whisperGM(`Global Attack Aliases
${lines}`); + } + if(mode==='add'){ + const attack = RT.clean(rawParts[3]); + const resName= RT.clean(rawParts[4]); + const cv = RT.getKV(parts, 'cost'); + const cost = cv ? Math.max(1, parseInt(cv,10)) : 1; + if(!attack || !resName) return RT.whisperGM(`Usage: ${RT.CMD} alias add "Attack Name" "Resource Name" [cost=1]`); + st.aliases[attack] = { resource: resName, cost }; + return RT.whisperGM(`Global alias: "${RT.esc(attack)}" → ${RT.esc(resName)} (cost=${cost}).`); + } + if(mode==='del'){ + const attack = RT.clean(rawParts[3]); + if(!attack) return RT.whisperGM(`Usage: ${RT.CMD} alias del "Attack Name"`); + delete st.aliases[attack]; + return RT.whisperGM(`Global alias removed: "${RT.esc(attack)}".`); + } + return RT.whisperGM(`Usage: ${RT.CMD} alias add|del|list ...`); + } + + // automap (per-character) + if(sub==='automap'){ + if(!isGM && st.config.playerMode!=='full') return; + const mode = (arg1||'').toLowerCase(); + if(mode==='list'){ + const all = st.automap || {}; + const lines = Object.entries(all).flatMap(([k, m])=>{ + const c = getObj('character', k); + const name = c ? c.get('name') : `(deleted ${k})`; + const pairs = Object.entries(m).map(([atk,res])=>`• ${RT.esc(name)}: "${RT.esc(atk)}" → ${RT.esc(res)}`); + return pairs.length ? pairs : [`• ${RT.esc(name)}: (no mappings)`]; + }).join('
') || '(no mappings)'; + return RT.whisperGM(`Per-Character Automaps
${lines}`); + } + const ctx = RT.resolveCtx(msg, parts); if(!ctx){ return RT.whisperGM(`Select a represented token or add char="Name".`); } + if(!RT.isGM(msg.playerid)) { + const ctl = (ctx.ch.get('controlledby')||'').split(',').map(s=>s.trim()); + if(!(ctl.includes('all') || ctl.includes(msg.playerid))) return RT.whisperGM(`Blocked: you do not control ${RT.esc(ctx.ch.get('name'))}.`); + } + const auto = RT.charAuto(ctx.ch.id); + if(mode==='add'){ + const attack = RT.clean(rawParts[3]); + const resName= RT.clean(rawParts[4]); + if(!attack || !resName) return RT.whisperGM(`Usage: ${RT.CMD} automap add "Attack" "Resource" [char=...]`); + auto[attack] = resName; + return RT.whisperGM(`${ctx.ch.get('name')}: mapped "${RT.esc(attack)}" → ${RT.esc(resName)}.`); + } else if(mode==='del'){ + const attack = RT.clean(rawParts[3]); + if(!attack) return RT.whisperGM(`Usage: ${RT.CMD} automap del "Attack" [char=...]`); + delete auto[attack]; + return RT.whisperGM(`${ctx.ch.get('name')}: unmapped "${RT.esc(attack)}".`); + } + return RT.whisperGM(`Usage: ${RT.CMD} automap add "Attack" "Resource" [char=...] | ${RT.CMD} automap del "Attack" [char=...] | ${RT.CMD} automap list`); + } + + // per-char REST + if(sub==='rest'){ + const tier = (arg1||'').toLowerCase(); + const scope = (RT.clean(rawParts[3])||'').toLowerCase(); + if(!/^(short|long)$/.test(tier)) return RT.whisperGM(`Usage: ${RT.CMD} rest <short|long> [sel|all|page] [char=...]`); + + if(scope==='sel' || scope==='all' || scope==='page'){ + if(!isGM) return; + return RT.whisperGM(`GM rest scopes unchanged in v${RT.VERSION}.`); + } + const ctx = RT.resolveCtx(msg, parts); if(!ctx) return RT.whisperGM(`Select a represented token or add char="Name".`); + if(!RT.isGM(msg.playerid)) { + const ctl = (ctx.ch.get('controlledby')||'').split(',').map(s=>s.trim()); + if(!(ctl.includes('all') || ctl.includes(msg.playerid))) return RT.whisperGM(`Blocked: you do not control ${RT.esc(ctx.ch.get('name'))}.`); + } + const dm = RT.charData(ctx.ch.id); + let count=0; Object.entries(dm).forEach(([nm,r])=>{ if(r.rest===tier){ r.cur = r.max; RT.syncBar(ctx.token || RT.getAnyTokenForChar(ctx.ch.id), ctx.ch, nm, r); count++; }}); + RT.notifyByKind(ctx.ch, `${tier}-rest reset ${count} resource(s) to max.`, msg.playerid, 'reset'); + return; + } + + // Unknown + RT.whisperGM(`Unknown subcommand. Try ${RT.CMD} help.`); + }); + }); +}); diff --git a/ResourceTracker/README.md b/ResourceTracker/README.md new file mode 100644 index 000000000..9ee5a6237 --- /dev/null +++ b/ResourceTracker/README.md @@ -0,0 +1,317 @@ +# ResourceTracker — User’s Guide (v0.5.6) + +This guide shows GMs and players how to **track expendable resources** (arrows, javelins, charges) in Roll20 using ResourceTracker. You’ll learn setup, day‑to‑day use, ScriptCards integration, and how to keep token bars in sync. + +> On startup the script whispers its status to the GM and how to open help: `!res help`. + +--- + +## 1) Requirements + +- **Roll20 Pro** (API access). +- The **ResourceTracker v0.5.6** script pasted into your game’s API scripts and saved. +- If the script is available in Roll20's "One-Click" option on the API Script page. + +--- + +## 2) First‑Time Setup (GM) + +1. **Enable the script** (first run is disabled): + ```text + !res enable + ``` + +2. **Create a resource** for the selected character (e.g., Arrows with max 20): + ```text + !res create Arrows 20 + ``` + - Optional: set current different from max: `!res create Arrows 20 12` + - Optional: add a rest tag: `!res create Arrows 20 rest=none|short|long` + +3. **(Optional) Link a token bar** (e.g., bar2) so it mirrors the resource: + ```text + !res linkbar Arrows bar2 + ``` + - This auto‑creates/uses a character attribute `RT_Arrows` and links bar2 to it. + - If a character has multiple tokens (duplicates across pages): + `!res linkbar Arrows bar2 all` + +4. **(Optional) Configure what players see** (GM always sees everything): + ```text + !res config notify all # default: players see all updates + !res config notify recover # players see only recover messages (with rolls) + !res config notify none # players see nothing (GM-only) + ``` + +5. **(Optional) Player permission scope** (global): + ```text + !res config playermode partial # default: use/list/menu/recover allowed + # none -> players cannot use the API + # partial -> players: use, list, menu, recover + # full -> players: above + create/add/set/delete/thresh/resttag/reset/linkbar + ``` + +6. **(Optional) Auto‑spend after attacks** (default ON). Map an attack name → resource: + - Per‑character mapping (preferred): + ```text + !res automap add "Longbow" "Arrows" char="Sluggis" + ``` + - Global fallback for everyone: + ```text + !res alias add "Longbow" "Arrows" cost=1 + ``` + +--- + +## 3) Everyday Use + +### GM or Player (depending on permission mode) + +- **Spend / Use** + ```text + !res use Arrows 1 + ``` + +- **Add** + ```text + !res add Arrows 5 + ``` + +- **Set current exactly** + ```text + !res set Arrows 12 + ``` + +- **List resources (for the selected character)** + ```text + !res list + ``` + +- **Quick menu (buttons in chat)** + ```text + !res menu + ``` + +- **Low‑resource threshold (optional)** + ```text + !res thresh Arrows 5 + ``` + +- **Rest tag (controls which resources reset on short/long rest)** + ```text + !res resttag Arrows none|short|long + ``` + +- **Reset one or all to max** + ```text + !res reset Arrows + !res reset all + ``` + +- **Delete a resource** + ```text + !res delete Arrows + ``` + +> Tip: You can target a specific character without selecting a token by adding a character argument: +> - By name: `char="Sluggis"` +> - By id (safe in macros): `charid=@{selected|character_id}` + +--- + +## 4) Recovery with Breakage (and Visible Rolls) + +Recover arrows/javelins after a fight, with a percent chance each one broke. The individual d100 **rolls are shown** to players if `notify=all` or `notify=recover`. + +```text +!res recover Arrows 10 break=2 +``` +- Rolls 10× d100. Results ≤ 2 are broken (✗), others survive (✓). +- Applies up to your max. Overflow (extra survivors past max) is reported. +- Example output: + - `Rolls: 14✓, 78✓, 2✗, 55✓, 1✗, 60✓, 33✓, 5✗, 94✓, 71✓` + +**Set default breakage** (used when `break=` not provided): +```text +!res config recover 2 +``` + +--- + +## 5) ScriptCards Integration + +ResourceTracker is tolerant of ScriptCards’ argument prefixes (leading `_` and `--`). Use the `@` API call style: + +**Spend 1 Arrow (by character id):** +```text +--@res|use _Arrows _1 _charid|@{selected|character_id} +``` + +**Recover 5 Arrows with 2% breakage:** +```text +--@res|recover _Arrows _5 _break|2 _charid|@{selected|character_id} +``` + +**Create and reset during setup:** +```text +--@res|create _Arrows _20 _charid|@{selected|character_id} +--@res|reset _Arrows _charid|@{selected|character_id} +``` + +**Notes** +- Name‑based targeting also works: `_char|"Sluggis"` +- Quoted names are accepted: `"Magic Arrows"` + +--- + +## 6) Auto‑Spend from Attacks (Sheets or ScriptCards) + +After an attack roll appears in chat, the script tries to detect: +1. **Character** (from rolltemplate fields like `{{character_id=...}}` / `{{character_name=...}}` or selected token). +2. **Attack name** (template fields like `{{rname=...}}`, `{{name=...}}`, etc.). +3. A mapping: **per‑character automap** (first) or **global alias** (fallback). + +If found, it **spends** the mapped resource (default cost 1 or the alias’ `cost=`). It never auto‑creates resources—only spends if they exist. + +**Examples (GM once per character or globally):** +```text +!res automap add "Longbow" "Arrows" char="Sluggis" +!res alias add "Longbow" "Arrows" cost=1 +``` + +--- + +## 7) Token Bar Linking (Reliable) + +Keep a token bar synced with a resource by linking it to an attribute `RT_`: + +```text +!res linkbar Arrows bar2 +# for all tokens representing the same character: +!res linkbar Arrows bar2 all +``` + +- If a bar was previously linked to something else, this command re‑links it properly. +- The script also keeps the **underlying attribute** up to date even when no token is selected, so values stay correct. + +**Troubleshooting bar sync** +- If the bar doesn’t move, re‑issue `linkbar`. +- In the token UI, the bar should show it’s linked to `RT_Arrows` (or your resource name). + +--- + +## 8) Player Visibility & Permissions (GM) + +### Player notification modes (what players see) +```text +!res config notify all # players see all updates +!res config notify recover # players see only recover messages (with rolls) +!res config notify none # players see nothing; GM still sees everything +``` + +### Player command permissions (what players can do) +```text +!res config playermode none|partial|full +``` +- `none` — players cannot use the API. +- `partial` — players can `use`, `list`, `menu`, `recover`. +- `full` — players can also `create`, `add`, `set`, `delete`, `thresh`, `resttag`, `reset`, `linkbar`. + +> Non‑GMs must **control the character** to act on it. + +--- + +## 9) Common Errors & Fixes + +- **“Select a token…”** + Add a character argument: `char="Name"` or `charid=@{selected|character_id}`. + +- **“Usage: …” messages** + You missed a required argument. Copy the example exactly and try again. + +- **Bars not updating** + Re‑link: `!res linkbar [all]` and confirm the bar shows `RT_`. + +- **Auto‑spend didn’t fire** + Ensure the attack name matches your `automap`/`alias`, and the resource exists for that character. + +- **Players don’t see updates** + Check `!res config notify` and confirm they control the character and are online. + +- **Resetting max** + Re‑run `!res create [current]` to overwrite max (and current if provided). + +--- + +## 10) Quick Reference (cheat sheet) + +```text +# Enable/disable +!res enable +!res disable +!res status + +# Create / spend / add / set +!res create Arrows 20 +!res use Arrows 1 +!res add Arrows 5 +!res set Arrows 12 + +# Recover with breakage (shows rolls to players if notify allows) +!res recover Arrows 10 break=2 + +# List & menu +!res list +!res menu + +# Threshold & rest tag +!res thresh Arrows 5 +!res resttag Arrows short + +# Reset +!res reset Arrows +!res reset all + +# Link token bar +!res linkbar Arrows bar2 +!res linkbar Arrows bar2 all + +# Automap / alias (auto-spend on attacks) +!res automap add "Longbow" "Arrows" char="Sluggis" +!res alias add "Longbow" "Arrows" cost=1 + +# Visibility & permissions +!res config notify all|recover|none +!res config playermode none|partial|full +``` + +--- + +## 11) FAQ + +**Q: Can players recover items themselves?** +A: Yes—if `playermode` allows it. Recover shows the individual d100 rolls when `notify` is `all` or `recover`. + +**Q: Do I have to select a token?** +A: No. Add `char="Name"` or `charid=@{selected|character_id}`. Selecting a represented token also works. + +**Q: How do I reset the total number of arrows?** +A: Run `!res create Arrows [current]`. This replaces the stored max (and current if provided). + +**Q: Does it work with ScriptCards?** +A: Yes. The parser accepts underscore/dash-prefixed args and quoted strings. See Section 5 for examples. + +**Q: Will bars update even if no token is selected?** +A: Yes—current/max are written to `RT_` and any linked token bars follow. + +--- + +## 12) Support + +*Scripts are provided 'as-is', without warranty of any kind, expressed or implied.* + +Any issues while using this script, need help using it, or if you have a neat suggestion for a new feature, please shoot me a PM: https://app.roll20.net/users/2447959/joeuser + +If you enjoy the tool, feedback is welcome. It helps prioritize QoL improvements for both GMs and players. + +Patreon: https://www.patreon.com/c/joeuser diff --git a/ResourceTracker/script.json b/ResourceTracker/script.json new file mode 100644 index 000000000..212a3d409 --- /dev/null +++ b/ResourceTracker/script.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://github.com/Roll20/roll20-api-scripts/master/_Example%20Script%20-%20Check%20for%20formatting%20details/script.schema.json", + "name": "ResourceTracker", + "script": "ResourceTracker.js", + "version": "0.5.6", + "previousversions": [], + "description": "an API script for tracking per-character consumables—arrows, javelins, wand charges, spell slots, ki, etc. GMs (and optionally players) can create, spend, recover, and reset resources via chat commands, with updates whispered to the character’s controllers and mirrored to the GM. It supports auto-spend from real sheet rolls (map an attack name to a resource), plays nicely with ScriptCards (underscore-friendly args), and keeps token bars in sync—even when linked to Character Attributes. Features include per-resource rest tags (short/long), a recover action with break chance, an enable/disable switch, and player access modes (none | partial | full) for table safety.", + "authors": "Joe Simmons", + "roll20userid": "2447959", + "patreon": "https://www.patreon.com/c/joeuser", + "useroptions": [ + { + "name": "enabled_on_boot", + "type": "checkbox", + "value": "true", + "checked": "checked", + "description": "Start ResourceTracker enabled when the API sandbox boots." + }, + { + "name": "player_mode", + "type": "select", + "options": ["none", "partial", "full"], + "default": "partial", + "description": "Player access level: none (no API), partial (use/list/menu/recover), or full (all char-scoped verbs)." + }, + { + "name": "autospend", + "type": "checkbox", + "value": "true", + "checked": "checked", + "description": "Automatically spend mapped resources on real sheet rolls." + }, + { + "name": "recover_break", + "type": "number", + "default": "2", + "description": "Default break chance (0–100) for !res recover." + } + ], + "dependencies": [], + "optional": ["ScriptCards"], + "modifies": { + "state.ResourceTracker": "read,write", + + "graphic.represents": "read", + "character.controlledby": "read", + + "token.bar1_link": "read", + "token.bar2_link": "read", + "token.bar3_link": "read", + + "token.bar1_value": "write", + "token.bar1_max": "write", + "token.bar2_value": "write", + "token.bar2_max": "write", + "token.bar3_value": "write", + "token.bar3_max": "write", + + "attribute.current": "write", + "attribute.max": "write" + }, + "conflicts": [] +} \ No newline at end of file