diff --git a/Director/1.0.2/Director.js b/Director/1.0.2/Director.js new file mode 100644 index 000000000..8fd6eb230 --- /dev/null +++ b/Director/1.0.2/Director.js @@ -0,0 +1,3870 @@ +var API_Meta = API_Meta || {}; +API_Meta.Director = { + offset: Number.MAX_SAFE_INTEGER, + lineCount: -1 +}; { + try { + throw new Error(''); + } catch (e) { + API_Meta.Director.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (4)); + } +} + + +// Jukebox Plus Plus (Fully Enhanced UI with Album/Playlist Toggle, Track Tagging, and Layout Fixes) +on('ready', () => +{ + + const version = '1.0.2'; //version number set here + log('-=> Director v' + version + ' is loaded. Command !director creates control handout and provides link. Click that to open.'); + +//Changelog: +//1.0.0 Debut script +//1.0.1 Grid Mode, fallback image system for Marketplace images +//1.0.2 Expanded Grid Mode up to 9x9 and tighterned spacing, added Star system + + +// == Director Script == +// Globals +const scriptName = 'Director'; +const stateName = 'DIRECTOR_STATE'; + +// Helper to initialize state if needed +const getState = () => { + if (!state[stateName]) { + state[stateName] = { + acts: {}, + actsExpanded: {}, + activeAct: null, + activeScene: null, + helpMode: false, + settings: { + mode: 'light', + settingsExpanded: false, + muteBackdropAudio: false, }, + scenes: {}, + }; + } + return state[stateName]; +}; + +const updateState = (st) => { + state[stateName] = st; +}; + + + + +const cssDark = { + // === Layout: Header, Sidebar, Columns === + header: 'color:#ddd; background:#2d4354; border-bottom:1px solid #444; font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px;', + sidebar: 'color:#ddd; background:#222; border-right:1px solid #444; width:150px; font-family: Nunito, Arial, sans-serif; vertical-align:top; padding:8px;', + images: 'color:#ddd; background:#1e1e1e; border-right:1px solid #444; width:210px; font-family: Nunito, Arial, sans-serif; vertical-align:top; padding:8px;', + items: 'color:#ddd; background:#1e1e1e; font-family: Nunito, Arial, sans-serif; vertical-align:top; padding:8px;', + columnHeader: 'color:#ddd; background:#333; border-bottom:1px solid #555; margin:-8px -7px 1px -7px; padding:6px 8px; font-weight:bold; font-size:15px;', + helpContainer: 'background:transparent;padding:16px; font-size:13px; line-height:1.5; max-height:800px; overflow-y:auto;', + + // === Buttons: Headers, Utility, Scene === + headerContainer: 'color:#ddd!important; background:#1a2833; border:1px solid #888; border-radius:4px; margin-top:-2px; margin-left:6px; padding:2px 6px; font-size:12px; float:right; text-decoration:none; position:relative; top:3px;', + headerSubButton: 'color:#ddd!important; background:#2B3D4F; border:1px solid #888; border-radius:4px; margin:1px 0px 0px 4px; padding:1px 6px 0px 6px; font-size:12px; text-decoration:none; display:inline-block;', + headerButton: 'color:#ddd!important; background:##1a2833; border:1px solid #888; border-radius:4px; margin-top:-2px; margin-left:6px; padding:4px 6px; font-size:12px; float:right; text-decoration:none; position:relative; top:3px;', + settingsButton: 'color:#ddd; background:transparent; width:90%; margin-top:6px; padding:4px 6px; font-size:12px; display:inline-block; text-align:center; text-decoration:none;', + utilityButton: 'color:#ddd; background:#555; border:1px solid #777; border-radius:4px; width:90%; margin-top:6px; padding:4px 6px; font-size:12px; display:inline-block; text-align:center; text-decoration:none;', + utilitySubButton: 'color:#ccc; background:#444; border:1px solid #666; border-radius:3px; margin:-1px -1px 0px 3px; padding:1px 5px; font-size:11px; float:right; text-decoration:none;', + utilitySubButtonActive: 'color:#111; background:#44aa44; border:1px solid #555; border-radius:3px; margin:-1px -1px 0 3px; padding:1px 5px; font-size:11px; float:right; text-decoration:none; cursor:pointer;', + utilitySubButtonInactive: 'color:#888; background:#333; border:1px solid #777; border-radius:3px; margin:-1px -1px 0 3px; padding:1px 5px; font-size:11px; float:right; text-decoration:none; cursor:pointer;', + sceneButtonActive: 'color:#111; background:#44aa44; border:1px solid #2a2a2a; border-radius:3px; margin:-1px -1px 0 3px; padding:3px 5px; font-size:11px; display:block; text-decoration:none; cursor:pointer;', + sceneButtonInactive: 'color:#888; background:#333; border:1px solid #666; border-radius:3px; margin:-1px -1px 0 3px; padding:3px 5px; font-size:11px; display:block; text-decoration:none; cursor:pointer;', + + // === Utility Containers === + utilityContainer: 'color:#ddd; background:#555; border:1px solid #777; border-radius:4px; width:90%; min-height:18px; margin-top:6px; padding:4px 6px; font-size:12px; position:relative;', + actContainer: 'color:#ddd; background:#555; border:1px solid #444; border-radius:4px; width:120px; min-height:18px; margin-top:0px; padding:4px 25px 4px 6px; font-size:12px; display:inline-block; position:relative;', + + // === Items and Item Buttons === + itemButton: 'color:#eee!important; background:#555; border:1px solid #666; width:calc(100%-6px); margin:3px 0 0 0; padding:3px 6px 3px 0px; font-size:12px; border-radius:4px; display:block; text-align:left; text-decoration:none;', + itemBadge: 'color:#111; background:#999; border-radius:3px; width:20px; max-height:20px; margin:0px 2px; padding-top:2px; font-size:12px; font-weight:bold; text-align:center; display:inline-block; cursor:pointer; text-decoration:none;', + itemAddBadge: 'color:#111; background:indianred; border-radius:3px; width:20px; max-height:20px; margin:0px 2px; padding-top:2px; font-size:12px; font-weight:bold; text-align:center; display:inline-block; cursor:pointer; text-decoration:none;', + editIcon: 'color:#eee; font-size:12px; margin:0px 4px; display:inline-block; float:right; cursor:pointer;', + utilityEditButton: 'color: #333; background: crimson; padding: 0 2px; border-radius: 3px; min-width:12px; margin-left:2px; margin-bottom:-19px; padding-top:2px; font-family: Pictos; font-size: 12px; text-align:center; float:right; position:relative; top:-22px; right:4px;', + utilityEditButtonOverlay: 'color: #333; background: crimson; padding: 0 4px; border-radius: 3px; min-width: 12px; margin-left: 4px; padding-top: 2px; font-family: Pictos; font-size: 20px; text-align: center; cursor: pointer; float: none; position: relative; top: 0; right: 0; margin-bottom: 0; z-index: 11;', +starred: `color: gold; font-weight: bold; font-size: 18px; text-decoration: none; user-select: none; cursor: pointer; position: absolute; top: 3px; right: 8px; margin: 0;`, +unstarred: `color: gray; font-weight: normal; font-size: 18px; text-decoration: none; user-select: none; cursor: pointer; position: absolute; top: 3px; right: 8px; margin: 0;`, + + + // === Message UI === + messageContainer: 'color:#ccc; background-color:#222; border:1px solid #444; border-radius:5px; padding:10px; font-family: Nunito, Arial, sans-serif; position:relative; top:-15px; left:-5px;', + messageTitle: 'color:#ddd; font-size:16px; text-transform:capitalize; text-align:center; margin-bottom:13px;', + messageButton: 'color:#ccc; background:#444; border-radius:4px; padding:2px 6px; margin-right:2px; display:inline-block; vertical-align:middle;', + + // === Images === + imageContainer: 'margin-bottom:2px; clear:both; overflow:hidden;', + imageBoxWrapper: 'background:#1e1e1e; border:1px solid #666; border-radius:4px; width:208px; height:119px; margin: 4px 0px 0px 0px; position:relative; float:left;', + imageDiv: 'background-position:center; background-size:cover; border:1px solid #666; border-radius:4px; width:208; height:117px; display:block;', + imageTitleOverlay: 'color:#fff; background:rgba(0,0,0,0.4); border-radius:4px; padding:2px 6px; position:absolute; top:4px; left:8px; font-weight:bold; font-size:16px; text-shadow:0 0 4px #000; z-index:2; cursor:pointer;', + imageBox: 'background-position:center!important; background-size:cover!important; width:208px; height:117px;', + imageTitle: 'color:#ddd; font-weight:bold; font-size:16px; cursor:pointer; margin-bottom:6px;', + imageControls: 'min-width:120px; float:left;', + imageControlButton: 'color:#ddd; background:#555; border:1px solid #444; border-radius:4px; padding:4px 6px; margin-bottom:6px; font-size:12px; display:block; text-align:center; text-decoration:none; cursor:pointer; user-select:none;', + image: 'border:1px solid #666; width:100px; height:100px; margin-right:8px; display:block; float:left; object-fit:cover;', + + // === Image Tracks === + trackButtonGhosted: 'color:white; opacity:0.4; text-shadow: 1px 1px black; font-family:Pictos; font-size:14px; text-decoration:none; margin-left:3px;', + trackButtonNormal: 'color:white; text-shadow: 1px 1px black; font-family:Pictos; font-size:14px; text-decoration:none; margin-left:3px;', + trackButtonPlaying: 'color:#44aa44; text-shadow: 1px 1px black; font-family:Pictos; font-size:14px; text-decoration:none; margin-left:3px;', + trackButtonEdit: 'color:#333; background:crimson; padding:2px 3px; border-radius:3px; min-width:12px; margin-left:3px; font-family:Pictos; font-size:14px; text-align:center; vertical-align: middle; cursor:pointer; text-decoration:none;', + + + // === Misc === + forceTextColor: 'color:#ddd; display:inline-block;', + + // === Badge Color Reference === + badgeColors: { + handout: '#2a80b9', + character: '#27ae60', + track: '#e67e22', + macro: '#e4048c', + table: '#7f6c4f' + } +}; + + +const lightModeOverrides = { + header: { color: '#222', background: '#93b3cc', border: '1px solid #666' }, + sidebar: { color: '#222', background: '#bbb', border: '1px solid #666' }, + images: { color: '#222', background: '#bbb', border: '1px solid #666' }, + items: { color: '#222', background: '#bbb' }, + columnHeader: { color: '#222', background: '#999', border: '1px solid #666' }, + + headerContainer: { color: '#222', background: '#e0e0e0', border: '1px solid #888' }, + headerSubButton: { color: '#222', background: '#C2C3C4', border: '1px solid #888' }, + headerButton: { color: '#222', background: '#e0e0e0', border: '1px solid #888' }, + settingsButton: { color: '#222' }, + utilityButton: { color: '#222', background: '#ccc', border: '1px solid #666' }, + utilitySubButton: { color: '#222', background: '#ddd', border: '1px solid #999' }, + utilitySubButtonActive: { color: '#222', background: '#88cc88', border: '1px solid #777' }, + utilitySubButtonInactive: { color: '#444', background: '#ddd', border: '1px solid #999' }, + sceneButtonActive: { color: '#222', background: '#88cc88', border: '1px solid #777' }, + sceneButtonInactive: { color: '#555', background: '#ddd', border: '1px solid #666' }, + + utilityContainer: { color: '#222', background: '#ccc', border: '1px solid #666' }, + actContainer: { color: '#222', background: '#ccc', border: '1px solid #666' }, + + itemButton: { color: '#111', background: '#ddd', border: '1px solid #666' }, + editIcon: { color: '#666' }, +starred: { color: 'darkorange' }, +unstarred: { color: '#bbb' }, + + + messageContainer: { color: '#222', background: '#f9f9f9', border: '1px solid #ccc' }, + messageTitle: { color: '#222' }, + messageButton: { color: '#222', background: '#ddd' }, + + imageBoxWrapper: { background: '#fff', border: '1px solid #ccc' }, + imageDiv: { border: '1px solid #ccc' }, + imageTitle: { color: '#222' }, + imageControlButton: { color: '#222', background: '#eee', border: '1px solid #ccc' }, + image: { border: '1px solid #bbb' }, + + forceTextColor: { color: '#111' }, + + badgeColors: { + handout: '#2a80b9', + character: '#27ae60', + track: '#e67e22', + macro: '#e4048c', + table: '#7f6c4f' + } +}; + +const generateCssLightFromDark = (cssDark, overrides) => { + const result = {}; + + const replaceColors = (styleStr, override) => { + const props = styleStr.split(';').map(p => p.trim()).filter(Boolean); + const mapped = {}; + + // Convert dark mode CSS string into key-value pairs + props.forEach(p => { + const [key, value] = p.split(':').map(s => s.trim()); + mapped[key] = value; + }); + + // Apply color/background/border overrides + if (override) { + if (override.color) mapped.color = override.color; + if (override.background) mapped.background = override.background; + if (override.border) { + // Override just the relevant border (most are single sides) + const sides = ['border', 'border-top', 'border-right', 'border-bottom', 'border-left']; + const borderKey = sides.find(k => Object.keys(mapped).includes(k)) || 'border'; + mapped[borderKey] = override.border; + } + } + + // Rebuild into CSS string + return Object.entries(mapped).map(([k, v]) => `${k}:${v}`).join('; ') + ';'; + }; + + // Handle all style keys (excluding badgeColors) + for (const key in cssDark) { + if (key === 'badgeColors') continue; + const override = overrides[key]; + result[key] = replaceColors(cssDark[key], override); + } + + // Copy and override badgeColors + result.badgeColors = { + ...(cssDark.badgeColors || {}), + ...(overrides.badgeColors || {}) + }; + + return result; +}; +const cssLight = generateCssLightFromDark(cssDark, lightModeOverrides); + + + + + + const getCSS = () => (state[stateName].settings.mode === 'dark' ? cssDark : cssLight); + +const getPageForPlayer = (playerid) => { + let player = getObj('player', playerid); + if (!player) return null; + + if (playerIsGM(playerid)) { + // For GM, get their last page viewed + return player.get('lastpage'); + } + + const campaign = Campaign(); + const psp = campaign.get('playerspecificpages'); + if (psp[playerid]) { + return psp[playerid]; + } + + return campaign.get('playerpageid'); +}; + + +// --- Helper Functions --- + +const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); + +const generateUUID = () => { + const a = () => Math.random().toString(16).slice(2); + return `${a()}-${a()}-${a()}`; +}; + +const getActiveAct = () => { + const activeScene = state[stateName].activeScene; + if (!activeScene) return null; + + for (const actName in state[stateName].acts) { + if (state[stateName].acts[actName].scenes?.[activeScene]) { + return actName; + } + } + + return null; +}; + + +const getActiveScene = () => { + const st = getState(); + const activeAct = getActiveAct(); + if (!activeAct) return null; + const act = st.acts[activeAct]; + const scene = st.activeScene; + if (scene && act.scenes && act.scenes[scene]) { + return scene; + } + const keys = Object.keys(act.scenes || {}); + if (keys.length) { + st.activeScene = keys[0]; + return keys[0]; + } + return null; +}; + +// Utility: Styled chat message sender +const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => { + const css = getCSS(); + let title, message; + if (messageOrUndefined === undefined) { + title = scriptName; + message = titleOrMessage; + } else { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } + message = String(message); + message = message.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, command) => { + return `${label}`; + }); + + const html = `
${title}
${message}
`; + const target = isPublic ? '' : '/w gm '; + sendChat(scriptName, `${target}${html}`, null, { noarchive: true }); +}; + +// Generate a unique ID for images/items +const generateRowID = () => { + return `${Date.now()}_${Math.floor(Math.random() * 100000)}`; +}; + + + +const deleteScene = (actName, sceneName) => { + const st = getState(); + + const act = st.acts?.[actName]; + if (!act) { + sendStyledMessage('Director', `Act "${actName}" does not exist.`); + return; + } + if (!act.scenes?.[sceneName]) { + sendStyledMessage('Director', `Scene "${sceneName}" does not exist in act "${actName}".`); + return; + } + + // Delete from scenes + delete act.scenes[sceneName]; + + // Delete from scenesOrder + if (Array.isArray(act.scenesOrder)) { + act.scenesOrder = act.scenesOrder.filter(name => name !== sceneName); + } + + // Clear activeScene if it was deleted + if (st.activeScene === sceneName) { + st.activeScene = null; + } + + // Remove associated buttons + if (st.items?.buttons) { + st.items.buttons = st.items.buttons.filter(btn => btn.scene !== sceneName); + } + + updateState(st); + updateHandout(); + sendStyledMessage('Director', `Scene "${sceneName}" deleted from act "${actName}".`); +}; + + + +const deleteAct = (actName) => { + const st = getState(); + + const act = st.acts?.[actName]; + if (!act) { + sendStyledMessage('Director', `Act "${actName}" does not exist.`); + return; + } + + // Remove associated buttons for scenes in this act + if (st.items?.buttons) { + const sceneNames = Object.keys(act.scenes || {}); + st.items.buttons = st.items.buttons.filter(btn => !sceneNames.includes(btn.scene)); + } + + // Clear activeScene if it was in this act + if (st.activeScene && act.scenes?.[st.activeScene]) { + st.activeScene = null; + } + + // Delete act + delete st.acts[actName]; + + // Remove from actsOrder + if (Array.isArray(st.actsOrder)) { + st.actsOrder = st.actsOrder.filter(name => name !== actName); + } + + updateState(st); + updateHandout(); + sendStyledMessage('Director', `Act "${actName}" and all its scenes deleted.`); +}; + + + +// Backup and restore +const makeBackup = () => { + const st = getState(); + + // Find existing backups and parse their numeric suffixes + const existing = findObjs({ type: 'handout' }) + .map(h => h.get('name')) + .filter(name => /^Director Backup \d{3}$/.test(name)) + .map(name => parseInt(name.match(/\d{3}$/)[0], 10)); + + // Get the next backup number + const num = existing.length ? Math.max(...existing) + 1 : 1; + const name = `Director Backup ${String(num).padStart(3, '0')}`; + + createObj('handout', { + name, + notes: JSON.stringify(st) + }); + + sendStyledMessage(scriptName, `Backup created: ${name}`); +}; + +const restoreBackup = (name) => { + const handout = findObjs({ type: 'handout', name })[0]; + if (!handout) { + sendStyledMessage(scriptName, `Backup not found: ${name}`); + return; + } + handout.get('notes', (notes) => { + try { + const restored = JSON.parse(notes); + state[stateName] = restored; + sendStyledMessage(scriptName, `Restored from backup: ${name}`); + updateHandout(); + } catch (err) { + sendStyledMessage(scriptName, `Failed to parse backup: ${err.message}`); + } + }); +}; + + +const repairAllOrders = () => { + const st = getState(); + + // Repair actsOrder + st.actsOrder = Object.keys(st.acts || {}); + log(`Repaired actsOrder: ${JSON.stringify(st.actsOrder)}`); + + // Repair scenesOrder for each act and initialize starredAssets for each scene + for (const actName of st.actsOrder) { + const act = st.acts[actName]; + if (act && act.scenes) { + act.scenesOrder = Object.keys(act.scenes); + log(`Repaired scenesOrder for act "${actName}": ${JSON.stringify(act.scenesOrder)}`); + + for (const sceneName of act.scenesOrder) { + const scene = act.scenes[sceneName]; + if (!scene.starredAssets) { + scene.starredAssets = {}; + } + } + } + } + + // Prune orphaned character items + if (st.items?.buttons?.length) { + const originalCount = st.items.buttons.length; + + st.items.buttons = st.items.buttons.filter(btn => { + if (btn.type !== 'character') return true; + return getObj('character', btn.refId); // Keep only if character still exists + }); + + const prunedCount = originalCount - st.items.buttons.length; + if (prunedCount > 0) { + log(`[Director] Pruned ${prunedCount} orphaned character items.`); + } + } + + updateState(st); + updateHandout(); +}; + +const createVariantButtonFromToken = (token, scene) => { + if (!token) return null; + + const represents = token.get('represents'); + if (!represents) return null; + + const safeProps = [ + 'width', 'height', 'imgsrc', 'name', 'bar1_value', 'bar1_max', 'bar1_link', 'bar1_formula', + 'bar2_value', 'bar2_max', 'bar2_link', 'bar2_formula', 'bar3_value', 'bar3_max', 'bar3_link', 'bar3_formula', + 'showplayers_name', 'showplayers_bar1', 'showplayers_bar2', 'showplayers_bar3', + 'aura1_radius', 'aura1_color', 'aura2_radius', 'aura2_color', + 'tint_color', 'rotation', 'light_radius', 'light_dimradius', 'light_angle', + 'light_hassight', 'light_losangle', 'light_multiplier', + 'has_bright_light_vision', 'has_low_light_vision', + 'night_vision_distance', 'limit_field_of_vision_total', + 'limit_field_of_night_vision_total', 'compact_bar', 'bar_location', + 'sides', 'showname', 'show_tooltip', 'layer', 'gmnotes' + ]; + + const props = {}; + safeProps.forEach(prop => props[prop] = token.get(prop)); + + return { + id: generateUUID(), + type: 'variant', + name: token.get('name') || 'Unnamed Variant', + refId: represents, + scene, + tokenProps: props + }; +}; + + + + +// Capture image from selected token +const handleCaptureImage = (msg) => { + if (!msg.selected || msg.selected.length !== 1) { + sendStyledMessage('Error', 'Please select exactly one token to capture image.'); + return; + } + + const token = getObj('graphic', msg.selected[0]._id); + if (!token) { + sendStyledMessage('Error', 'Selected token not found.'); + return; + } + + let url = token.get('imgsrc').replace(/(thumb|med|original)/, 'max'); + const width = token.get('width'); + const height = token.get('height'); + const ratio = height / width; + + const st = getState(); + let sceneName = getActiveScene(); + + if (!sceneName) { + sceneName = 'Default Scene'; + if (!st.acts[st.activeAct]?.scenes[sceneName]) { + if (!st.acts[st.activeAct]) st.acts[st.activeAct] = { scenes: {} }; + st.acts[st.activeAct].scenes[sceneName] = { images: [], items: [], backdropId: null }; + sendStyledMessage('Director', `No active scene found. Created default scene "${sceneName}".`); + } + st.activeScene = sceneName; + } + + const images = st.acts[st.activeAct].scenes[sceneName].images; + + const id = generateRowID(); + images.push({ + id, + url, + ratio, + type: 'highlight', + title: 'New Image' + }); + + //sendStyledMessage('Image Captured', `Image added to scene "${sceneName}" as a highlight.`); + updateHandout(); +}; + + +const getBadgeColor = (type) => { + const colors = { + handout: '#2a80b9', + character: '#27ae60', + variant: '#16a085', // Teal for variants + track: '#e67e22', + macro: '#e4048c', + table: '#7f6c4f', + all: '#888' + }; + return colors[type] || '#999'; +}; + + +const getBadge = (type, css) => { + let badgeLetter; + if (type === 'variant') badgeLetter = 'V'; + else if (type === 'table') badgeLetter = 'R'; + else badgeLetter = type.charAt(0).toUpperCase(); + + return `
${badgeLetter}
`; +}; + + + + + +const getEditIcon = (id, css) => + ``; + + +const Pictos = (char) => `${char}`; + + +const getScaledToFit = (ratio, maxW, maxH) => { + const r = parseFloat(ratio) || 1; // height / width + let w = maxW; + let h = Math.round(w * r); + + if (h > maxH) { + h = maxH; + w = Math.round(h / r); + } + + return { w, h }; +}; + + +const tagGraphicAsDirector = (graphic) => { + graphic.set({ + aura2_color: '#000001', + aura2_radius: '', + }); +}; + + +const isDirectorGraphic = (graphic) => + graphic.get('aura2_color') === '#000001' && + graphic.get('aura2_radius') === ''; + + + +const FALLBACK_IMG = 'https://files.d20.io/images/450376099/-A1LbVK3RyZu-huOhIlTSw/original.png?1753641861'; + +const getSafeImgsrc = (imgsrc) => imgsrc.includes('/marketplace/') ? FALLBACK_IMG : imgsrc; + + + + + + +const enableDynamicLighting = (pageId) => { + const page = getObj('page', pageId); + if (!page) { + return sendStyledMessage('Dynamic Lighting', 'Page not found.'); + } + + page.set({ + dynamic_lighting_enabled: true, + daylight_mode_enabled: true, + daylightModeOpacity: 1, // 1 = 100% + explorer_mode: 'off', + lightupdatedrop: true, + lightrestrictmove: true, + force_lighting_refresh: true, + fog_opacity: 0, + lightupdatedrop: true + }); + + //sendStyledMessage('Dynamic Lighting', `Dynamic Lighting enabled for page "${page.get('name')}".`); +}; + + + +const disableDynamicLighting = (pageId) => { + const page = getObj('page', pageId); + if (!page) { + return sendStyledMessage('Dynamic Lighting', 'Page not found.'); + } + + page.set({ + dynamic_lighting_enabled: false, + daylight_mode_enabled: false, + explorer_mode: 'off', + force_lighting_refresh: true + }); + + //sendStyledMessage('Dynamic Lighting', `Dynamic Lighting disabled for page "${page.get('name')}".`); +}; + + + + + + + + +const handleSetScene = (playerid) => { + const st = getState(); + const currentScene = st.activeScene; + + if (!currentScene) + return sendStyledMessage('Set Scene', 'No active scene is selected.'); + + // Wipe previous scene if different + wipeScene(st.lastSetScene, playerid); + + + let pageId = getPageForPlayer(playerid); + if (!pageId) pageId = Campaign().get('playerpageid'); + + const page = getObj('page', pageId); + if (!page) { + return sendStyledMessage('Set Scene', 'No valid player page found, including fallback.'); + } + + const pageName = page.get('name')?.toLowerCase() || ''; + if (!/stage|scene|theater|theatre/.test(pageName)) { + return sendStyledMessage('Set Scene', `Current page "${page.get('name')}" must contain:
stage, scene, theater, or theatre.

Skipping scene setup.`); + } + + + +disableDynamicLighting(pageId); + + + page.set({ + showgrid: false, + //background_color: '#000000', + }); + + // --- Find scene data --- + let scene = null; + for (const act of Object.values(st.acts)) { + if (act.scenes?.[currentScene]) { + scene = act.scenes[currentScene]; + break; + } + } + if (!scene) return sendStyledMessage('Set Scene', 'Active scene data not found.'); + + const pageWidth = page.get('width') * 70; + const pageHeight = page.get('height') * 70; + const centerX = pageWidth / 2; + const centerY = pageHeight / 2; + + // Stop all currently playing tracks + const playingTracks = findObjs({ _type: 'jukeboxtrack' }).filter(t => t.get('playing')); + playingTracks.forEach(t => t.set('playing', false)); + + // --- Backdrop --- + const backdropImg = scene.images?.find(img => img.id === scene.backdropId); + if (backdropImg) { + const maxWidth = pageWidth - 140; + const maxHeight = pageHeight - 140; + const size = getScaledToFit(backdropImg.ratio, maxWidth, maxHeight); + + const backdrop = createObj('graphic', { + _pageid: pageId, + layer: 'map', + imgsrc: cleanImg(backdropImg.url), + left: centerX, + top: centerY, + width: size.w, + height: size.h, + isdrawing: true, + name: backdropImg.title || 'Backdrop', + showname: false, + showplayers_name: false, + }); + tagGraphicAsDirector(backdrop); + +if (backdropImg.trackId && !st.settings.muteBackdropAudio) { + const track = getObj('jukeboxtrack', backdropImg.trackId); + if (track && !track.get('playing')) { + track.set('playing', true); + } else if (!track) { + log(`[Director] Backdrop track ID "${backdropImg.trackId}" not found.`); + } +} + + st.lastSetScene = currentScene; + } + + // --- Highlights --- + const highlights = scene.images?.filter(img => img.type === 'highlight') || []; + let highlightTop = 105; + let highlightLeft = -105; + + for (const img of highlights) { + const size = getScaledDimensions(img.ratio, 210); + + // Wrap column if needed + if (highlightTop + size.h > pageHeight - 50) { + highlightTop = 105; + highlightLeft -= (210 + 10); // 210 fixed width + 10px gap + } + + const highlight = createObj('graphic', { + _pageid: pageId, + layer: 'objects', + imgsrc: cleanImg(img.url), + left: highlightLeft, + top: highlightTop + size.h / 2, + width: size.w, + height: size.h, + isdrawing: true, + name: img.title || 'Highlight', + showname: true, + showplayers_name: false, + }); + tagGraphicAsDirector(highlight); + highlightTop += size.h + 20; + } + + // --- Character Tokens --- + const charItems = (st.items?.buttons || []).filter(btn => + btn.scene === currentScene && + ( + (btn.type === 'character' && btn.refId) || + (btn.type === 'variant') + ) + ); + + let tokenTop = 105; + let tokenLeft = pageWidth + 70; + let currentColumnMaxWidth = 70; + +const placeNextToken = () => { + if (!charItems.length) { + updateHandout(); + highlightStarredTokens(currentScene, pageId); // ✅ Now runs after tokens are placed + return; + } + + const btn = charItems.shift(); + + const handlePlacement = (props, name) => { + const tokenWidth = props.width || 70; + const tokenHeight = props.height || 70; + + if (tokenTop + tokenHeight > pageHeight - 50) { + tokenTop = 105; + tokenLeft += currentColumnMaxWidth + 70; + currentColumnMaxWidth = tokenWidth; + } else { + currentColumnMaxWidth = Math.max(currentColumnMaxWidth, tokenWidth); + } + + props.left = tokenLeft + tokenWidth / 2; + props.top = tokenTop + tokenHeight / 2; + + const token = createObj('graphic', props); + tagGraphicAsDirector(token); + + tokenTop += tokenHeight + 20; + }; + + if (btn.type === 'variant') { + try { + const props = { ...btn.tokenProps }; + if (!props || !props.imgsrc) { + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)) || FALLBACK_IMG; + } + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); + props._pageid = pageId; + props.layer = 'objects'; + + handlePlacement(props, btn.name); + } catch (e) { + log(`[Director] Error placing variant "${btn.name}": ${e.message}`); + } + return setTimeout(placeNextToken, 0); + } + + const char = getObj('character', btn.refId); + if (!char) return placeNextToken(); + + char.get('_defaulttoken', (blob) => { + try { + const props = JSON.parse(blob); + if (!props || !props.imgsrc) return placeNextToken(); + + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); + props._pageid = pageId; + props.layer = 'objects'; + + handlePlacement(props, char.get('name')); + //log (char.get('name') + ":props = " + JSON.stringify(props)); + + } catch (e) { + log(`[Director] Error parsing default token for ${char.get('name')}: ${e}`); + } + setTimeout(placeNextToken, 0); + }); +}; + + placeNextToken(); + updateHandout(); + + + highlightStarredTokens(currentScene, pageId); +}; + + +const wipeScene = (sceneName, playerid) => { + const pageId = getPageForPlayer(playerid); // This returns what the GM is *currently viewing* + if (!pageId) { + // Count total number of pages in the game + const allPages = findObjs({ _type: 'page' }); + const pageCount = allPages.length; + // Base message + let msg = 'No valid page found for your view. Director-controlled pages must contain:
stage, scene, theater, or theatre in the title.'; + // Add extra paragraph if only one page exists. This is for new games, since the GM must have manually switched to a page at least once. + if (pageCount === 1) { + msg += `

Also: If this is a new game, you must have changed pages at least once, as the GM.`; + } + + return sendStyledMessage('Wipe Scene', msg); + } + + const page = getObj('page', pageId); + if (!page) { + return sendStyledMessage('Wipe Scene', 'Page object could not be found.'); + } + + const name = page.get('name')?.toLowerCase() || ''; + if (!/stage|scene|theater|theatre/.test(name)) { + return sendStyledMessage('Wipe Scene', `Page "${page.get('name')}" must contain:
stage, scene, theater, or theatre.

Aborting wipe.`); + } + + const graphics = findObjs({ _type: 'graphic', _pageid: pageId }); + graphics.forEach(g => { + if (isDirectorGraphic(g)) g.remove(); + }); + + const paths = findObjs({ _type: 'pathv2', _pageid: pageId, layer: 'walls' }); + paths.forEach(p => { + if (p.get('stroke') === '#84d162') p.remove(); + }); + + +// Remove all gold stroke paths on GM layer for this page +const goldPaths = findObjs({ _type: 'pathv2', _pageid: pageId, layer: 'gmlayer' }) + .filter(p => p.get('stroke') === 'gold'); +goldPaths.forEach(p => p.remove()); + + + +disableDynamicLighting(pageId); + + + //sendStyledMessage('Wipe Scene', `All Director graphics cleared from page "${page.get('name')}".`); +}; + + + + + + + + + + +const handleSetGrid = (playerid) => { + const st = getState(); + const currentScene = st.activeScene; + + if (!currentScene) + return sendStyledMessage('Set Grid', 'No active scene is selected.'); + + wipeScene(st.lastSetScene, playerid); + + let pageId = getPageForPlayer(playerid); + if (!pageId) pageId = Campaign().get('playerpageid'); + + const page = getObj('page', pageId); + if (!page) + return sendStyledMessage('Set Grid', 'No valid player page found, including fallback.'); + + enableDynamicLighting(pageId); + + let act, scene; + for (const a of Object.values(st.acts)) { + if (a.scenes?.[currentScene]) { + act = a; + scene = a.scenes[currentScene]; + break; + } + } + + if (!scene) + return sendStyledMessage('Set Grid', 'Active scene data not found.'); + + const validImages = (scene.images || []).filter(img => img.url && img.url.startsWith('https://')); + + if (!validImages.length) + return sendStyledMessage('Set Grid', 'No image assets found for grid placement.'); + + const layouts = [ + [1,1], [1,2], [2,1], + [1,3], [3,1], + [2,2], + [2,3], [3,2], + [3,3], + [4,2], [2,4] + ]; + + const maxImages = 9; // maximum images supported + + const imgCount = Math.min(validImages.length, maxImages); + + if (validImages.length > maxImages) { + sendStyledMessage('Set Grid', `Too many images (${validImages.length}) to fit grid; only the first ${maxImages} will be placed.`); + } + + const pageWidth = page.get('width') * 70; + const pageHeight = page.get('height') * 70 - 105; + + const isWide = pageWidth > pageHeight; + const isTall = pageHeight > pageWidth; + + // Fix: Always include 2x2 layout if exactly 4 images to get perfect fit + let filteredLayouts = layouts.filter(([c, r]) => { + if (imgCount === 4 && c === 2 && r === 2) { + return true; + } + if (isWide) return c > r; + if (isTall) return r > c; + return true; + }); + + filteredLayouts.sort((a,b) => (a[0]*a[1]) - (b[0]*b[1])); + let chosenLayout = filteredLayouts.find(([c, r]) => c*r >= imgCount); + + if (!chosenLayout) chosenLayout = [3,3]; + + const [cols, rows] = chosenLayout; + const gridCells = cols * rows; + + const cellWidth = Math.floor(pageWidth / cols); + const cellHeight = Math.floor(pageHeight / rows); + + const gridImageMargin = 35; + const maxImgWidth = cellWidth - 2 * gridImageMargin; + const maxImgHeight = cellHeight - 2 * gridImageMargin; + + if (maxImgWidth <= 0 || maxImgHeight <= 0) { + return sendStyledMessage('Set Grid', 'Grid layout failed: Page is too small to fit all images with required spacing. Resize the page or reduce the number of images and try again.'); + } + + const positions = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + positions.push({ + x: c * cellWidth + cellWidth / 2, + y: r * cellHeight + 105 + cellHeight / 2 + }); + } + } + + validImages.slice(0, imgCount).forEach((img, i) => { + const pos = positions[i]; + const dims = getScaledToFit(img.ratio || 1, maxImgWidth, maxImgHeight); + const cleanUrl = cleanImg(img.url); + if (!cleanUrl) return; + + const g = createObj('graphic', { + _pageid: pageId, + layer: 'map', + imgsrc: cleanUrl, + left: pos.x, + top: pos.y, + width: dims.w, + height: dims.h, + isdrawing: true, + name: img.title || `Image ${i + 1}`, + showname: false, + showplayers_name: false, + }); + + if (!g) return; + + tagGraphicAsDirector(g); + + createObj('pathv2', { + _pageid: pageId, + layer: 'walls', + stroke: '#84d162', + stroke_width: 5, + fill: 'transparent', + shape: 'rec', + points: JSON.stringify([ + [0, 0], + [cellWidth, cellHeight] + ]), + x: pos.x, + y: pos.y, + barrierType: 'wall', + controlledby: '' + }); + }); + + st.lastSetScene = currentScene; + + // --- Character Tokens (unchanged) --- + const charItems = (st.items?.buttons || []).filter(btn => + btn.scene === currentScene && + ( + (btn.type === 'character' && btn.refId) || + (btn.type === 'variant') + ) + ); + + let tokenTop = 105; + let tokenLeft = pageWidth + 70; + let currentColumnMaxWidth = 70; + + const placeNextToken = () => { + if (!charItems.length) return; + + const btn = charItems.shift(); + + const handlePlacement = (props, name) => { + const tokenWidth = props.width || 70; + const tokenHeight = props.height || 70; + + if (tokenTop + tokenHeight > pageHeight - 50) { + tokenTop = 105; + tokenLeft += currentColumnMaxWidth + 70; + currentColumnMaxWidth = tokenWidth; + } else { + currentColumnMaxWidth = Math.max(currentColumnMaxWidth, tokenWidth); + } + + props.left = tokenLeft + tokenWidth / 2; + props.top = tokenTop + tokenHeight / 2; + + const token = createObj('graphic', props); + tagGraphicAsDirector(token); + + tokenTop += tokenHeight + 20; + }; + + if (btn.type === 'variant') { + try { + const props = { ...btn.tokenProps }; + if (!props || !props.imgsrc) { + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)) || FALLBACK_IMG; + } + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); + props._pageid = pageId; + props.layer = 'objects'; + + handlePlacement(props, btn.name); + } catch (e) { + log(`[Director] Error placing variant "${btn.name}": ${e.message}`); + } + return setTimeout(placeNextToken, 0); + } + + const char = getObj('character', btn.refId); + if (!char) return placeNextToken(); + + char.get('_defaulttoken', (blob) => { + try { + const props = JSON.parse(blob); + if (!props || !props.imgsrc) return placeNextToken(); + + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); + props._pageid = pageId; + props.layer = 'objects'; + + handlePlacement(props, char.get('name')); + + log(char.get('name')+": props = " + props); + + + } catch (e) { + log(`[Director] Error parsing default token for ${char.get('name')}: ${e}`); + } + setTimeout(placeNextToken, 0); + }); + }; + + placeNextToken(); + updateHandout(); +}; + + + + +const getScaledDimensions = (ratio, maxDim) => { + const r = parseFloat(ratio) || 1; + let w, h; + if (r >= 1) { + // Taller than wide — scale height to max + h = maxDim; + w = Math.round(maxDim / r); + } else { + // Wider than tall — scale width to max + w = maxDim; + h = Math.round(maxDim * r); + } + return { w, h }; +}; + + +const sanitizeTokenProps = (raw) => { + const props = { ...raw }; + delete props._id; + delete props.id; + delete props._type; + delete props._pageid; + delete props.layer; + + if (props.imgsrc) props.imgsrc = cleanImg(props.imgsrc); + + return props; +}; + + + + + +const cleanImg = (src) => { + if (!src) return ''; + const parts = src.match(/(.*\/images\/.*)(thumb|med|original|max)([^?]*)(\?[^?]+)?$/); + if (parts) { + return parts[1] + 'thumb' + parts[3] + (parts[4] || `?${Math.floor(Math.random() * 9999999)}`); + } + return ''; +}; + + +const renderHelpHtml = (css) => ` +
+

Director

+

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.

+
+ +

Interface Overview

+

The interface appears in a Roll20 handout. It consists of four main sections:

+ +
+ +

Acts & Scenes

+

Act Controls

+

Acts group together related scenes. Use the + + Add Act + button to create an act.

+

In Edit Mode, act-level options include: +

+

+ +

Scene Controls

+

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: +

+

+
+ +

Images

+

Backdrop vs. Highlight

+

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) +

Adding Images

+

To add an image: +

    +
  1. Drag a graphic to the tabletop. Hold Alt/Option while dragging to preserve aspect ratio.
  2. +
  3. Select the graphic and click + + Add Image + at the top of the Images section. +
  4. +
+

+ +

Image Controls

+ +

Mute Button

+

Click to toggle. When this button is red, the audio track auto-play behavior of backdrops is suppressed.

+
+ +

Items (Characters, Variants, Tracks, Macros, Tables)

+

Items define what is placed or triggered when a scene is set. Items are scoped per scene.

+ +

Adding Items

+

Click a badge to add a new item:

+
+
H
+
C
+
V
+
T
+
M
+
R
+
+ +

Item Behavior

+ + + + + +

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.

+ +

Edit Controls

+

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. +

+
+ +

Header Buttons

+

Set Scene as:

+

Scene populates the tabletop with: +

+

+

Grid populates the tabletop with: +

+

+

Only works if the current page name contains: scene, stage, theater, theatre

+ +

Wipe Scene

+

Wipe the Scene removes all placed images and stops all audio.

+

Only functions on valid stage pages.

+ +

Edit Mode

+

${Pictos(')')} toggles editing. When enabled:

+ +

JB+

+

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.

+

Help

+

Displays this Help documentation. While in help mode, this changes to read "Exit Help".

+

Make Help Handout

+

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.

+ + +

Helpful Macros

+

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
+  
+
+`; + +const getJukeboxPlusHandoutLink = () => { + const css = getCSS(); + if (typeof API_Meta !== 'undefined' && + API_Meta.JukeboxPlus && + typeof API_Meta.JukeboxPlus.offset === 'number') { + + const handout = findObjs({ type: 'handout', name: 'Jukebox Plus' })[0]; + if (handout) { + const url = `http://journal.roll20.net/handout/${handout.id}`; + return `JB+`; + } + } + return ''; +}; + + + +// 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'))); + const macros = findObjs({ _type: 'macro' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); + const tables = findObjs({ _type: 'rollabletable' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); + const tracks = findObjs({ _type: 'jukeboxtrack' }).sort((a, b) => a.get('title').localeCompare(b.get('title'))); + + const buildOpts = (objs, labelFn = o => o.get('name')) => + objs.map(o => `${labelFn(o).replace(/"/g, """)},${o.id}`).join('|'); + + const charOpts = buildOpts(characters); + const handoutOpts = buildOpts(handouts); + const macroOpts = buildOpts(macros); + 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`, + `V`, + `T`, + `M`, + `R`, + `🔍 + ${filterByType}`, + // Only show starFilterBtn if NOT in grid mode + ...(!gridModeActive ? [starFilterBtn] : []) + ]; + + return buttons.join(''); +}; + + + +// Render the items list with handout buttons and inline query prompt if undefined +const renderItemsList = (css) => { + const st = getState(); + 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 || + (activeFilter === 'character' && btn.type === 'variant'); + const excludeActions = btn.type !== 'action'; + + 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'))); + const tables = findObjs({ _type: 'rollabletable' }).sort((a, b) => a.get('name').localeCompare(b.get('name'))); + + return items.map(btn => { + let action = ''; + 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('|'); + action = `!director --set-action-character|${btn.id}|?{Select Character|${options}}`; + labelText = 'New Action'; + tooltipAttr = ` title="Assign character for action"`; + } else if (btn.refId && !btn.actionName) { + const char = getObj('character', btn.refId); + let actions = []; + if (char) { + const abilities = findObjs({ _type: 'ability', _characterid: char.id }); + actions = abilities.map(a => a.get('name')).sort(); + } + const opts = actions.length + ? actions.map(name => `${name},${name}`).join('|') + : 'No Actions Available,None'; + action = `!director --set-action|${btn.id}|?{Select Action|${opts}}`; + labelText = `${char ? char.get('name') : 'Unknown Character'} — Choose Action`; + tooltipAttr = ` title="Choose action for character"`; + } else if (btn.refId && btn.actionName) { + const char = getObj('character', btn.refId); + labelText = `${char ? char.get('name') : 'Unknown Character'}: ${btn.actionName}`; + action = `!director --run-action|${btn.id}`; + tooltipAttr = ` title="Run character action"`; + } + } else if (btn.type === 'handout') { + if (btn.refId) { + action = `http://journal.roll20.net/handout/${btn.refId}`; + tooltipAttr = ` title="Open handout"`; + } else { + const sanitizeQueryLabel = (label) => label.replace(/,/g, '—'); + const options = handouts.map(h => `${sanitizeQueryLabel(h.get('name'))},${h.id}`).join('|'); + action = `!director --set-handout|${btn.id}|?{Select Handout|${options}}`; + tooltipAttr = ` title="Assign handout"`; + } + } else if (btn.type === 'character') { + if (btn.refId) { + action = `http://journal.roll20.net/character/${btn.refId}`; + tooltipAttr = ` title="Open character sheet"`; + } else { + const options = characters.map(c => `${c.get('name')},${c.id}`).join('|'); + action = `!director --set-character|${btn.id}|?{Select Character|${options}}`; + tooltipAttr = ` title="Assign character sheet"`; + } + } else if (btn.type === 'variant') { + if (btn.refId) { + action = `http://journal.roll20.net/character/${btn.refId}`; + tooltipAttr = btn.tokenProps?.tooltip + ? ` title="${btn.tokenProps.tooltip.replace(/"/g, '"')}"` + : ` title="Linked variant token"`; + } else { + action = 'javascript:void(0)'; + tooltipAttr = ` title="Unlinked variant token"`; + } + } else if (btn.type === 'macro') { + if (btn.refId) { + action = `!director --run-macro|${btn.refId}`; + tooltipAttr = ` title="Run macro"`; + } else { + const options = macros.map(m => `${m.get('name')},${m.id}`).join('|'); + action = `!director --set-macro|${btn.id}|?{Select Macro|${options}}`; + tooltipAttr = ` title="Assign macro"`; + } + } else if (btn.type === 'table') { + if (btn.refId) { + const table = getObj('rollabletable', btn.refId); + if (table) { + action = `!director --roll-table|${btn.refId}`; + tooltipAttr = ` title="Roll table"`; + } else { + const options = tables.map(t => `${t.get('name')},${t.id}`).join('|'); + action = `!director --set-table|${btn.id}|?{Select Table|${options}}`; + tooltipAttr = ` title="Assign table"`; + } + } else { + const options = tables.map(t => `${t.get('name')},${t.id}`).join('|'); + action = `!director --set-table|${btn.id}|?{Select Table|${options}}`; + tooltipAttr = ` title="Assign table"`; + } + } else if (btn.type === 'track') { + const track = btn.refId ? getObj('jukeboxtrack', btn.refId) : null; + const isPlaying = track?.get('playing'); + + if (btn.refId && track) { + labelText = `${track.get('title')}${isPlaying ? ' ♬' : ''}`; + action = `!director --toggle-track|${btn.refId}`; + tooltipAttr = ` title="${track.get('title')}"`; + } else { + action = `!director --check-or-assign-track|${btn.id}`; + labelText = `New Track`; + tooltipAttr = ` title="Assign or play track"`; + } + } else { + action = `!director --item-placeholder|${btn.id}`; + tooltipAttr = ` title="Item placeholder"`; + } + + // --- build edit controls --- + const editControls = isEditMode + ? ` + ${Pictos('p')} + ${Pictos('#')} + ` + : ''; + + // --- 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 ` +
+ + ${getBadge(btn.type, css)} ${labelText} + ${editControls} + + ${starHTML} +
+ `; + }).join(''); +}; + +// Helper to reorder keys in an object according to new order array +const reorderObjectKeys = (obj, keyOrder) => { + const newObj = {}; + for (const key of keyOrder) { + if (obj.hasOwnProperty(key)) { + newObj[key] = obj[key]; + } + } + return newObj; +}; + +function moveActUp(actName) { + const st = getState(); + const keys = st.actsOrder || Object.keys(st.acts); + const idx = keys.indexOf(actName); + if (idx <= 0) return; + + const newKeys = [...keys]; + [newKeys[idx - 1], newKeys[idx]] = [newKeys[idx], newKeys[idx - 1]]; + + const reordered = {}; + newKeys.forEach(k => (reordered[k] = st.acts[k])); + st.acts = reordered; + + st.actsOrder = newKeys; // Update the order array + + updateState(st); + updateHandout(); +} + +function moveActDown(actName) { + const st = getState(); + const keys = st.actsOrder || Object.keys(st.acts); + const idx = keys.indexOf(actName); + if (idx === -1 || idx >= keys.length - 1) return; + + const newKeys = [...keys]; + [newKeys[idx], newKeys[idx + 1]] = [newKeys[idx + 1], newKeys[idx]]; + + const reordered = {}; + newKeys.forEach(k => (reordered[k] = st.acts[k])); + st.acts = reordered; + + st.actsOrder = newKeys; // Update the order array + + updateState(st); + updateHandout(); +} +function moveSceneUp(actName, sceneName) { + const st = getState(); + const act = st.acts?.[actName]; + if (!act || !act.scenes?.hasOwnProperty(sceneName)) return; + + const actKeys = st.actsOrder || Object.keys(st.acts); + const expanded = st.actsExpanded || {}; + const scenes = act.scenes; + const sceneKeys = act.scenesOrder || Object.keys(scenes); + const idx = sceneKeys.indexOf(sceneName); + if (idx === -1) return; + + if (idx > 0) { + const newSceneKeys = [...sceneKeys]; + [newSceneKeys[idx - 1], newSceneKeys[idx]] = [newSceneKeys[idx], newSceneKeys[idx - 1]]; + + const reordered = {}; + newSceneKeys.forEach(k => (reordered[k] = scenes[k])); + act.scenes = reordered; + act.scenesOrder = newSceneKeys; + } else { + // Find previous expanded act + const actIdx = actKeys.indexOf(actName); + for (let i = actIdx - 1; i >= 0; i--) { + const prevActName = actKeys[i]; + if (expanded[prevActName]) { + const prevAct = st.acts[prevActName]; + if (!prevAct) return; + + prevAct.scenes = { ...prevAct.scenes, [sceneName]: scenes[sceneName] }; + act.scenes = { ...scenes }; + delete act.scenes[sceneName]; + + prevAct.scenesOrder = Object.keys(prevAct.scenes); + act.scenesOrder = Object.keys(act.scenes); + break; + } + } + } + + updateState(st); + updateHandout(); +} + +function moveSceneDown(actName, sceneName) { + const st = getState(); + const act = st.acts?.[actName]; + if (!act || !act.scenes?.hasOwnProperty(sceneName)) return; + + const actKeys = st.actsOrder || Object.keys(st.acts); + const expanded = st.actsExpanded || {}; + const scenes = act.scenes; + const sceneKeys = act.scenesOrder || Object.keys(scenes); + const idx = sceneKeys.indexOf(sceneName); + if (idx === -1) return; + + if (idx < sceneKeys.length - 1) { + const newSceneKeys = [...sceneKeys]; + [newSceneKeys[idx], newSceneKeys[idx + 1]] = [newSceneKeys[idx + 1], newSceneKeys[idx]]; + + const reordered = {}; + newSceneKeys.forEach(k => (reordered[k] = scenes[k])); + act.scenes = reordered; + act.scenesOrder = newSceneKeys; + } else { + // Find next expanded act + const actIdx = actKeys.indexOf(actName); + for (let i = actIdx + 1; i < actKeys.length; i++) { + const nextActName = actKeys[i]; + if (expanded[nextActName]) { + const nextAct = st.acts[nextActName]; + if (!nextAct) return; + + nextAct.scenes = { [sceneName]: scenes[sceneName], ...nextAct.scenes }; + act.scenes = { ...scenes }; + delete act.scenes[sceneName]; + + nextAct.scenesOrder = Object.keys(nextAct.scenes); + act.scenesOrder = Object.keys(act.scenes); + break; + } + } + } + + updateState(st); + updateHandout(); +} + +function moveImageUp(imageId) { + const st = getState(); + const currentScene = st.activeScene; + if (!currentScene) return; + + const scene = Object.values(st.acts).flatMap(a => Object.values(a.scenes || {})).find(s => s && s.images?.some(img => img.id === imageId)); + if (!scene) return; + + const idx = scene.images.findIndex(img => img.id === imageId); + if (idx > 0) { + const newImages = [...scene.images]; + [newImages[idx - 1], newImages[idx]] = [newImages[idx], newImages[idx - 1]]; + scene.images = newImages; + updateState(st); + updateHandout(); + } +} + +function moveImageDown(imageId) { + const st = getState(); + const currentScene = st.activeScene; + if (!currentScene) return; + + const scene = Object.values(st.acts).flatMap(a => Object.values(a.scenes || {})).find(s => s && s.images?.some(img => img.id === imageId)); + if (!scene) return; + + const idx = scene.images.findIndex(img => img.id === imageId); + if (idx < scene.images.length - 1) { + const newImages = [...scene.images]; + [newImages[idx], newImages[idx + 1]] = [newImages[idx + 1], newImages[idx]]; + scene.images = newImages; + updateState(st); + updateHandout(); + } +} + + + +const initializeOrderArrays = (st) => { + if (!st.actsOrder) { + st.actsOrder = Object.keys(st.acts || {}); + } + + for (const actName of st.actsOrder) { + const act = st.acts[actName]; + if (act && !act.scenesOrder) { + act.scenesOrder = Object.keys(act.scenes || {}); + } + } +}; + +const overlayButtonsContainer = ` +
+ +
+`; + +const getTrackNameById = (id) => { + const track = findObjs({ type: 'jukeboxtrack', id })[0]; + return track ? track.get('title') : 'Unknown Track'; +}; + +const isTrackPlaying = (id) => { + const track = findObjs({ type: 'jukeboxtrack', id })[0]; + return track && track.get('playing'); +}; + + + + + + +// --- Handout Update --- +const updateHandout = () => { + const css = getCSS(); + const st = getState(); + + const handout = findObjs({ type: 'handout', name: 'Director' })[0]; + if (!handout) return; + + // === Help Mode === + if (st.helpMode) { + const html = ` +
+ + + + +
+ Director + Exit Help + Make Help Handout +
+ ${renderHelpHtml(css)} +
+ `; + return handout.set({ notes: html }); + } + + + +for (const actName of Object.keys(st.acts)) { + const act = st.acts[actName]; + const sceneKeys = Object.keys(act.scenes || {}); + const orderKeys = act.scenesOrder || []; + const badKeys = orderKeys.filter(name => !sceneKeys.includes(name)); + if (badKeys.length > 0) { + log(`Mismatch in "${actName}": scenesOrder contains invalid keys:`, badKeys); + } +} + + + + + + if (!st.acts) st.acts = {}; + initializeOrderArrays(st); + + + const actsExpanded = st.actsExpanded || {}; + const activeScene = st.activeScene; + const isEditMode = !!st.items?.editMode; + +let scenesObj = {}; +for (const actName of st.actsOrder || Object.keys(st.acts)) { + const act = st.acts[actName]; + if (!act || !act.scenes) continue; + if (Object.prototype.hasOwnProperty.call(act.scenes, activeScene)) { + scenesObj = act.scenes; + break; + } +} + + + let actsHtml = ''; + for (const actName of st.actsOrder) { + if (!(actName in st.acts)) continue; // skip if missing act due to deletion + + const expanded = !!actsExpanded[actName]; + const caret = expanded ? '▼' : '▶'; + + // === Act-level edit controls === + const actControls = isEditMode + ? ` +${Pictos('p')} + + ${Pictos('#')} + +${Pictos('{')} +${Pictos('}')} + ` + : ''; + +actsHtml += ` +
+ + ${caret} + ${actName} + + + + ${actControls} +`; + + if (expanded) { + const act = st.acts[actName]; + const scenes = act.scenes || {}; + const scenesOrder = act.scenesOrder || Object.keys(scenes); + + actsHtml += '
'; + for (const sceneName of scenesOrder) { + if (!(sceneName in scenes)) continue; // skip if scene missing + + const isActiveScene = sceneName === activeScene; + + // === Scene-level edit controls === + const sceneControls = isEditMode + ? ` + ${Pictos('p')} + + ${Pictos('#')} + + ${Pictos('{')} + ${Pictos('}')} ` + : ''; + + actsHtml += ` + + ${sceneName} + ${sceneControls} + + `; + } + actsHtml += '
'; + } + actsHtml += '
'; + } + + const getImageUrl = (img) => { + if (!img.url || typeof img.url !== 'string') return ''; + return img.url.replace(/(thumb|med|original)/, 'max'); + }; + +const imagesHTML = (() => { + if (!activeScene) return '
No active scene.
'; + const images = scenesObj[activeScene]?.images || []; + if (images.length === 0) return '
No images yet
'; + + return images.map(img => ` +
+
+ ${img.title || 'Untitled'} +
+ + ${isEditMode ? ` +
+${Pictos('{')} +${Pictos('}')} + ${Pictos('R')} + ${Pictos('#')} +
+ ` : ''} + + +
+ ${Pictos('|')} + ${Pictos('`')} + + + ${ + img.trackId + ? ` + ${Pictos('m')} + ` + : `${Pictos('m')}` + } + + + ${isEditMode && img.trackId + ? `${Pictos('dm')}` + : '' + } +
+ +
+
+
+ `).join(''); +})(); + + + + const html = ` +
+ + + + + + + + + +
+ Director + +
+Set as + + Grid + + + Scene + +
+ + Wipe the Scene + + + Stop Audio + +${getJukeboxPlusHandoutLink()} + + + ${st.helpMode ? 'Exit Help' : 'Help'} + + + + ${Pictos(st.items?.editMode ? ')' : '(')} +
+
+ Acts + + Add Act +
+
${actsHtml}
+ +
+ +
+ + Settings ${st.settings.settingsExpanded ? '▴' : '▾'} + + ${st.settings.settingsExpanded ? ` +
+ Mode + Dark + Light +
+
+ Backup + make + restore +
+ ↻ Repair + ` : ''} +
+
+ + + + ${imagesHTML} + +
+ Items ${renderFilterBarInline(css)} + +
+${renderItemsList(css)} + +
+
`; + + + handout.set({ notes: html }); +}; + + + +// Jukebox Handler +on('change:jukeboxtrack', () => { + updateHandout(); // Refresh labels to reflect play status +}); + + + +// --- Main Chat Handler --- + +on('chat:message', (msg) => { + if (msg.type !== 'api') return; +if (!msg.playerid || !playerIsGM(msg.playerid)) { + //sendStyledMessage('Access Denied', 'Only the GM can use Director commands.'); + return; +} + const playerid = msg.playerid; + + const input = msg.content; + if (!input.startsWith('!director')) return; + + const parts = input.split(/\s+--/).slice(1); + const st = getState(); // assuming getState() returns or initializes state.DirectorScript + + if (!parts.length) { + const handout = findObjs({ type: 'handout', name: 'Director' })[0] || createObj('handout', { name: 'Director' }); + sendStyledMessage('Director', `[Open the Director Interface](http://journal.roll20.net/handout/${handout.id})`); + updateHandout(); + return; + } + + for (const part of parts) { + const [cmd, ...params] = part.split('|'); + const val = params.join('|').trim(); + + switch (cmd.trim()) { + + +case 'filter': { + const filterType = val?.toLowerCase(); + const validTypes = ['handout', 'character', 'track', 'macro', 'table', 'action', 'all']; + if (!validTypes.includes(filterType)) { + sendStyledMessage('Director', `Invalid filter type: "${val}".`); + break; + } + + st.items = st.items || {}; + st.items.filter = filterType; + updateHandout(); + break; +} + + + + + + + +case 'add-item': { + const type = val?.toLowerCase(); + const validTypes = ['handout', 'character', 'track', 'macro', 'action', 'table', 'variant']; + if (!validTypes.includes(type)) { + sendStyledMessage('Director', `Invalid item type: "${val}".`); + break; + } + + st.items = st.items || {}; + st.items.buttons = st.items.buttons || []; + const activeScene = st.activeScene || null; + + // === TRACK: only assign if one is playing === + if (type === 'track') { + const tracks = findObjs({ _type: 'jukeboxtrack' }); + const playingTracks = tracks.filter(t => t.get('playing')); + + if (playingTracks.length > 0) { + const track = playingTracks[0]; + st.items.buttons.push({ + id: generateUUID(), + type: 'track', + name: track.get('title'), + refId: track.id, + scene: activeScene, + }); + updateHandout(); + } else { + sendStyledMessage('Add Track', 'No track is currently playing. Start a track before creating a track button.'); + } + break; + } + + // === VARIANT: create one button per selected token === + if (type === 'variant') { + if (!msg.selected || !msg.selected.length) { + sendStyledMessage('Director', 'You must select one or more tokens to define as variants.'); + break; + } + + const created = []; + + for (const sel of msg.selected) { + const token = getObj('graphic', sel._id); + if (!token) continue; + + const rawProps = token.toJSON(); + const cleanedProps = sanitizeTokenProps(rawProps); + + // Marketplace image check (non-blocking) + const imgsrc = token.get('imgsrc') || ''; + if (imgsrc.includes('/marketplace/')) { + sendStyledMessage('Marketplace Image Detected', 'This asset uses a marketplace image and may not render correctly on the VTT. A fallback will be used at placement.'); + log(`⚠️ This asset uses a marketplace image and may not render correctly on the VTT. A fallback will be used at placement.`); + } +log ("imgsrc = " + imgsrc); + st.items.buttons.push({ + id: generateUUID(), + type: 'variant', + name: token.get('name') || 'New Variant', + refId: token.get('represents') || null, + tokenProps: cleanedProps, + scene: activeScene, + }); + + created.push(token.get('name') || 'New Variant'); + } + + updateHandout(); + + if (created.length) { + //sendStyledMessage('Director', `Created ${created.length} variant button${created.length > 1 ? 's' : ''}:
${created.join(', ')}`); + } else { + sendStyledMessage('Director', 'No valid tokens were selected.'); + } + + break; + } + + // === DEFAULT: create placeholder for other types === + st.items.buttons.push({ + id: generateUUID(), + type, + name: `New ${capitalize(type)}`, + refId: null, + actionName: null, + scene: activeScene, + }); + + updateHandout(); + break; +} + + + + + + +case 'toggle-edit-mode': { + st.items = st.items || {}; + st.items.editMode = !st.items.editMode; + updateHandout(); + break; +} + + + +case 'redefine-item': { + const btnId = val; + const btn = st.items?.buttons?.find(b => b.id === btnId); + if (!btn) break; + + const defaultName = `New ${capitalize(btn.type)}`; + btn.name = defaultName; + btn.refId = null; + + updateHandout(); + break; +} + + +case 'delete-item': { + const btnId = val; + const index = st.items?.buttons?.findIndex(b => b.id === btnId); + if (index !== -1) { + st.items.buttons.splice(index, 1); + updateHandout(); + } else { + sendStyledMessage('Director', `Item not found for deletion: ${btnId}`); + } + break; +} + + + + + + + + + + + + +// Handler to assign the selected handout to the button and refresh UI + handout +case 'set-handout': { + const [btnId, handoutId] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + const handout = getObj('handout', handoutId); + if (btn && handout) { + btn.name = handout.get('name'); + btn.refId = handoutId; + updateHandout(); + } else { + sendStyledMessage('Director', `Failed to assign handout "${handoutId}" to item "${btnId}".`); + } + break; +} + +//QX Needed? +case 'set-character': { + const [btnId, charId] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + const char = getObj('character', charId); + if (btn && char) { + btn.name = char.get('name'); + btn.refId = charId; + updateHandout(); + } else { + sendStyledMessage('Director', `Failed to assign character "${charId}" to item "${btnId}".`); + } + break; +} + + +case 'add-character': { + const charId = val; + const char = getObj('character', charId); + if (!char) { + sendStyledMessage('Director', `Character ID "${charId}" not found.`); + break; + } + + // Check for marketplace image (non-blocking) + const defaultToken = char.get('defaulttoken'); + if (defaultToken) { + try { + const tokenObj = JSON.parse(defaultToken); + const imgsrc = tokenObj.imgsrc || ''; + if (imgsrc.includes('/marketplace/')) { + sendStyledMessage('Marketplace Image Detected', 'This asset uses a marketplace image and may not render correctly on the VTT. A fallback will be used at placement.'); + log(`This asset uses a marketplace image and may not render correctly on the VTT. A fallback will be used at placement.`); + } + } catch (e) { + log(`⚠️ Unable to parse default token for character ID ${charId}`); + } + } + + const st = getState(); + st.items = st.items || {}; + st.items.buttons = st.items.buttons || []; + + const id = generateUUID(); + const activeScene = st.activeScene || null; + + st.items.buttons.push({ + id, + type: 'character', + name: char.get('name'), + refId: charId, + actionName: null, + scene: activeScene + }); + + updateHandout(); + break; +} + + + +case 'add-handout': { + const handout = getObj('handout', val); + if (!handout) break; + + const st = getState(); + const id = generateUUID(); + const activeScene = st.activeScene || null; + + st.items.buttons.push({ + id, + type: 'handout', + name: handout.get('name'), + refId: val, + actionName: null, + scene: activeScene + }); + + updateHandout(); + break; +} + + +case 'add-track': { + const track = getObj('jukeboxtrack', val); + if (!track) break; + + const st = getState(); + const id = generateUUID(); + const activeScene = st.activeScene || null; + + st.items.buttons.push({ + id, + type: 'track', + name: track.get('title'), + refId: val, + actionName: null, + scene: activeScene + }); + + updateHandout(); + break; +} + + +case 'add-macro': { + const macro = getObj('macro', val); + if (!macro) { + sendStyledMessage('Director', `No Macro Chosen. You must have at least one Macro in your Collections tab.`); + break; +} + const st = getState(); + const id = generateUUID(); + const activeScene = st.activeScene || null; + + st.items.buttons.push({ + id, + type: 'macro', + name: macro.get('name'), + refId: val, + actionName: null, + scene: activeScene + }); + + updateHandout(); + break; +} + + +case 'add-table': { + const table = getObj('rollabletable', val); + if (!table) { + sendStyledMessage('Director', `No Rollable Table Chosen. You must have at least one Rollable Table in your Collections tab.`); + break; + } + + const st = getState(); + const id = generateUUID(); + const activeScene = st.activeScene || null; + + st.items.buttons.push({ + id, + type: 'table', + name: table.get('name'), + refId: val, + actionName: null, + scene: activeScene + }); + + updateHandout(); + break; +} + + + + + + + + +case 'set-variant-character': { + if (!msg.selected || msg.selected.length === 0) { + sendStyledMessage('Director', 'Please select one or more tokens that represent characters.'); + break; + } + + const activeScene = getActiveScene(); + if (!activeScene) { + sendStyledMessage('Director', 'No active scene. Please select or create one.'); + break; + } + + const createdNames = []; + + for (const sel of msg.selected) { + const token = getObj('graphic', sel._id); + if (!token) continue; + + const variantBtn = createVariantButtonFromToken(token, activeScene); + if (!variantBtn) continue; + + st.items.buttons.push(variantBtn); + createdNames.push(variantBtn.name); + } + + if (createdNames.length) { + updateHandout(); + sendStyledMessage('Director', `Created ${createdNames.length} variant button${createdNames.length > 1 ? 's' : ''}:
${createdNames.join(', ')}`); + } else { + sendStyledMessage('Director', 'No valid tokens were selected or none were linked to characters.'); + } + break; +} + + + +case 'define-variant': { + const [btnId] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + + if (!btn || btn.type !== 'variant') { + sendStyledMessage('Director', `Invalid variant button ID: "${btnId}".`); + break; + } + + const selected = msg.selected; + if (!selected || !selected.length) { + sendStyledMessage('Director', 'Please select a token to define this variant.'); + break; + } + + const token = getObj('graphic', selected[0]._id); + if (!token) { + sendStyledMessage('Director', 'Selected token could not be found.'); + break; + } + + // Marketplace image check (non-blocking) + const imgsrc = token.get('imgsrc') || ''; + if (imgsrc.includes('/marketplace/')) { + sendStyledMessage('Marketplace Image Detected', 'This asset uses a marketplace image and may not render correctly on the VTT. A fallback will be used at placement.'); + log(`This asset uses a marketplace image and may not render correctly on the VTT. A fallback will be used at placement.`); + } + + const variantBtn = createVariantButtonFromToken(token, getActiveScene()); + if (!variantBtn) { + sendStyledMessage('Director', 'Selected token must represent a character.'); + break; + } + + // Update existing button in place + btn.refId = variantBtn.refId; + btn.name = variantBtn.name; + btn.tokenProps = variantBtn.tokenProps; + + updateHandout(); + sendStyledMessage('Director', `Variant defined as "${btn.name}".`); + break; +} + + +case 'set-macro': { + const [btnId, macroId] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + const macro = getObj('macro', macroId); + if (btn && macro) { + btn.name = macro.get('name'); + btn.refId = macroId; + updateHandout(); + } else { + sendStyledMessage('Director', `Failed to assign macro "${macroId}" to item "${btnId}".`); + } + break; +} + + +case 'set-action-character': { + const [btnId, charId] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + const char = getObj('character', charId); + if (btn && char) { + btn.refId = charId; + btn.actionName = null; // reset action selection + btn.name = `New Action`; // temporary label until action chosen + updateHandout(); + } else { + sendStyledMessage('Director', `Failed to assign character "${charId}" to action item "${btnId}".`); + } + break; +} + +case 'set-action': { + const [btnId, actionName] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + if (btn && btn.refId && actionName) { + btn.actionName = actionName; + const char = getObj('character', btn.refId); + btn.name = `${char ? char.get('name') : 'Unknown'}: ${actionName}`; + updateHandout(); + } else { + sendStyledMessage('Director', `Failed to assign action "${actionName}" to item "${btnId}".`); + } + break; +} + + + +case 'run-action': { + const btnId = val; + const btn = st.items?.buttons?.find(b => b.id === btnId); + if (btn && btn.refId && btn.actionName) { + // Find ability on character matching actionName + const abilities = findObjs({ + _type: 'ability', + _characterid: btn.refId, + name: btn.actionName, + }); + + if (abilities.length) { + const ability = abilities[0]; + const actionText = ability.get('action'); + if (actionText && actionText.trim().length > 0) { + + sendChat('GM', actionText); + + } else { + sendStyledMessage('Director', `Ability "${btn.actionName}" has no action text.`); + } + } else { + sendStyledMessage('Director', `Ability "${btn.actionName}" not found on character.`); + } + } else { + sendStyledMessage('Director', `Invalid action button or not fully defined.`); + } + break; +} + + + +case 'roll-table': { + const tableId = val; + const table = getObj('rollabletable', tableId); + if (table) { + // Roll the table with a whisper using the default roll template and the table name + sendChat('Director', `/w gm &{template:default} {{name=${table.get('name')}}} {{=[[1t[${table.get('name')}]]]}}`); + sendStyledMessage(table.get('name'), `=[[1t[${table.get('name')}]]]`); + } + break; +} + +case 'set-table': { + const [btnId, tableId] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + const table = getObj('rollabletable', tableId); + if (btn && table) { + btn.name = table.get('name'); + btn.refId = tableId; + updateHandout(); + } else { + sendStyledMessage('Director', `Failed to assign table "${tableId}" to item "${btnId}".`); + } + break; +} + + +case 'run-macro': { + const macroId = val; + const macro = getObj('macro', macroId); + if (macro) { + sendChat('GM', macro.get('action')); + } else { + sendStyledMessage('Director', `Macro not found: ${macroId}`); + } + break; +} + + + + +case 'check-or-assign-track': { + const btnId = val; + const btn = st.items?.buttons?.find(b => b.id === btnId); + const tracks = findObjs({ _type: 'jukeboxtrack' }); + const playingTracks = tracks.filter(t => t.get('playing')); + if (!btn) break; + + + + if (playingTracks.length > 0) { + const track = playingTracks[0]; + btn.name = track.get('title'); + btn.refId = track.id; + updateHandout(); + } else { + const options = tracks + .sort((a, b) => a.get('title').localeCompare(b.get('title'))) + .map(t => `${t.get('title')},${t.id}`) + .join('|'); + + const queryCmd = `!director --set-track|${btnId}|?{Select Track|${options}}`; + sendChat('Director', queryCmd); + } + + break; +} + +case 'set-track': { + const [btnId, trackId] = params; + const btn = st.items?.buttons?.find(b => b.id === btnId); + const track = getObj('jukeboxtrack', trackId); + + if (btn && track) { + btn.name = track.get('title'); + btn.refId = trackId; + updateHandout(); + } else { + sendStyledMessage('Director', `Failed to assign track "${trackId}" to item "${btnId}".`); + } + break; +} + +case 'toggle-track': { + const trackId = val; + const track = getObj('jukeboxtrack', trackId); + const allTracks = findObjs({ _type: 'jukeboxtrack' }); + + if (!track) { + sendStyledMessage('Director', `Track not found: ${trackId}`); + break; + } + + const isPlaying = track.get('playing'); + + track.set('playing', !isPlaying); // Start or stop this one + + updateHandout(); + break; +} + + + + + + + +case 'set-scene': { + handleSetScene(msg.playerid); + break; +} + + +case 'set-backdrop': { + const imageId = val; + const st = getState(); + const currentScene = st.activeScene; + if (!currentScene) break; + + let scene = null; + for (const act of Object.values(st.acts)) { + if (act.scenes?.[currentScene]) { + scene = act.scenes[currentScene]; + break; + } + } + if (!scene) break; + + const newBackdrop = scene.images.find(img => img.id === imageId); + if (!newBackdrop) { + sendStyledMessage('Set Backdrop', `Could not find image with ID ${imageId}.`); + break; + } + + // Stop track for the current backdrop if it has one + const oldBackdrop = scene.images.find(img => img.id === scene.backdropId); + if (oldBackdrop?.trackId) { + const oldTrack = getObj('jukeboxtrack', oldBackdrop.trackId); + if (oldTrack?.get('playing')) { + oldTrack.set('playing', false); + } + } + + const pid = getPageForPlayer(playerid); + if (!pid) { + sendStyledMessage('Set Backdrop', 'No valid page found for your view.'); + break; + } + + const page = getObj('page', pid); + if (!page) { + sendStyledMessage('Set Backdrop', 'Page object could not be found.'); + break; + } + + const existingPaths = findObjs({ _type: 'pathv2', _pageid: pid, layer: 'walls' }); + const blockingPaths = existingPaths.filter(p => p.get('stroke') === '#84d162'); + if (blockingPaths.length > 0) { + sendStyledMessage('Set Backdrop', `Cannot set backdrop. Please wipe the scene and use the Set as Scene command in order to use backdrops.`); + break; + } + + + const pageWidth = page.get('width') * 70; + const pageHeight = page.get('height') * 70; + const centerX = pageWidth / 2; + const centerY = pageHeight / 2; + + // Find existing backdrop graphic (by matching imgsrc OR name) + const allGraphics = findObjs({ _type: 'graphic', _pageid: pid, layer: 'map' }); + const cleanNewUrl = cleanImg(newBackdrop.url); + let targetGraphic = allGraphics.find(g => cleanImg(g.get('imgsrc')) === cleanImg(oldBackdrop?.url)); + if (!targetGraphic && oldBackdrop?.title) { + targetGraphic = allGraphics.find(g => g.get('name') === oldBackdrop.title); + } + + if (targetGraphic) { + const maxWidth = pageWidth - 140; + const maxHeight = pageHeight - 140; + const dims = getScaledToFit(newBackdrop.ratio, maxWidth, maxHeight); + + targetGraphic.set({ + imgsrc: cleanNewUrl, + width: dims.w, + height: dims.h, + left: centerX, + top: centerY, + name: newBackdrop.title || 'Backdrop', + }); + } + + // Set the new backdrop + scene.backdropId = imageId; + const idx = scene.images.findIndex(img => img.id === imageId); + if (idx > 0) { + const [backdrop] = scene.images.splice(idx, 1); + scene.images.unshift(backdrop); + } + + // ✅ If the new backdrop has a track, start playing it — unless muted + if (newBackdrop.trackId && !st.settings.muteBackdropAudio) { + const newTrack = getObj('jukeboxtrack', newBackdrop.trackId); + if (newTrack && !newTrack.get('playing')) { + newTrack.set('playing', true); + } + } + + updateState(st); + updateHandout(); + + + // Refresh GM-only starred-token highlights for this page/scene + // (creates gmlayer rects around tokens starred for the newly assigned backdrop) + highlightStarredTokens(currentScene, pid); + + + break; +} + + +case 'wipe-scene': { + const st = getState(); + + // Stop any playing tracks + const tracks = findObjs({ _type: 'jukeboxtrack' }); + tracks.forEach(track => { + if (track.get('playing')) { + track.set('playing', false); + } + }); + + const currentScene = st.activeScene; + if (!currentScene) { + sendStyledMessage('Wipe Scene', 'No active scene selected.'); + break; + } + + wipeScene(currentScene, msg.playerid); // Pass playerid from msg + break; +} + +case 'set-grid': + handleSetGrid(playerid); + break; + + + case 'open-handout': { + const handoutId = val; + const handout = getObj('handout', handoutId); + if (handout) handout.showToPlayers(); + break; + } + + case 'item-placeholder': { + const btnId = val; + const btn = st.items?.buttons?.find(b => b.id === btnId); + if (btn) { + sendStyledMessage('Not Implemented', `This is a placeholder for a ${btn.type} item.`, 'warning'); + } + break; + } + + + case 'toggle-star-filter': { + const st = getState(); + st.items = st.items || {}; + st.items.starMode = !st.items.starMode; + updateState(st); + updateHandout(); + break; +} + +case 'toggle-star': { + const assetId = val; // from "!director --toggle-star|assetId" + if (!assetId) break; + + const st = getState(); + const currentSceneName = st.activeScene; + if (!currentSceneName) { + sendStyledMessage('Director', 'No active scene set.'); + break; + } + + // Find scene object + let sceneObj = null; + for (const act of Object.values(st.acts || {})) { + if (act.scenes?.[currentSceneName]) { + sceneObj = act.scenes[currentSceneName]; + break; + } + } + if (!sceneObj) { + sendStyledMessage('Director', 'Active scene not found.'); + break; + } + + const backdropId = sceneObj.backdropId; + if (!backdropId) { + sendStyledMessage('Director', 'No backdrop image assigned. Assign a backdrop image before starring assets.'); + break; + } + + sceneObj.starredAssets = sceneObj.starredAssets || {}; + sceneObj.starredAssets[backdropId] = sceneObj.starredAssets[backdropId] || []; + + const starredList = sceneObj.starredAssets[backdropId]; + const index = starredList.indexOf(assetId); + if (index === -1) { + starredList.push(assetId); + //sendStyledMessage('Director', `Starred asset ${assetId} for backdrop.`); + } else { + starredList.splice(index, 1); + //sendStyledMessage('Director', `Unstarred asset ${assetId} for backdrop.`); + } + + updateState(st); + updateHandout(); + + // Add this call to refresh GM-layer highlights immediately + const pid = Campaign().get('playerpageid'); + highlightStarredTokens(currentSceneName, pid); + + break; +} + + + + + + + + + + + + case 'toggle-act': { +const actName = decodeURIComponent(val); + st.actsExpanded = st.actsExpanded || {}; + st.actsExpanded[actName] = !st.actsExpanded[actName]; + updateHandout(); + break; + } + +case 'new-act': { + const actName = decodeURIComponent(params[0] || '') || `Act ${Object.keys(st.acts).length + 1}`; + if (st.acts[actName]) { + sendStyledMessage('Director', `Act "${actName}" already exists.`); + break; + } + st.acts[actName] = { scenes: {}, scenesOrder: [] }; + + if (!Array.isArray(st.actsOrder)) st.actsOrder = []; + st.actsOrder.push(actName); + + st.activeAct = actName; + updateState(st); + updateHandout(); + break; +} + + +case 'new-scene': { + if (params.length >= 2) { + const actName = decodeURIComponent(params[0]); + const sceneName = decodeURIComponent(params[1]); + const act = st.acts[actName]; + if (!act) { + sendStyledMessage('Director', `Act "${actName}" not found.`); + break; + } + + if (act.scenes[sceneName]) { + sendStyledMessage('Director', `Scene "${sceneName}" already exists in act "${actName}".`); + break; + } + + act.scenes[sceneName] = { images: [], items: [], backdropId: null }; + if (!Array.isArray(act.scenesOrder)) act.scenesOrder = []; + act.scenesOrder.push(sceneName); + + st.activeScene = sceneName; + updateState(st); + updateHandout(); + break; + } + + const activeAct = getActiveAct(); + if (!activeAct) { + sendStyledMessage('Director', 'No active act. Create an act first.'); + break; + } + + const act = st.acts[activeAct]; + const sceneName = val || `Scene ${Object.keys(act.scenes).length + 1}`; + if (act.scenes[sceneName]) { + sendStyledMessage('Director', `Scene "${sceneName}" already exists in act "${activeAct}".`); + break; + } + + act.scenes[sceneName] = { images: [], items: [], backdropId: null }; + if (!Array.isArray(act.scenesOrder)) act.scenesOrder = []; + act.scenesOrder.push(sceneName); + + st.activeScene = sceneName; + updateState(st); + updateHandout(); + break; +} + + + case 'set-active-scene': { + const [actName, sceneName] = params.map(decodeURIComponent); + if (!actName || !sceneName) { + sendStyledMessage('Director', 'Both act and scene must be specified.'); + break; + } + const act = state[stateName].acts[actName]; + if (!act || !act.scenes[sceneName]) { + sendStyledMessage('Director', `Scene "${sceneName}" not found in act "${actName}".`); + break; + } + state[stateName].activeScene = sceneName; + updateHandout(); + break; +} + + +case 'rename-act': { + const oldName = params[0]?.trim(); + const newName = params[1]?.trim(); + if (!oldName || !newName) { + sendStyledMessage('Rename Act', 'You must provide both the old and new act names.'); + break; + } + + const st = getState(); + if (!st.acts?.[oldName]) { + sendStyledMessage('Rename Act', `Act "${oldName}" not found.`); + break; + } + + if (st.acts[newName]) { + sendStyledMessage('Rename Act', `An act named "${newName}" already exists.`); + break; + } + + // Rename act key + st.acts[newName] = st.acts[oldName]; + delete st.acts[oldName]; + + // Update actsOrder array + if (st.actsOrder && Array.isArray(st.actsOrder)) { + const idx = st.actsOrder.indexOf(oldName); + if (idx !== -1) { + st.actsOrder[idx] = newName; + } + } + + // Update actsExpanded keys if needed + if (st.actsExpanded?.[oldName]) { + st.actsExpanded[newName] = true; + delete st.actsExpanded[oldName]; + } + + // Active scene fix is fine but redundant + if (st.activeScene && st.acts[newName].scenes?.[st.activeScene]) { + st.activeScene = st.activeScene; + } + + updateState(st); + updateHandout(); + sendStyledMessage('Rename Act', `Renamed act ${oldName} to ${newName}.`); + break; +} + +case 'rename-scene': { + const actName = params[0]?.trim(); + const oldSceneName = params[1]?.trim(); + const newSceneName = params[2]?.trim(); + + const st = getState(); + + if (!actName || !oldSceneName || !newSceneName) { + sendStyledMessage('Rename Scene', 'You must provide act name, old scene name, and new scene name.'); + break; + } + + const act = st.acts?.[actName]; + if (!act) { + sendStyledMessage('Rename Scene', `Act "${actName}" not found.`); + break; + } + + const scenes = act.scenes; + if (!scenes?.[oldSceneName]) { + sendStyledMessage('Rename Scene', `Scene "${oldSceneName}" not found in act "${actName}".`); + break; + } + + if (scenes[newSceneName]) { + sendStyledMessage('Rename Scene', `Scene "${newSceneName}" already exists in act "${actName}".`); + break; + } + + // Rename scene key + scenes[newSceneName] = scenes[oldSceneName]; + delete scenes[oldSceneName]; + + // Update scenesOrder array + if (act.scenesOrder && Array.isArray(act.scenesOrder)) { + const idx = act.scenesOrder.indexOf(oldSceneName); + if (idx !== -1) { + act.scenesOrder[idx] = newSceneName; + } + } + + if (st.activeScene === oldSceneName) { + st.activeScene = newSceneName; + } + + updateState(st); + updateHandout(); + sendStyledMessage('Rename Scene', `Renamed scene ${oldSceneName} to ${newSceneName} in act ${actName}.`); + break; +} + + + + +case 'delete-scene-confirm': { + const [choice, actName, sceneName] = val.split('|'); + if (choice === 'Cancel') { + sendStyledMessage('Director', 'Delete scene cancelled.'); + break; + } + if (choice === 'Delete') { + deleteScene(actName, sceneName); + updateHandout(); + } + break; +} + +case 'delete-act-confirm': { + const [choice, actName] = val.split('|'); + if (choice === 'Cancel') { + sendStyledMessage('Director', 'Delete act cancelled.'); + break; + } + if (choice === 'Delete') { + deleteAct(actName); + updateHandout(); + } + break; +} + + + +case 'move-act-up': { + moveActUp(val); + break; +} +case 'move-act-down': { + moveActDown(val); + break; +} +case 'move-scene-up': { + const [actName, sceneName] = val.split('|'); + if (actName && sceneName) moveSceneUp(actName, sceneName); + break; +} +case 'move-scene-down': { + const [actName, sceneName] = val.split('|'); + if (actName && sceneName) moveSceneDown(actName, sceneName); + break; +} + +case 'move-image-up': { + moveImageUp(val); + break; +} +case 'move-image-down': { + moveImageDown(val); + break; +} + + + + + + +case 'stop-audio': { + const tracks = findObjs({ _type: 'jukeboxtrack' }); + tracks.forEach(track => { + if (track.get('playing')) { + track.set('playing', false); + } + }); + updateHandout(); + break; +} + + +case 'new-image': { + const css = getCSS(); + const activeAct = getActiveAct(); + const sceneName = getActiveScene(); + if (!activeAct || !sceneName) { + sendStyledMessage('Director', 'No active scene. Create at least one act and one scene first.'); + break; + } + + if (!msg.selected || msg.selected.length !== 1) { + const urlButton = `Enter URL`; + sendStyledMessage('Director', `Please select exactly one graphic to add as an image.

Alternatively, you may press this button and enter a valid URL.
Image URLs must be of graphics in a user library.
${urlButton}
`); + break; + } + + const token = getObj('graphic', msg.selected[0]._id); + if (!token) { + sendStyledMessage('Director', 'Selected graphic not found.'); + break; + } + + const url = token.get('imgsrc').replace(/(thumb|med|original)/, 'max'); + + // Warn if marketplace asset + if (url.includes('/marketplace/')) { + sendStyledMessage('Marketplace Image Detected', 'Image URL references a marketplace asset and will be skipped when setting the scene.'); + log(`Image URL includes a marketplace asset and will be skipped when setting the scene.`); + } + + const width = token.get('width'); + const height = token.get('height'); + const ratio = height / width; + const id = generateRowID(); + const title = token.get('name')?.trim() || 'New Image'; + + st.acts[activeAct].scenes[sceneName].images.push({ + id, + url, + ratio, + type: 'highlight', + title + }); + + updateHandout(); + break; +} + +case 'add-image-url': { + const activeAct = getActiveAct(); + const sceneName = getActiveScene(); + if (!activeAct || !sceneName) { + sendStyledMessage('Director', 'No active scene. Create at least one act and one scene first.'); + break; + } + + if (!val || !/^https:\/\/(s3\.amazonaws\.com|files\.d20\.io)\/.*\.(png|jpe?g|gif|webm)(\?.*)?$/.test(val)) { + sendStyledMessage('Director', 'Invalid image URL. Must be a Roll20-hosted image (e.g., uploaded to your user library, a character bio, or a forum post).'); + break; + } + + // Warn if using marketplace asset + if (val.includes('/marketplace/')) { + sendStyledMessage('Marketplace Image Detected', 'Image URL references a marketplace asset and will be skipped when setting the scene.'); + log(`Image URL includes a marketplace asset and will be skipped when setting the scene.`); + } + + const id = generateRowID(); + + st.acts[activeAct].scenes[sceneName].images.push({ + id, + url: val, + ratio: 1, // fallback; user may want to edit later + type: 'highlight', + title: 'New Image' + }); + + updateHandout(); + break; +} + + + + case 'set-image-title': { + const [id, ...titleParts] = params; + const newTitle = titleParts.join('|').trim() || val; + if (!id) { + sendStyledMessage('Director', 'Please provide an image ID and a new title.'); + break; + } + const activeAct = getActiveAct(); + const activeScene = getActiveScene(); + if (!activeAct || !activeScene) { + sendStyledMessage('Director', 'No active scene.'); + break; + } + const img = st.acts[activeAct].scenes[activeScene].images.find(i => i.id === id); + if (!img) { + sendStyledMessage('Director', `Image ID "${id}" not found.`); + break; + } + img.title = newTitle; + updateHandout(); + break; + } + + case 'set-backdrop': { + const id = val; + const activeAct = getActiveAct(); + const activeScene = getActiveScene(); + if (!activeAct || !activeScene) { + sendStyledMessage('Director', 'No active scene.'); + break; + } + if (!st.acts[activeAct].scenes[activeScene].images.find(i => i.id === id)) { + sendStyledMessage('Director', `Image ID "${id}" not found.`); + break; + } + st.acts[activeAct].scenes[activeScene].backdropId = id; + updateHandout(); + break; + } + + case 'highlight': { + const id = val; + const activeAct = getActiveAct(); + const activeScene = getActiveScene(); + if (!activeAct || !activeScene) { + sendStyledMessage('Director', 'No active scene.'); + break; + } + const img = st.acts[activeAct].scenes[activeScene].images.find(i => i.id === id); + if (!img) { + sendStyledMessage('Director', `Image ID "${id}" not found.`); + break; + } + img.type = 'highlight'; + updateHandout(); + break; + } + + case 'delete-image': { + const [id, ...confirmParts] = val.split(' '); + const confirmation = confirmParts.join(' ').trim(); + if (confirmation !== 'Delete') { + sendStyledMessage('Director', 'Delete cancelled.'); + break; + } + const activeAct = getActiveAct(); + const activeScene = getActiveScene(); + if (!activeAct || !activeScene) { + sendStyledMessage('Director', 'No active scene.'); + break; + } + const images = st.acts[activeAct].scenes[activeScene].images; + const index = images.findIndex(i => i.id === id); + if (index === -1) { + sendStyledMessage('Director', `Image ID "${id}" not found.`); + break; + } + images.splice(index, 1); + if (st.acts[activeAct].scenes[activeScene].backdropId === id) { + st.acts[activeAct].scenes[activeScene].backdropId = null; + } + updateHandout(); + break; + } + + case 'toggle-image': { + const id = val; + const activeAct = getActiveAct(); + const activeScene = getActiveScene(); + if (!activeAct || !activeScene) { + sendStyledMessage('Director', 'No active scene.'); + break; + } + const img = st.acts[activeAct].scenes[activeScene].images.find(i => i.id === id); + if (!img) { + sendStyledMessage('Director', `Image ID "${id}" not found.`); + break; + } + img.type = img.type === 'highlight' ? 'normal' : 'highlight'; + sendStyledMessage('Director', `Image ID "${id}" toggled to "${img.type}".`); + updateHandout(); + break; + } + + case 'recapture-image': { + const id = val; + const activeAct = getActiveAct(); + const activeScene = getActiveScene(); + if (!activeAct || !activeScene) { + sendStyledMessage('Director', 'No active scene.'); + break; + } + const imgIndex = st.acts[activeAct].scenes[activeScene].images.findIndex(i => i.id === id); + if (imgIndex === -1) { + sendStyledMessage('Director', `Image ID "${id}" not found.`); + break; + } + + if (!msg.selected || msg.selected.length !== 1) { + sendStyledMessage('Director', 'Please select exactly one token to recapture image.'); + break; + } + const token = getObj('graphic', msg.selected[0]._id); + if (!token) { + sendStyledMessage('Director', 'Selected token not found.'); + break; + } + const url = token.get('imgsrc').replace(/(thumb|med|original)/, 'max'); + const width = token.get('width'); + const height = token.get('height'); + const ratio = height / width; + const img = st.acts[activeAct].scenes[activeScene].images[imgIndex]; + img.url = url; + img.ratio = ratio; + updateHandout(); + break; + } + + case 'mode': { + if (val !== 'dark' && val !== 'light') { + sendStyledMessage('Director', 'Mode must be "dark" or "light".'); + break; + } + st.settings.mode = val; + updateHandout(); + break; + } + + case 'toggle-settings': { + st.settings.settingsExpanded = !st.settings.settingsExpanded; + updateHandout(); + break; + } + + case 'backup': { + makeBackup(); + break; + } + + case 'restore': { + if (val) { + restoreBackup(val); + } else { + sendStyledMessage(scriptName, 'No backup name specified to restore.'); + } + break; + } + + +case 'assign-image-track': { + const imageId = val; + const st = getState(); + const currentScene = st.activeScene; + if (!currentScene) break; + + let targetImage = null; + + // Find the image in the current scene + for (const act of Object.values(st.acts)) { + const scene = act.scenes?.[currentScene]; + if (scene) { + targetImage = scene.images.find(img => img.id === imageId); + if (targetImage) break; + } + } + + if (!targetImage) { + sendStyledMessage('Assign Track', `Image with ID ${imageId} not found.`); + break; + } + + // Find the currently playing track + const playingTrack = findObjs({ type: 'jukeboxtrack' }).find(track => track.get('playing')); + if (!playingTrack) { + sendStyledMessage('Assign Track', 'No track is currently playing.'); + break; + } + + // Assign the track ID to the image + targetImage.trackId = playingTrack.id; + //sendStyledMessage('Assign Track', `Assigned track ${playingTrack.get('title')} to image ${targetImage.title || 'Untitled'}.`); + + updateHandout(); + break; +} + +case 'remove-image-track': { + const imgId = String(params[0]).trim(); + const st = getState(); + + let targetImage = null; + let scene = null; + + // Search for the image across all scenes + for (const act of Object.values(st.acts)) { + for (const s of Object.values(act.scenes || {})) { + const img = s.images?.find(i => String(i.id).trim() === imgId); + if (img) { + targetImage = img; + scene = s; + break; + } + } + if (targetImage) break; + } + + if (!targetImage || !scene) { + sendStyledMessage('Remove Track', `Image with ID "${imgId}" not found.`); + break; + } + + delete targetImage.trackId; + updateState(st); + updateHandout(); + sendStyledMessage('Remove Track', 'Track removed from image.'); + break; +} + + +case 'toggle-mute': { + const st = getState(); + st.settings.muteBackdropAudio = !st.settings.muteBackdropAudio; + updateState(st); + updateHandout(); +sendStyledMessage(scriptName, `Backdrop audio is now ${st.settings.muteBackdropAudio ? 'muted' : 'unmuted'}.`); + break; +} + + +case 'toggle-image-track': { + const imageId = val; + const st = getState(); + const currentScene = st.activeScene; + if (!currentScene) break; + + let targetImage = null; + + // Find the image in the current scene + for (const act of Object.values(st.acts)) { + const scene = act.scenes?.[currentScene]; + if (scene) { + targetImage = scene.images.find(img => img.id === imageId); + if (targetImage) break; + } + } + + if (!targetImage || !targetImage.trackId) { + sendStyledMessage('Toggle Track', 'No track assigned to this image.'); + break; + } + + const track = getObj('jukeboxtrack', targetImage.trackId); + if (!track) { + sendStyledMessage('Toggle Track', 'Assigned track not found in jukebox.'); + break; + } + + const currentlyPlaying = track.get('playing'); + track.set('playing', !currentlyPlaying); + + // ✅ Force UI to refresh so icon style updates + updateHandout(); + + break; +} + + +case 'repair-orders': { + repairAllOrders(); + sendStyledMessage('Director', 'All scene and act orders have been repaired.'); + const tracks = findObjs({ _type: 'jukeboxtrack' }); + tracks.forEach(track => { + if (track.get('playing')) { + track.set('playing', false); + } + }); + + break; +} + + +case 'toggle-help': { + const st = getState(); + st.helpMode = !st.helpMode; + updateState(st); + updateHandout(); + break; +} + + +case 'make-help-handout': { + const css = getCSS(); + const helpHtml = renderHelpHtml(css); + + const handoutName = 'Director Help'; + let handout = findObjs({ type: 'handout', name: handoutName })[0]; + + if (handout) { + handout.set({ notes: helpHtml }); + } else { + handout = createObj('handout', { + name: handoutName, + notes: helpHtml + }); + } + + sendStyledMessage('Director', `[Open the Help Handout](http://journal.roll20.net/handout/${handout.id})`); + break; +} + + + + case 'refresh': { + updateHandout(); + sendStyledMessage('Director', 'Director interface refreshed.'); + break; + } + + default: { + sendStyledMessage('Director', `Unknown command: ${cmd}`); + } + } + } +}); + +// Initial update on script load +on('ready', () => { + getState(); + + updateHandout(); +}); + +}); +{ try { throw new Error(''); } catch (e) { API_Meta.Director.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Director.offset); } } diff --git a/Director/Director.js b/Director/Director.js index 7ee3a0610..8fd6eb230 100644 --- a/Director/Director.js +++ b/Director/Director.js @@ -15,12 +15,13 @@ API_Meta.Director = { on('ready', () => { - const version = '1.0.1'; //version number set here + const version = '1.0.2'; //version number set here log('-=> Director v' + version + ' is loaded. Command !director creates control handout and provides link. Click that to open.'); //Changelog: //1.0.0 Debut script //1.0.1 Grid Mode, fallback image system for Marketplace images +//1.0.2 Expanded Grid Mode up to 9x9 and tighterned spacing, added Star system // == Director Script == @@ -80,12 +81,15 @@ const cssDark = { actContainer: 'color:#ddd; background:#555; border:1px solid #444; border-radius:4px; width:120px; min-height:18px; margin-top:0px; padding:4px 25px 4px 6px; font-size:12px; display:inline-block; position:relative;', // === Items and Item Buttons === - itemButton: 'color:#eee!important; background:#555; border:1px solid #666; width:98%; margin:3px 0 0 0; padding:3px 6px 3px 0px; font-size:12px; border-radius:4px; display:inline-block; text-align:left; text-decoration:none;', + itemButton: 'color:#eee!important; background:#555; border:1px solid #666; width:calc(100%-6px); margin:3px 0 0 0; padding:3px 6px 3px 0px; font-size:12px; border-radius:4px; display:block; text-align:left; text-decoration:none;', itemBadge: 'color:#111; background:#999; border-radius:3px; width:20px; max-height:20px; margin:0px 2px; padding-top:2px; font-size:12px; font-weight:bold; text-align:center; display:inline-block; cursor:pointer; text-decoration:none;', itemAddBadge: 'color:#111; background:indianred; border-radius:3px; width:20px; max-height:20px; margin:0px 2px; padding-top:2px; font-size:12px; font-weight:bold; text-align:center; display:inline-block; cursor:pointer; text-decoration:none;', editIcon: 'color:#eee; font-size:12px; margin:0px 4px; display:inline-block; float:right; cursor:pointer;', utilityEditButton: 'color: #333; background: crimson; padding: 0 2px; border-radius: 3px; min-width:12px; margin-left:2px; margin-bottom:-19px; padding-top:2px; font-family: Pictos; font-size: 12px; text-align:center; float:right; position:relative; top:-22px; right:4px;', utilityEditButtonOverlay: 'color: #333; background: crimson; padding: 0 4px; border-radius: 3px; min-width: 12px; margin-left: 4px; padding-top: 2px; font-family: Pictos; font-size: 20px; text-align: center; cursor: pointer; float: none; position: relative; top: 0; right: 0; margin-bottom: 0; z-index: 11;', +starred: `color: gold; font-weight: bold; font-size: 18px; text-decoration: none; user-select: none; cursor: pointer; position: absolute; top: 3px; right: 8px; margin: 0;`, +unstarred: `color: gray; font-weight: normal; font-size: 18px; text-decoration: none; user-select: none; cursor: pointer; position: absolute; top: 3px; right: 8px; margin: 0;`, + // === Message UI === messageContainer: 'color:#ccc; background-color:#222; border:1px solid #444; border-radius:5px; padding:10px; font-family: Nunito, Arial, sans-serif; position:relative; top:-15px; left:-5px;', @@ -147,6 +151,9 @@ const lightModeOverrides = { itemButton: { color: '#111', background: '#ddd', border: '1px solid #666' }, editIcon: { color: '#666' }, +starred: { color: 'darkorange' }, +unstarred: { color: '#bbb' }, + messageContainer: { color: '#222', background: '#f9f9f9', border: '1px solid #ccc' }, messageTitle: { color: '#222' }, @@ -429,12 +436,19 @@ const repairAllOrders = () => { st.actsOrder = Object.keys(st.acts || {}); log(`Repaired actsOrder: ${JSON.stringify(st.actsOrder)}`); - // Repair scenesOrder for each act + // Repair scenesOrder for each act and initialize starredAssets for each scene for (const actName of st.actsOrder) { const act = st.acts[actName]; if (act && act.scenes) { act.scenesOrder = Object.keys(act.scenes); log(`Repaired scenesOrder for act "${actName}": ${JSON.stringify(act.scenesOrder)}`); + + for (const sceneName of act.scenesOrder) { + const scene = act.scenes[sceneName]; + if (!scene.starredAssets) { + scene.starredAssets = {}; + } + } } } @@ -787,80 +801,80 @@ if (backdropImg.trackId && !st.settings.muteBackdropAudio) { let tokenLeft = pageWidth + 70; let currentColumnMaxWidth = 70; - const placeNextToken = () => { - if (!charItems.length) return; - - const btn = charItems.shift(); - - const handlePlacement = (props, name) => { - const tokenWidth = props.width || 70; - const tokenHeight = props.height || 70; - - // Wrap to next column if vertical space exceeded - if (tokenTop + tokenHeight > pageHeight - 50) { - tokenTop = 105; - tokenLeft += currentColumnMaxWidth + 70; - currentColumnMaxWidth = tokenWidth; - } else { - currentColumnMaxWidth = Math.max(currentColumnMaxWidth, tokenWidth); - } +const placeNextToken = () => { + if (!charItems.length) { + updateHandout(); + highlightStarredTokens(currentScene, pageId); // ✅ Now runs after tokens are placed + return; + } - props.left = tokenLeft + tokenWidth / 2; - props.top = tokenTop + tokenHeight / 2; + const btn = charItems.shift(); - const token = createObj('graphic', props); - tagGraphicAsDirector(token); + const handlePlacement = (props, name) => { + const tokenWidth = props.width || 70; + const tokenHeight = props.height || 70; - tokenTop += tokenHeight + 20; - }; + if (tokenTop + tokenHeight > pageHeight - 50) { + tokenTop = 105; + tokenLeft += currentColumnMaxWidth + 70; + currentColumnMaxWidth = tokenWidth; + } else { + currentColumnMaxWidth = Math.max(currentColumnMaxWidth, tokenWidth); + } - if (btn.type === 'variant') { - try { - const props = { ...btn.tokenProps }; + props.left = tokenLeft + tokenWidth / 2; + props.top = tokenTop + tokenHeight / 2; -/* - if (!props || !props.imgsrc) { - log(`[Director] Invalid tokenProps for variant "${btn.name}". Skipping.`); - return placeNextToken(); - } - */ - if (!props || !props.imgsrc) { - props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc))||FALLBACK_IMG; -} + const token = createObj('graphic', props); + tagGraphicAsDirector(token); - props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); - props._pageid = pageId; - props.layer = 'objects'; + tokenTop += tokenHeight + 20; + }; - handlePlacement(props, btn.name); - } catch (e) { - log(`[Director] Error placing variant "${btn.name}": ${e.message}`); + if (btn.type === 'variant') { + try { + const props = { ...btn.tokenProps }; + if (!props || !props.imgsrc) { + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)) || FALLBACK_IMG; } - return setTimeout(placeNextToken, 0); + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); + props._pageid = pageId; + props.layer = 'objects'; + + handlePlacement(props, btn.name); + } catch (e) { + log(`[Director] Error placing variant "${btn.name}": ${e.message}`); } + return setTimeout(placeNextToken, 0); + } - const char = getObj('character', btn.refId); - if (!char) return placeNextToken(); + const char = getObj('character', btn.refId); + if (!char) return placeNextToken(); - char.get('_defaulttoken', (blob) => { - try { - const props = JSON.parse(blob); - if (!props || !props.imgsrc) return placeNextToken(); + char.get('_defaulttoken', (blob) => { + try { + const props = JSON.parse(blob); + if (!props || !props.imgsrc) return placeNextToken(); - props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); - props._pageid = pageId; - props.layer = 'objects'; + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); + props._pageid = pageId; + props.layer = 'objects'; - handlePlacement(props, char.get('name')); - } catch (e) { - log(`[Director] Error parsing default token for ${char.get('name')}: ${e}`); - } - setTimeout(placeNextToken, 0); - }); - }; + handlePlacement(props, char.get('name')); + //log (char.get('name') + ":props = " + JSON.stringify(props)); + + } catch (e) { + log(`[Director] Error parsing default token for ${char.get('name')}: ${e}`); + } + setTimeout(placeNextToken, 0); + }); +}; placeNextToken(); updateHandout(); + + + highlightStarredTokens(currentScene, pageId); }; @@ -900,6 +914,14 @@ const wipeScene = (sceneName, playerid) => { if (p.get('stroke') === '#84d162') p.remove(); }); + +// Remove all gold stroke paths on GM layer for this page +const goldPaths = findObjs({ _type: 'pathv2', _pageid: pageId, layer: 'gmlayer' }) + .filter(p => p.get('stroke') === 'gold'); +goldPaths.forEach(p => p.remove()); + + + disableDynamicLighting(pageId); @@ -931,8 +953,7 @@ const handleSetGrid = (playerid) => { if (!page) return sendStyledMessage('Set Grid', 'No valid player page found, including fallback.'); -enableDynamicLighting(pageId); - + enableDynamicLighting(pageId); let act, scene; for (const a of Object.values(st.acts)) { @@ -951,23 +972,53 @@ enableDynamicLighting(pageId); if (!validImages.length) return sendStyledMessage('Set Grid', 'No image assets found for grid placement.'); - if (validImages.length > 6) - return sendStyledMessage('Set Grid', 'Grid layout only supports up to 6 images.'); + const layouts = [ + [1,1], [1,2], [2,1], + [1,3], [3,1], + [2,2], + [2,3], [3,2], + [3,3], + [4,2], [2,4] + ]; + + const maxImages = 9; // maximum images supported + + const imgCount = Math.min(validImages.length, maxImages); + + if (validImages.length > maxImages) { + sendStyledMessage('Set Grid', `Too many images (${validImages.length}) to fit grid; only the first ${maxImages} will be placed.`); + } const pageWidth = page.get('width') * 70; const pageHeight = page.get('height') * 70 - 105; - const imgCount = validImages.length; - const gridCells = (imgCount <= 2) ? 2 : (imgCount <= 4) ? 4 : 6; - const rows = (gridCells === 2) ? 1 : 2; - const cols = (gridCells === 2) ? 2 : (gridCells === 4) ? 2 : 3; + const isWide = pageWidth > pageHeight; + const isTall = pageHeight > pageWidth; + + // Fix: Always include 2x2 layout if exactly 4 images to get perfect fit + let filteredLayouts = layouts.filter(([c, r]) => { + if (imgCount === 4 && c === 2 && r === 2) { + return true; + } + if (isWide) return c > r; + if (isTall) return r > c; + return true; + }); + + filteredLayouts.sort((a,b) => (a[0]*a[1]) - (b[0]*b[1])); + let chosenLayout = filteredLayouts.find(([c, r]) => c*r >= imgCount); + + if (!chosenLayout) chosenLayout = [3,3]; + + const [cols, rows] = chosenLayout; + const gridCells = cols * rows; const cellWidth = Math.floor(pageWidth / cols); const cellHeight = Math.floor(pageHeight / rows); - const margin = 70; - const maxImgWidth = cellWidth - 2 * margin; - const maxImgHeight = cellHeight - 2 * margin; + const gridImageMargin = 35; + const maxImgWidth = cellWidth - 2 * gridImageMargin; + const maxImgHeight = cellHeight - 2 * gridImageMargin; if (maxImgWidth <= 0 || maxImgHeight <= 0) { return sendStyledMessage('Set Grid', 'Grid layout failed: Page is too small to fit all images with required spacing. Resize the page or reduce the number of images and try again.'); @@ -983,9 +1034,7 @@ enableDynamicLighting(pageId); } } - validImages.forEach((img, i) => { - if (i >= positions.length) return; - + validImages.slice(0, imgCount).forEach((img, i) => { const pos = positions[i]; const dims = getScaledToFit(img.ratio || 1, maxImgWidth, maxImgHeight); const cleanUrl = cleanImg(img.url); @@ -1029,7 +1078,7 @@ enableDynamicLighting(pageId); st.lastSetScene = currentScene; - // --- Character Tokens --- + // --- Character Tokens (unchanged) --- const charItems = (st.items?.buttons || []).filter(btn => btn.scene === currentScene && ( @@ -1051,7 +1100,6 @@ enableDynamicLighting(pageId); const tokenWidth = props.width || 70; const tokenHeight = props.height || 70; - // Wrap to next column if vertical space exceeded if (tokenTop + tokenHeight > pageHeight - 50) { tokenTop = 105; tokenLeft += currentColumnMaxWidth + 70; @@ -1072,17 +1120,9 @@ enableDynamicLighting(pageId); if (btn.type === 'variant') { try { const props = { ...btn.tokenProps }; - -/* if (!props || !props.imgsrc) { - log(`[Director] Invalid tokenProps for variant "${btn.name}". Skipping.`); - return placeNextToken(); + props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)) || FALLBACK_IMG; } - */ - if (!props || !props.imgsrc) { - props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc))||FALLBACK_IMG; -} - props.imgsrc = getSafeImgsrc(cleanImg(props.imgsrc)); props._pageid = pageId; props.layer = 'objects'; @@ -1107,6 +1147,10 @@ enableDynamicLighting(pageId); props.layer = 'objects'; handlePlacement(props, char.get('name')); + + log(char.get('name')+": props = " + props); + + } catch (e) { log(`[Director] Error parsing default token for ${char.get('name')}: ${e}`); } @@ -1293,7 +1337,11 @@ const renderHelpHtml = (css) => `

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) => `
  • Backdrop image (Map Layer)
  • Highlight images (Object Layer, left-aligned off page edge)
  • Character and variant tokens (Object Layer, right-aligned off page edge)
  • +
  • Any tokens that are starred for the current backdrop image are highlighted
  • Starts assigned track (if set)
  • 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 ` -
    - - ${getBadge(btn.type, css)} ${labelText} ${editControls} +
    + + ${getBadge(btn.type, css)} ${labelText} + ${editControls} + ${starHTML}
    `; }).join(''); @@ -2789,6 +3039,13 @@ case 'set-backdrop': { updateState(st); updateHandout(); + + + // Refresh GM-only starred-token highlights for this page/scene + // (creates gmlayer rects around tokens starred for the newly assigned backdrop) + highlightStarredTokens(currentScene, pid); + + break; } @@ -2835,6 +3092,79 @@ case 'set-grid': break; } + + case 'toggle-star-filter': { + const st = getState(); + st.items = st.items || {}; + st.items.starMode = !st.items.starMode; + updateState(st); + updateHandout(); + break; +} + +case 'toggle-star': { + const assetId = val; // from "!director --toggle-star|assetId" + if (!assetId) break; + + const st = getState(); + const currentSceneName = st.activeScene; + if (!currentSceneName) { + sendStyledMessage('Director', 'No active scene set.'); + break; + } + + // Find scene object + let sceneObj = null; + for (const act of Object.values(st.acts || {})) { + if (act.scenes?.[currentSceneName]) { + sceneObj = act.scenes[currentSceneName]; + break; + } + } + if (!sceneObj) { + sendStyledMessage('Director', 'Active scene not found.'); + break; + } + + const backdropId = sceneObj.backdropId; + if (!backdropId) { + sendStyledMessage('Director', 'No backdrop image assigned. Assign a backdrop image before starring assets.'); + break; + } + + sceneObj.starredAssets = sceneObj.starredAssets || {}; + sceneObj.starredAssets[backdropId] = sceneObj.starredAssets[backdropId] || []; + + const starredList = sceneObj.starredAssets[backdropId]; + const index = starredList.indexOf(assetId); + if (index === -1) { + starredList.push(assetId); + //sendStyledMessage('Director', `Starred asset ${assetId} for backdrop.`); + } else { + starredList.splice(index, 1); + //sendStyledMessage('Director', `Unstarred asset ${assetId} for backdrop.`); + } + + updateState(st); + updateHandout(); + + // Add this call to refresh GM-layer highlights immediately + const pid = Campaign().get('playerpageid'); + highlightStarredTokens(currentSceneName, pid); + + break; +} + + + + + + + + + + + case 'toggle-act': { const actName = decodeURIComponent(val); st.actsExpanded = st.actsExpanded || {}; @@ -3516,57 +3846,6 @@ case 'make-help-handout': { -case 'checkwall': { - sendStyledMessage('Director', `Open Checkwall.`); - - const pageId = getPageForPlayer(playerid); - const page = getObj('page', pageId); - - if (!page) { - sendStyledMessage('Director', `❌ No valid page found.`); - return; - } - - sendStyledMessage('Director', `✅ Page: ${page.get('name')}`); - - // Static triangle with known good coordinates - const wall = createObj('pathv2', { - _pageid: pageId, - shape: 'pol', - points: JSON.stringify([ - [0, 0], - [0, 70], - [70, 0], - [0, 0] - ]), - fill: 'transparent', - stroke: '#FF0000', - stroke_width: 5, - x: 140, // Placed slightly off origin so it's visible - y: 140, - layer: 'walls', - barrierType: 'wall' - }); - - if (!wall) { - log("❌ PathV2 wall creation failed"); - sendStyledMessage('Director', `❌ Path creation failed.`); - } else { - log("✅ PathV2 wall created successfully"); - sendStyledMessage('Director', `✅ Wall created at (140, 140).`); - } - - sendStyledMessage('Director', `Close Checkwall.`); - break; -} - - - - - - - - case 'refresh': { updateHandout(); sendStyledMessage('Director', 'Director interface refreshed.'); diff --git a/Director/script.json b/Director/script.json index 9eae9da0f..74a358f01 100644 --- a/Director/script.json +++ b/Director/script.json @@ -1,7 +1,7 @@ { "name": "Director", "script": "Director.js", - "version": "1.0.1", + "version": "1.0.2", "description": "# Director\n\n**Director** is a script for supporting \"theater of the mind\"-style play in Roll20. It provides an interface for managing scenes, images, audio, and game assets — all organized within a persistent handout.\n\n---\n\n## Interface Overview\n\nThe interface appears in a Roll20 handout and consists of four main sections:\n\n- **Acts & Scenes** — scene navigation and management \n- **Images** — backdrops, highlights, and associated tracks \n- **Items** — characters, variants, macros, and token-linked objects \n- **Utility Controls** — edit mode, help toggle, settings, backup tools \n\n---\n\n## Acts & Scenes\n\n### Act Controls\n\nActs group together related scenes. Use the `+ Add Act` button to create one.\n\nIn **Edit Mode**, you can:\n- Rename or delete acts\n- Move acts up or down\n\n### Scene Controls\n\nEach scene represents a distinct moment or location. Click a scene name to set it active — this controls what images and items are shown.\n\nIn **Edit Mode**, you can:\n- Rename or delete scenes\n- Move scenes up or down (scenes moved beyond an act will join the next expanded act)\n\n---\n\n## Images\n\n### Backdrop vs. Highlight\n\n- **Backdrop**: Main background image placed on the Map Layer \n- **Highlights**: Visuals layered above the backdrop on the Object Layer (for focus or emphasis) \n\nWhen a scene is set:\n- The backdrop is placed on the map\n- All highlights appear just off the left edge of the page\n\nHighlights can be dragged manually, or previewed using `Shift+Z`.\n\n### Adding Images\n\n1. Drag a graphic to the tabletop (hold `Alt`/`Option` to preserve aspect ratio) \n2. Select the graphic and click `+ Add Image` in the interface\n\n### Image Controls\n\n- **Title**: Click to rename \n- **Bottom-right icons**: \n - `expanding arrows icon` = Set as Backdrop \n - `overlapping rectangles icon` = Set as Highlight \n - `music note icon` = Assign currently playing track. This track will auto play whenever the image becomes a backdrop image.\n- In **Edit Mode**: \n - Move an image up or down. Although the backdrop image always goes to the top\n - Recapture\n - Delete\n\n### Mute Button\n\nToggles automatic track playback. When red, backdrops will no longer auto-start audio.\n\n---\n\n## Items (Characters, Variants, Tracks, Macros, Tables)\n\nItems define what gets placed or triggered when a scene is set. Items are scoped per scene.\n\n### Adding Items\n\nClick a badge to add a new item:\n- `H` = Handout \n- `C` = Character \n- `V` = Variant \n- `T` = Track \n- `M` = Macro \n- `R` = Rollable Table \n\n### Item Behavior\n\n| Badge | Type | Behavior |\n|-------|------------|--------------------------------------------------------------------------|\n| `H` | Handout | Opens the handout |\n| `C` | Character | Opens the sheet if assigned; otherwise prompts for assignment |\n| `V` | Variant | Places token on scene set (does not open a sheet) |\n| `T` | Track | Toggles playback; assigns current track if none assigned |\n| `M` | Macro | Runs macro if assigned; otherwise prompts to choose an existing macro |\n| `R` | Table | Rolls the assigned table; result whispered to GM |\n\n> Variants are token snapshots that share a character sheet. Use them to represent alternate versions of a character or avoid issues with default token behavior.\n\n### Edit Mode Controls\n\nWhile in **Edit Mode**, each item displays:\n- `pencil icon` — Reassign\n- `trash icon` — Delete\n\nYou can also click the `magnifying glass icon` icon to filter items by type.\n\n---\n\n## Header Buttons\n\n### Set Scene as:\n\n**Scene** places the following on the tabletop:\n\n- Backdrop image (Map Layer) \n- Highlight images (Object Layer, left-aligned off page edge) \n- Character and variant tokens (Object Layer, right-aligned off page edge) \n- Starts assigned track (if set)\n\n**Grid** places the following on the tabletop:\n\n- Up to six images, arranged in a grid (Map Layer) \n- Surrounds each image with dynamic lighting barriers and turns on dynamic lighting with Daylight Mode \n- Top strip of the page is reserved (for holding player tokens) \n- Character and variant tokens (Object Layer, right-aligned off page edge)\n\n> _Only works if the current page name contains:_ `scene`, `stage`, `theater`, or `theatre`\n\n### Wipe the Scene\n\n`Wipe the Scene` removes all images and stops all audio.\n\n> Only works on valid stage pages.\n\n### Edit Mode\n\nToggles editing. When enabled:\n- Rename, delete, and move controls appear for acts, scenes, and images\n- Items display grouped by type with assign/delete icons\n\n### JB+\n\nIf Jukebox Plus is installed, this button appears and provides a chat link to launch its controls.\n\n### Help\n\nDisplays this help interface. While in help mode, this changes to \"Exit Help\".\n\n### Make Help Handout\n\nCreates a handout containing the help documentation. Use it to reference instructions while working in the main interface.\n\n---\n\n## Helpful Macros\n\nThese commands can be used in the chat or bound to macro/action buttons:\n\n`!director --set-scene`\n\n`!director --wipe-scene`\n\n`!director --new-act|Act I`\n\n`!director --new-scene|Act I|Opening Scene`\n\n`!director --capture-image`", "authors": "Keith Curtis", "roll20userid": "162065", "dependencies": [], @@ -14,5 +14,5 @@ "rollabletable": "read" }, "conflicts": [], - "previousversions": ["1.0.0","1.0.1"] + "previousversions": ["1.0.0","1.0.1","1.0.2"] } diff --git a/ResourceTracker/0.5.6/ResourceTracker.js b/ResourceTracker/0.5.6/ResourceTracker.js new file mode 100644 index 000000000..973dc47e0 --- /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, + 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 = 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)}
    ${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..3c904a23f --- /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