From c0f3fcbf5528536803246a364a09ddda1086f57d Mon Sep 17 00:00:00 2001
From: keithcurtis1 Filter: Click the
🔍
- button to filter items by type.
+ button to filter items by type. The filter button supersedes the Star Filter button
+
+ Star system: Use stars to link specific characters to a backdrop image. For instance, if a scene has several shops, you can star each proprietor for their shop image. When that image is the backdrop, the linked characters’ tokens are highlighted in gold in the token list. This feature is disabled in Grid mode.
+ The Star Filter button in the header will filter to show only starred items. To temporarily show all characters without turning off the star filter, use the filter button to show all Characters.
@@ -1304,15 +1352,17 @@ const renderHelpHtml = (css) => `
Grid populates the tabletop with:
Only works if the current page name contains: scene, stage, theater, theatre
@@ -1363,12 +1413,151 @@ const getJukeboxPlusHandoutLink = () => { }; + +// Create or refresh GM-layer rectangle highlights for starred tokens for the given scene/page. +// - sceneName: name of the scene in state +// - pageId: the page to inspect and on which to create gmlayer highlights +const highlightStarredTokens = (sceneName, pageId) => { + if (!sceneName || !pageId) return; + const st = getState(); + st.starHighlights = st.starHighlights || {}; + + // Find scene object + let scene = null; + for (const act of Object.values(st.acts || {})) { + if (act.scenes?.[sceneName]) { + scene = act.scenes[sceneName]; + break; + } + } + if (!scene) { + st.starHighlights[pageId] = st.starHighlights[pageId] || []; + updateState(st); + return; + } + + // Remove prior highlights + const oldHighlights = st.starHighlights[pageId] || []; + oldHighlights.forEach(id => { + const p = getObj('pathv2', id); + if (p) p.remove(); + }); + st.starHighlights[pageId] = []; + + // Only starred for current backdrop + const backdropId = scene.backdropId; + if (!backdropId) { + updateState(st); + return; + } + const starredList = scene.starredAssets?.[backdropId] || []; + if (!Array.isArray(starredList) || !starredList.length) { + updateState(st); + return; + } + + const pageGraphics = findObjs({ _type: 'graphic', _pageid: pageId }); + const newHighlightIds = []; + + const padding = 12; + const strokeColor = 'gold'; + const fillColor = 'transparent'; + const strokeWidth = 4; + + const findButtonById = id => (st.items?.buttons || []).find(b => b.id === id); + + starredList.forEach(btnId => { + const btn = findButtonById(btnId); + if (!btn) return; + + let matched = []; + + if (btn.type === 'character' && btn.refId) { + const charObj = getObj('character', btn.refId); + if (charObj) { + const charName = (charObj.get('name') || '').toLowerCase(); + matched = pageGraphics.filter(g => { + try { + if (g.get('layer') !== 'objects') return false; + const repId = g.get('represents'); + if (!repId) return false; + const repChar = getObj('character', repId); + if (!repChar) return false; + return (repChar.get('name') || '').toLowerCase() === charName; + } catch { + return false; + } + }); + } + } + + if (btn.type === 'variant') { + const btnNameLower = (btn.name || '').toLowerCase(); + matched = pageGraphics.filter(g => { + try { + return ( + g.get('layer') === 'objects' && + (g.get('name') || '').toLowerCase() === btnNameLower + ); + } catch { + return false; + } + }); + } + + if (!matched.length) return; + + matched.forEach(g => { + try { + const gw = (g.get('width') || 70) + padding; + const gh = (g.get('height') || 70) + padding; + const gx = g.get('left'); + const gy = g.get('top'); + + const path = createObj('pathv2', { + _pageid: pageId, + layer: 'gmlayer', + stroke: strokeColor, + stroke_width: strokeWidth, + fill: fillColor, + shape: 'rec', + points: JSON.stringify([[0,0],[gw,gh]]), + x: gx, + y: gy, + rotation: 0 + }); + + if (path) newHighlightIds.push(path.id); + } catch (e) { + log(`[Director] highlightStarredTokens: failed to create highlight for btn ${btnId}: ${e.message}`); + } + }); + }); + + st.starHighlights[pageId] = newHighlightIds; + updateState(st); +}; + + + + + + const renderFilterBarInline = (css) => { const st = getState(); const activeFilter = st.items?.filter || 'all'; + const starMode = st.items?.starMode || false; const mode = st.settings?.mode || 'light'; const borderColor = mode === 'dark' ? '#eee' : '#444'; + // Determine if grid mode is active by checking for DL paths on GM page + const pid = Campaign().get('playerpageid'); + let gridModeActive = false; + if (pid) { + const existingPaths = findObjs({ _type: 'pathv2', _pageid: pid, layer: 'walls' }); + gridModeActive = existingPaths.some(p => p.get('stroke') === '#84d162'); + } + // Build dynamic option strings const characters = findObjs({ _type: 'character' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); const handouts = findObjs({ _type: 'handout' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); @@ -1378,8 +1567,6 @@ const renderFilterBarInline = (css) => { const buildOpts = (objs, labelFn = o => o.get('name')) => objs.map(o => `${labelFn(o).replace(/"/g, """)},${o.id}`).join('|'); - // Suggested replacement for following line to catch names with double quotes. - //objs.map(o => `${labelFn(o)},${o.id}`).join('|'); const charOpts = buildOpts(characters); const handoutOpts = buildOpts(handouts); @@ -1387,6 +1574,8 @@ const renderFilterBarInline = (css) => { const tableOpts = buildOpts(tables); const trackOpts = buildOpts(tracks, t => t.get('title')); + const starFilterBtn = `★`; + const filterByType = (st.items.filter === "all" ? '': '= ' + st.items.filter + 's'); const buttons = [ `H`, `C`, @@ -1395,7 +1584,10 @@ const renderFilterBarInline = (css) => { `M`, `R`, `🔍` + style="${css.itemAddBadge};" title="Filter Items by Type">🔍 + ${filterByType}`, + // Only show starFilterBtn if NOT in grid mode + ...(!gridModeActive ? [starFilterBtn] : []) ]; return buttons.join(''); @@ -1409,16 +1601,57 @@ const renderItemsList = (css) => { const isEditMode = !!st.items?.editMode; const currentScene = st.activeScene; const activeFilter = st.items?.filter || 'all'; + const starMode = st.items?.starMode || false; + + // --- Detect grid mode by checking if any DL paths with stroke #84d162 exist on current player's page --- + const pid = Campaign().get('playerpageid'); + let gridModeActive = false; + if (pid) { + const existingPaths = findObjs({ _type: 'pathv2', _pageid: pid, layer: 'walls' }); + gridModeActive = existingPaths.some(p => p.get('stroke') === '#84d162'); + } + + const stData = st; // reuse state reference + + // Find sceneObj and backdropId once for reuse + let sceneObj = null; + let backdropId = null; + if (currentScene) { + for (const act of Object.values(stData.acts || {})) { + if (act.scenes?.[currentScene]) { + sceneObj = act.scenes[currentScene]; + break; + } + } + backdropId = sceneObj?.backdropId; + } + + // Build set of starred assets for current scene if starMode is active + let starredAssetsSet = new Set(); + if (currentScene && starMode && sceneObj && backdropId && sceneObj.starredAssets?.[backdropId]) { + starredAssetsSet = new Set(sceneObj.starredAssets[backdropId]); + } + + // Filter items by scene, type, exclude 'action', and if starMode is active, filter to starred only const items = (st.items?.buttons || []).filter(btn => { const sceneMatch = btn.scene === currentScene; - const typeMatch = activeFilter === 'all' || - btn.type === activeFilter || + const typeMatch = activeFilter === 'all' || + btn.type === activeFilter || (activeFilter === 'character' && btn.type === 'variant'); const excludeActions = btn.type !== 'action'; - return sceneMatch && typeMatch && excludeActions; + + if (!sceneMatch || !typeMatch || !excludeActions) return false; + + // Apply star filter ONLY when activeFilter is 'all' + if (activeFilter === 'all' && starMode) { + return starredAssetsSet.has(btn.id); + } + + return true; }); + // Fetch lookup objects for handouts, characters, macros, tables const handouts = findObjs({ _type: 'handout' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); const characters = findObjs({ _type: 'character' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); const macros = findObjs({ _type: 'macro' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); @@ -1429,6 +1662,7 @@ const renderItemsList = (css) => { let labelText = btn.name; let tooltipAttr = ''; + // === existing type-based logic unchanged === if (btn.type === 'action') { if (!btn.refId) { const options = characters.map(c => `${c.get('name')},${c.id}`).join('|'); @@ -1526,6 +1760,7 @@ const renderItemsList = (css) => { tooltipAttr = ` title="Item placeholder"`; } + // --- build edit controls --- const editControls = isEditMode ? ` ${Pictos('p')} @@ -1533,11 +1768,26 @@ const renderItemsList = (css) => { ` : ''; + // --- compute star HTML, omit if grid mode active or in edit mode --- + let starHTML = ''; + if (!gridModeActive && !isEditMode) { + if (!backdropId) { + starHTML = `★`; + } else { + const starredList = sceneObj.starredAssets?.[backdropId] || []; + const isStarred = Array.isArray(starredList) && starredList.includes(btn.id); + starHTML = `★`; + } + } + + // --- return the row: container is relative so star positions itself INSIDE the button area --- return ` -The Director script supports "theater of the mind" style play in Roll20. It provides an interface for managing scenes, associated images, audio, and relevant game assets — all organized within a persistent handout.
+Watch a video demo of Director.
+The interface appears in a Roll20 handout. It consists of four main sections:
+Acts group together related scenes. Use the + + Add Act + button to create an act.
+In Edit Mode, act-level options include: +
Each scene represents a distinct time and place. Click a scene name to set it active. The active scene determines what appears in the Images and Items sections.
+In Edit Mode, scene controls include: +
Backdrop is the main background image for the scene, displayed on the map layer to set the overall environment.
+Highlights are supplementary images layered above the backdrop on the object layer, used to draw attention to specific elements or areas.
+When a scene is set, the backdrop is placed on the map layer, while all highlights appear on the object layer, aligned left beyond the page boundary for easy visibility and interaction.
+To use a highlight, the gm can drag it onto the page, or select it and use the shift-Z keyboard command to preview it to the players.
+Highlights and Bacdrops can be switched on the fly by using the buttons found on each image in the handout (see below) +
To add an image: +
Click to toggle. When this button is red, the audio track auto-play behavior of backdrops is suppressed.
+Items define what is placed or triggered when a scene is set. Items are scoped per scene.
+ +Click a badge to add a new item:
+Variants are token snapshots that share a sheet. Use these when default tokens cannot be reliably spawned, or to represent unique versions of a shared character sheet.
+ +In Edit Mode, each item shows:
+Filter: Click the + 🔍 + button to filter items by type. The filter button supersedes the Star Filter button +
+
+ Star system: Use stars to link specific characters to a backdrop image. For instance, if a scene has several shops, you can star each proprietor for their shop image. When that image is the backdrop, the linked characters’ tokens are highlighted in gold in the token list. This feature is disabled in Grid mode.
+ The Star Filter button in the header will filter to show only starred items. To temporarily show all characters without turning off the star filter, use the filter button to show all Characters.
+
Scene populates the tabletop with: +
Grid populates the tabletop with: +
Only works if the current page name contains: scene, stage, theater, theatre
+ +Wipe the Scene removes all placed images and stops all audio.
+Only functions on valid stage pages.
+ +${Pictos(')')} toggles editing. When enabled:
+If you have the Jukebox Plus script installed, this button will display and will put a link in chat for opening that program's controls.
+Displays this Help documentation. While in help mode, this changes to read "Exit Help".
+This button appears only while in Help mode. Pressing it will create a handout containing the help documentaiton. Useful if you want to see the documentation and the interface at the same time.
+ + +The interface is primary, but the following macros can be used in chat or action buttons:
++!director --set-scene +!director --wipe-scene +!director --new-act|Act I +!director --new-scene|Act I|Opening Scene +!director --capture-image ++
+ Director + Exit Help + Make Help Handout + | +
+ Director + + + + Wipe the Scene + + + Stop Audio + +${getJukeboxPlusHandoutLink()} + + + ${st.helpMode ? 'Exit Help' : 'Help'} + + + + ${Pictos(st.items?.editMode ? ')' : '(')} + | +||
+
+ Acts
+ + Add Act
+
+ ${actsHtml}
+
+ + + + |
+
+
+ Images
+ + Add Image
+
+ ${Pictos('m')}
+
+
+
+
+ ${imagesHTML}
+ |
+
+
+ Items ${renderFilterBarInline(css)}
+
+
+${renderItemsList(css)}
+
+ |
+
${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${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('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 = unq(p0.split(/[=|]/)[1] || 'none');
+ else { const maybe = parseInt(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)}${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_