From ccd13bd67efd49a7d1df26f346dca2d4c3aa5324 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 22:32:49 -0400 Subject: [PATCH 01/41] Gaslight: initiative tracking sync across linked tokens - Auto-add linked copies when a token is added to turn order - Auto-remove linked copies when a token is removed - Initiative value sync: changes propagate to all linked tokens - Direction-aware auto-skip: non-master children are skipped on forward/backward - Sort detection: reorders groups with master on top after sort - _pageid included on pushed entries for cross-page visibility --- Gaslight/Gaslight.js | 234 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 470984965..0cbc0262e 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2864,6 +2864,239 @@ var Gaslight = Gaslight || (() => { } }; + // ========================================================================= + // Initiative Tracking — sync turn order across linked tokens + // ========================================================================= + + var _suppressTurnSync = false; + + /** + * Get all linked token IDs for a given token across all active groups. + * Returns { linkedIds: [...], isMaster: bool, masterPageId: string|null } + */ + const getLinkedInfo = (tokenId) => { + var s = state[SCRIPT_NAME]; + var linkedIds = []; + var isMaster = false; + var masterPageId = null; + Object.values(s.activeGroups).forEach(function(active) { + var ids = active.linkedTokens[tokenId]; + if (ids && ids.length > 0) { + linkedIds = linkedIds.concat(ids); + masterPageId = active.masterPageId; + var obj = getObj('graphic', tokenId); + if (obj && obj.get('_pageid') === active.masterPageId) isMaster = true; + } + }); + return { linkedIds: linkedIds, isMaster: isMaster, masterPageId: masterPageId }; + }; + + /** + * Determine if a token ID is a master token (on master page) in any active group. + */ + const isMasterToken = (tokenId) => { + var s = state[SCRIPT_NAME]; + var obj = getObj('graphic', tokenId); + if (!obj) return false; + var pageId = obj.get('_pageid'); + return Object.values(s.activeGroups).some(function(active) { + return active.masterPageId === pageId && !!active.linkedTokens[tokenId]; + }); + }; + + /** + * Check if two token IDs are linked (in the same link group). + */ + const areLinked = (tokenIdA, tokenIdB) => { + var info = getLinkedInfo(tokenIdA); + return info.linkedIds.indexOf(tokenIdB) !== -1; + }; + + /** + * Handle turnorder changes: sync linked tokens, apply [GM] tags, auto-skip children. + */ + const onTurnOrderChanged = (obj, prev) => { + if (_suppressTurnSync) return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + + var newOrder = JSON.parse(obj.get('turnorder') || '[]'); + var oldOrder = JSON.parse(prev.turnorder || '[]'); + + // Detect additions: entries in newOrder not in oldOrder + var oldIds = new Set(oldOrder.map(function(e) { return e.id; })); + var newIds = new Set(newOrder.map(function(e) { return e.id; })); + var added = newOrder.filter(function(e) { return e.id && e.id !== '-1' && !oldIds.has(e.id); }); + var removed = oldOrder.filter(function(e) { return e.id && e.id !== '-1' && !newIds.has(e.id); }); + + var modified = false; + + var modified = false; + + // Handle additions: add linked counterparts + added.forEach(function(entry) { + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + // Add linked tokens that aren't already in the order + var existingIds = new Set(newOrder.map(function(e) { return e.id; })); + info.linkedIds.forEach(function(linkedId) { + if (existingIds.has(linkedId)) return; + var linkedObj = getObj('graphic', linkedId); + var pushEntry = { id: linkedId, pr: entry.pr }; + if (linkedObj) pushEntry._pageid = linkedObj.get('_pageid'); + newOrder.push(pushEntry); + modified = true; + }); + }); + + // Handle removals: remove linked counterparts + removed.forEach(function(entry) { + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + var before = newOrder.length; + newOrder = newOrder.filter(function(e) { + return info.linkedIds.indexOf(e.id) === -1; + }); + if (newOrder.length !== before) modified = true; + }); + + // Handle value changes: sync initiative values across linked tokens + newOrder.forEach(function(entry) { + if (!entry.id || entry.id === '-1') return; + var oldEntry = oldOrder.find(function(e) { return e.id === entry.id; }); + if (!oldEntry || oldEntry.pr === entry.pr) return; + // Value changed — propagate to linked tokens + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + info.linkedIds.forEach(function(linkedId) { + var linkedEntry = newOrder.find(function(e) { return e.id === linkedId; }); + if (linkedEntry && linkedEntry.pr !== entry.pr) { + linkedEntry.pr = entry.pr; + modified = true; + } + }); + }); + + // Reorder: group linked tokens together, master first (only on additions) + if (added.length > 0) { + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + + // Auto-skip: if turn advanced and new top is linked to the entry that just left the top + // Auto-skip: if current top is a linked non-master token, skip in the correct direction + if (newOrder.length > 1 && newOrder[0].id && newOrder[0].id !== '-1') { + var topId = newOrder[0].id; + var topInfo = getLinkedInfo(topId); + if (topInfo.linkedIds.length > 0 && !topInfo.isMaster) { + // Detect direction + var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; + var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; + + var startId = topId; + var safety = newOrder.length; + + if (isForward) { + // Rotate forward: shift to end until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.push(newOrder.shift()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + modified = true; + } else if (isBackward) { + // Rotate backward: pop from end to front until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.unshift(newOrder.pop()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + modified = true; + } else if (added.length === 0 && removed.length === 0) { + // Neither forward nor backward and no add/remove — likely a sort; reorder groups + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + } + } else if (added.length === 0 && removed.length === 0 && newOrder.length > 1) { + // No child on top, but check if sort happened (order changed without add/remove) + var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; + var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; + if (!isForward && !isBackward && JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) { + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + } + + if (modified) { + var finalJson = JSON.stringify(newOrder); + _suppressTurnSync = true; + Campaign().set('turnorder', finalJson); + _suppressTurnSync = false; + } + }; + + /** + * Reorder initiative: group linked tokens together with master first in each group. + */ + const reorderInitiative = (order) => { + var s = state[SCRIPT_NAME]; + var result = []; + var placed = new Set(); + + order.forEach(function(entry) { + if (placed.has(entry.id)) return; + if (!entry.id || entry.id === '-1') { + result.push(entry); + placed.add(entry.id); + return; + } + + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) { + // Not a linked token — just add it + result.push(entry); + placed.add(entry.id); + return; + } + + // Find all entries in this link group + var groupIds = [entry.id].concat(info.linkedIds); + var groupEntries = order.filter(function(e) { + return groupIds.indexOf(e.id) !== -1 && !placed.has(e.id); + }); + + // Sort: master first, then children + groupEntries.sort(function(a, b) { + var aIsMaster = isMasterToken(a.id) ? 0 : 1; + var bIsMaster = isMasterToken(b.id) ? 0 : 1; + return aIsMaster - bIsMaster; + }); + + groupEntries.forEach(function(e) { + result.push(e); + placed.add(e.id); + }); + }); + + return result; + }; + const registerEventHandlers = () => { on('chat:message', handleInput); on('chat:message', viewInterceptor); @@ -2876,6 +3109,7 @@ var Gaslight = Gaslight || (() => { on('change:attribute', onAttributeChanged); on('change:graphic', onGraphicPropChanged); on('change:graphic:gmnotes', onGmNotesChanged); + on('change:campaign:turnorder', onTurnOrderChanged); }; return { checkInstall, registerEventHandlers }; From 5a8ace745b8bf51e5e772d0cfe8272b98e2174be Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 22:39:33 -0400 Subject: [PATCH 02/41] Gaslight: add initiative HUD idea to TODO --- Gaslight/TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index f4b609db4..0d342b6b7 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -38,6 +38,7 @@ - [ ] Focus-ping on split ## v2 Ideas +- [ ] Initiative HUD — optional on-canvas initiative display using tokens + text on the foreground layer (one per turn entry). Keeps GM from having to parse N copies of each token in the built-in tracker bar. Auto-updates on turn order changes. - [ ] `!gaslight relay all --except ` flag - [ ] Config handout (editable in-game, live reload) - [ ] Group/page-level relay-command overrides From bf341ce150c18f4bb0d5658cf29aa4bb82009f1d Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 22:47:37 -0400 Subject: [PATCH 03/41] Gaslight: token-level gaslight_sync + sync/desync commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gaslight_sync now reads from token gmnotes (same pattern as gaslight_link) - Auto-populate from character attribute on token placement/split - !gaslight sync [show|set|add|remove|clear] — manage sync props per token - !gaslight desync [add|remove] — manage exclusions per token - getGaslightSync accepts token object or charId (backward compat) --- Gaslight/Gaslight.js | 181 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 163 insertions(+), 18 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 0cbc0262e..3e38d5e6b 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -389,23 +389,69 @@ var Gaslight = Gaslight || (() => { }; /** - * Read the gaslight_sync character attribute. + * Read gaslight_sync from a token's gmnotes. + * Returns the raw string value, or null if not present. + */ + const getSyncConfigRaw = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_sync:\s*(.+)/); + return match ? match[1].trim() : null; + }; + + const setSyncConfig = (token, value) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.match(/gaslight_sync:\s*.*/)) { + notes = notes.replace(/gaslight_sync:\s*.*/, 'gaslight_sync: ' + value); + } else { + notes = (notes ? notes + '\n' : '') + 'gaslight_sync: ' + value; + } + token.set('gmnotes', notes); + }; + + const autoPopulateSyncConfig = (token) => { + if (getSyncConfigRaw(token) !== null) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr && attr.get('current') !== undefined && attr.get('current') !== null) { + setSyncConfig(token, attr.get('current')); + } + }; + + /** + * Read the gaslight_sync config for a token (from gmnotes) or character (fallback). * Returns: - * null — attribute absent (default: sync all non-spatial) - * '' — attribute present but empty (no sync) + * null — not configured (default: sync all non-spatial) + * '' — explicitly empty (no sync) * ['prop1','prop2',...] — specific props to sync */ - const getGaslightSync = (charId) => { - if (!charId) return null; - var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; - if (!attr) return null; - var val = attr.get('current'); - if (val === undefined || val === null) return null; - val = val.trim(); - if (val === '') return ''; + const getGaslightSync = (tokenOrCharId) => { + // Accept either a token object or a character ID + var rawVal = null; + if (tokenOrCharId && typeof tokenOrCharId === 'object' && typeof tokenOrCharId.get === 'function') { + // Token object — read from gmnotes first + rawVal = getSyncConfigRaw(tokenOrCharId); + if (rawVal === null) { + // Fallback to character attribute + var charId = tokenOrCharId.get('represents'); + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr) rawVal = (attr.get('current') || '').trim(); + } + } + } else if (typeof tokenOrCharId === 'string') { + // Legacy: character ID passed directly + var attr = findObjs({ _type: 'attribute', _characterid: tokenOrCharId, name: 'gaslight_sync' })[0]; + if (!attr) return null; + rawVal = (attr.get('current') || '').trim(); + } + if (rawVal === null || rawVal === undefined) return null; + if (rawVal === '') return ''; // Parse comma-separated props, resolve groups - // Prefix with ! to exclude (e.g. "!anchor" = everything except anchor props) - var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var parts = rawVal.split(',').map(function(s) { return s.trim(); }).filter(Boolean); var includes = []; var excludes = []; parts.forEach(function(p) { @@ -686,8 +732,8 @@ var Gaslight = Gaslight || (() => { var ids = tokens.map(function(t) { return t.get('id'); }); - // Check gaslight_sync attribute - var syncProps = getGaslightSync(repCharId); + // Check gaslight_sync — read from first token's gmnotes (falls back to character attr) + var syncProps = getGaslightSync(tokens[0]); // syncProps: null = default (base spatial), '' = no sync at all, array = specific // If empty string, skip all linking for this group @@ -909,7 +955,7 @@ var Gaslight = Gaslight || (() => { // Auto-populate gaslight_link from character attributes var allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); allPageIds.forEach(function(pid) { - findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); }); // Resolve links @@ -1143,6 +1189,103 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Unlink', tokens.length + ' token(s) unlinked.'); }; + const doSync = (msg, args) => { + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + + var subCmd = (args[0] || '').toLowerCase(); + var props = args.slice(1).join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + + switch (subCmd) { + case 'show': { + var results = tokens.map(function(t) { + var raw = getSyncConfigRaw(t); + var name = t.get('name') || t.get('id'); + return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); + }); + reply(msg, 'Sync', results.join('
')); + break; + } + case 'set': { + var value = props.join(', '); + tokens.forEach(function(t) { setSyncConfig(t, value); }); + reply(msg, 'Sync', 'Set gaslight_sync to "' + value + '" on ' + tokens.length + ' token(s).'); + break; + } + case 'add': { + if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight sync add '); return; } + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + props.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Sync', 'Added [' + props.join(', ') + '] to ' + tokens.length + ' token(s).'); + break; + } + case 'remove': { + if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight sync remove '); return; } + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + existing = existing.filter(function(p) { return props.indexOf(p) === -1; }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Sync', 'Removed [' + props.join(', ') + '] from ' + tokens.length + ' token(s).'); + break; + } + case 'clear': { + tokens.forEach(function(t) { + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/gaslight_sync:\s*.*\n?/, ''); + t.set('gmnotes', notes); + }); + reply(msg, 'Sync', 'Cleared token-level gaslight_sync from ' + tokens.length + ' token(s). Will fall back to character attribute.'); + break; + } + default: + reply(msg, 'Error', 'Usage: !gaslight sync [show|set|add|remove|clear] [props]'); + } + }; + + const doDesync = (msg, args) => { + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + + var subCmd = (args[0] || '').toLowerCase(); + var props = args.slice(1).join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + + switch (subCmd) { + case 'add': { + if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync add '); return; } + var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + excludeProps.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' token(s).'); + break; + } + case 'remove': { + if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync remove '); return; } + var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + existing = existing.filter(function(p) { return excludeProps.indexOf(p) === -1; }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Desync', 'Removed exclusion of [' + props.join(', ') + '] from ' + tokens.length + ' token(s).'); + break; + } + default: + reply(msg, 'Error', 'Usage: !gaslight desync [add|remove] '); + } + }; + const doGroup = (msg, args) => { if (args.length < 2) { reply(msg, 'Error', 'Usage: !gaslight group <group> <player|GM>'); return; } const groupName = args.shift(); @@ -1350,7 +1493,7 @@ var Gaslight = Gaslight || (() => { var groupDiscovered = discoverGroup(groupName); var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); allPageIds.forEach(function(pid) { - findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); }); var allLinks = []; Object.values(groupDiscovered.players).forEach(function(pInfo) { @@ -1399,7 +1542,7 @@ var Gaslight = Gaslight || (() => { var groupDiscovered = discoverGroup(groupName); var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); allPageIds.forEach(function(pid) { - findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); }); var allLinks = []; Object.values(groupDiscovered.players).forEach(function(pInfo) { @@ -2409,6 +2552,8 @@ var Gaslight = Gaslight || (() => { case 'test': doTest(msg, args); break; case 'link': doLink(msg, args); break; case 'unlink': doUnlink(msg, args); break; + case 'sync': doSync(msg, args); break; + case 'desync': doDesync(msg, args); break; case 'group': doGroup(msg, args); break; case 'ungroup': doUngroup(msg, args); break; case 'relay': doRelay(msg, args); break; From bfc9f2dceee2ed239a5fd0ba2de56ee476ccc9f1 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 22:53:22 -0400 Subject: [PATCH 04/41] Gaslight: simplify sync/desync commands + desync all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - !gaslight sync (no args) — show current config - !gaslight sync — add to whitelist - !gaslight sync all — explicitly sync everything - !gaslight sync reset — remove token-level override - !gaslight desync — exclude props from sync - !gaslight desync all — disable all syncing (preserves link) --- Gaslight/Gaslight.js | 139 +++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 83 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 3e38d5e6b..7aa583003 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1193,97 +1193,70 @@ var Gaslight = Gaslight || (() => { var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } - var subCmd = (args[0] || '').toLowerCase(); - var props = args.slice(1).join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); - - switch (subCmd) { - case 'show': { - var results = tokens.map(function(t) { - var raw = getSyncConfigRaw(t); - var name = t.get('name') || t.get('id'); - return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); - }); - reply(msg, 'Sync', results.join('
')); - break; - } - case 'set': { - var value = props.join(', '); - tokens.forEach(function(t) { setSyncConfig(t, value); }); - reply(msg, 'Sync', 'Set gaslight_sync to "' + value + '" on ' + tokens.length + ' token(s).'); - break; - } - case 'add': { - if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight sync add '); return; } - tokens.forEach(function(t) { - var raw = getSyncConfigRaw(t) || ''; - var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); - props.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); - setSyncConfig(t, existing.join(', ')); - }); - reply(msg, 'Sync', 'Added [' + props.join(', ') + '] to ' + tokens.length + ' token(s).'); - break; - } - case 'remove': { - if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight sync remove '); return; } - tokens.forEach(function(t) { - var raw = getSyncConfigRaw(t) || ''; - var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); - existing = existing.filter(function(p) { return props.indexOf(p) === -1; }); - setSyncConfig(t, existing.join(', ')); - }); - reply(msg, 'Sync', 'Removed [' + props.join(', ') + '] from ' + tokens.length + ' token(s).'); - break; - } - case 'clear': { - tokens.forEach(function(t) { - var notes = t.get('gmnotes') || ''; - try { notes = decodeURIComponent(notes); } catch(e) {} - notes = notes.replace(/gaslight_sync:\s*.*\n?/, ''); - t.set('gmnotes', notes); - }); - reply(msg, 'Sync', 'Cleared token-level gaslight_sync from ' + tokens.length + ' token(s). Will fall back to character attribute.'); - break; - } - default: - reply(msg, 'Error', 'Usage: !gaslight sync [show|set|add|remove|clear] [props]'); + // No args — show current config + if (args.length === 0) { + var results = tokens.map(function(t) { + var raw = getSyncConfigRaw(t); + var name = t.get('name') || t.get('id'); + return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); + }); + reply(msg, 'Sync', results.join('
')); + return; } + + var subCmd = args[0].toLowerCase(); + + // Reset — remove token-level override + if (subCmd === 'reset') { + tokens.forEach(function(t) { + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/gaslight_sync:\s*.*\n?/, ''); + t.set('gmnotes', notes); + }); + reply(msg, 'Sync', 'Reset token-level gaslight_sync on ' + tokens.length + ' token(s). Falling back to character attribute.'); + return; + } + + // "all" — explicitly sync everything + if (subCmd === 'all') { + tokens.forEach(function(t) { setSyncConfig(t, 'all'); }); + reply(msg, 'Sync', 'Set to sync all properties on ' + tokens.length + ' token(s).'); + return; + } + + // Otherwise, treat all args as props to add to sync list + var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + props.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Sync', 'Added [' + props.join(', ') + '] to sync on ' + tokens.length + ' token(s).'); }; const doDesync = (msg, args) => { var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync '); return; } - var subCmd = (args[0] || '').toLowerCase(); - var props = args.slice(1).join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); - - switch (subCmd) { - case 'add': { - if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync add '); return; } - var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); - tokens.forEach(function(t) { - var raw = getSyncConfigRaw(t) || ''; - var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); - excludeProps.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); - setSyncConfig(t, existing.join(', ')); - }); - reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' token(s).'); - break; - } - case 'remove': { - if (props.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync remove '); return; } - var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); - tokens.forEach(function(t) { - var raw = getSyncConfigRaw(t) || ''; - var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); - existing = existing.filter(function(p) { return excludeProps.indexOf(p) === -1; }); - setSyncConfig(t, existing.join(', ')); - }); - reply(msg, 'Desync', 'Removed exclusion of [' + props.join(', ') + '] from ' + tokens.length + ' token(s).'); - break; - } - default: - reply(msg, 'Error', 'Usage: !gaslight desync [add|remove] '); + // "all" — disable all syncing (set empty string = no sync) + if (args[0].toLowerCase() === 'all') { + tokens.forEach(function(t) { setSyncConfig(t, ''); }); + reply(msg, 'Desync', 'Disabled all syncing on ' + tokens.length + ' token(s). Use !gaslight sync reset to restore.'); + return; } + + var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + excludeProps.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' token(s).'); }; const doGroup = (msg, args) => { From 49b1146e0a893ea7a74481b337ccf3732bad0a04 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 22:55:40 -0400 Subject: [PATCH 05/41] Gaslight: remove character fallback from getGaslightSync, reset re-copies from character - getGaslightSync reads only from token gmnotes (no runtime fallback) - Character attribute is purely a template (auto-populated on token place/split) - !gaslight sync reset now re-copies character attribute value into token gmnotes - Consistent with gaslight_link pattern --- Gaslight/Gaslight.js | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7aa583003..146320a46 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -422,29 +422,19 @@ var Gaslight = Gaslight || (() => { }; /** - * Read the gaslight_sync config for a token (from gmnotes) or character (fallback). + * Read the gaslight_sync config for a token (from gmnotes only). * Returns: * null — not configured (default: sync all non-spatial) * '' — explicitly empty (no sync) * ['prop1','prop2',...] — specific props to sync */ - const getGaslightSync = (tokenOrCharId) => { - // Accept either a token object or a character ID + const getGaslightSync = (token) => { var rawVal = null; - if (tokenOrCharId && typeof tokenOrCharId === 'object' && typeof tokenOrCharId.get === 'function') { - // Token object — read from gmnotes first - rawVal = getSyncConfigRaw(tokenOrCharId); - if (rawVal === null) { - // Fallback to character attribute - var charId = tokenOrCharId.get('represents'); - if (charId) { - var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; - if (attr) rawVal = (attr.get('current') || '').trim(); - } - } - } else if (typeof tokenOrCharId === 'string') { - // Legacy: character ID passed directly - var attr = findObjs({ _type: 'attribute', _characterid: tokenOrCharId, name: 'gaslight_sync' })[0]; + if (token && typeof token === 'object' && typeof token.get === 'function') { + rawVal = getSyncConfigRaw(token); + } else if (typeof token === 'string') { + // Legacy: character ID — read attribute directly (used internally only) + var attr = findObjs({ _type: 'attribute', _characterid: token, name: 'gaslight_sync' })[0]; if (!attr) return null; rawVal = (attr.get('current') || '').trim(); } @@ -1206,15 +1196,20 @@ var Gaslight = Gaslight || (() => { var subCmd = args[0].toLowerCase(); - // Reset — remove token-level override + // Reset — re-copy from character attribute if (subCmd === 'reset') { tokens.forEach(function(t) { - var notes = t.get('gmnotes') || ''; - try { notes = decodeURIComponent(notes); } catch(e) {} - notes = notes.replace(/gaslight_sync:\s*.*\n?/, ''); - t.set('gmnotes', notes); + var charId = t.get('represents'); + var charVal = ''; + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr && attr.get('current') !== undefined && attr.get('current') !== null) { + charVal = attr.get('current').trim(); + } + } + setSyncConfig(t, charVal); }); - reply(msg, 'Sync', 'Reset token-level gaslight_sync on ' + tokens.length + ' token(s). Falling back to character attribute.'); + reply(msg, 'Sync', 'Reset gaslight_sync to character default on ' + tokens.length + ' token(s).'); return; } From 47896057c22a55237e01e6c670636f4b24de4cba Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 23:07:09 -0400 Subject: [PATCH 06/41] Gaslight: view off/none disables relay, view master enables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - s.view = null → relay disabled (GM-only changes) - s.view = 'master' → relay to all players (default on split) - s.view = → relay to specific player - !gaslight view off/none → disable relay - !gaslight view master/gm/all → enable relay to all - Split sets view to 'master' automatically --- Gaslight/Gaslight.js | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 146320a46..1a374643f 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -995,6 +995,9 @@ var Gaslight = Gaslight || (() => { // Establish links establishLinks(groupName, groupInfo, allLinks); + // Enable relay by default on split + s.view = 'master'; + var summary = 'Group "' + groupName + '" activated. ' + Object.keys(groupInfo.players).length + ' player(s), ' + allLinks.length + ' link(s) established.'; @@ -1283,18 +1286,26 @@ var Gaslight = Gaslight || (() => { var s = state[SCRIPT_NAME]; if (args.length === 0) { // Show current view - var current = s.view ? Object.values(s.activeGroups).reduce(function(name, g) { - if (name) return name; - var entry = g.playerPages[s.view]; - return entry ? entry.name : null; - }, null) || s.view : 'master'; + var current; + if (s.view === null) current = 'off (relay disabled)'; + else if (s.view === 'master') current = 'master (relay to all)'; + else { + current = Object.values(s.activeGroups).reduce(function(name, g) { + if (name) return name; + var entry = g.playerPages[s.view]; + return entry ? entry.name : null; + }, null) || s.view; + } reply(msg, 'View', 'Current view: ' + current + ''); return; } var arg = args.join(' ').replace(/^["']|["']$/g, ''); - if (arg.toLowerCase() === 'master' || arg.toLowerCase() === 'gm') { + if (arg.toLowerCase() === 'master' || arg.toLowerCase() === 'gm' || arg.toLowerCase() === 'all') { + s.view = 'master'; + reply(msg, 'View', 'Switched to master view. Commands relay to all player pages.'); + } else if (arg.toLowerCase() === 'none' || arg.toLowerCase() === 'off') { s.view = null; - reply(msg, 'View', 'Switched to master view. Commands target master tokens; use !gaslight relay for player targeting.'); + reply(msg, 'View', 'Relay disabled. Commands stay on master only.'); } else { // Resolve player var resolved = resolvePlayer(msg, arg, CMD + ' view'); @@ -2955,7 +2966,10 @@ var Gaslight = Gaslight || (() => { // Universal relay: master-page refs, no player-page refs if (masterTokens.length > 0 && !hasPlayerPageRef) { var viewPlayerId = s.view; - var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); + if (!viewPlayerId) return; // view off — no relay + var targetPlayerIds = viewPlayerId === 'master' + ? Object.keys(activeEntry[1].playerPages) + : [viewPlayerId]; executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); return; } From f2587a7a3d67a4bb4db439efd559171bfbd2e5e7 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 23:11:29 -0400 Subject: [PATCH 07/41] Gaslight: bump to v2.1.0, state migration for view semantics - SCRIPT_VERSION -> 2.1.0 - Migration: v2.0.0 state with view=null (meant relay-all) -> view='master' - New installs default view to 'master' --- Gaslight/Gaslight.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 1a374643f..3efb82fc3 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -35,7 +35,7 @@ var Gaslight = Gaslight || (() => { 'use strict'; const SCRIPT_NAME = 'Gaslight'; - const SCRIPT_VERSION = '2.0.0'; + const SCRIPT_VERSION = '2.1.0'; const CMD = '!gaslight'; const CONFIG_HEADER = '---GASLIGHT---'; const LINK_KEY = 'gaslight_link'; @@ -80,11 +80,15 @@ var Gaslight = Gaslight || (() => { state[SCRIPT_NAME] = { activeGroups: {}, config: { autoCommit: false, relayCommands: [] }, - view: null + view: 'master' }; } - if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" + if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { + if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; + state[SCRIPT_NAME].version = SCRIPT_VERSION; + } }; // ========================================================================= From f6c447800c48a98f143bfba44247fffb93547fa7 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 23:17:36 -0400 Subject: [PATCH 08/41] Gaslight: expand TODO with HUD system, diff, batch ops --- Gaslight/TODO.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 0d342b6b7..8226f4805 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -38,7 +38,13 @@ - [ ] Focus-ping on split ## v2 Ideas -- [ ] Initiative HUD — optional on-canvas initiative display using tokens + text on the foreground layer (one per turn entry). Keeps GM from having to parse N copies of each token in the built-in tracker bar. Auto-updates on turn order changes. +- [ ] GM HUD system — persistent on-canvas indicators, each toggled via `!gaslight hud [on|off]`: + - `initiative` — on-canvas turn order display (tokens + text on foreground layer, one per entry). Deduplicates linked copies so GM sees a clean list. + - `view` — big text showing current relay state (OFF / ALL / player name) + - `links` — ascii-art boxes around linked tokens with sync/desync annotations + - `group` — active group indicator +- [ ] Diff command (`!gaslight diff `) — show what's different between master and a player's page +- [ ] Batch operations — `--all` flag on sync/desync to affect all linked tokens on the page - [ ] `!gaslight relay all --except ` flag - [ ] Config handout (editable in-game, live reload) - [ ] Group/page-level relay-command overrides From e017a5fbe67d092a8d3edc8c8117cf313b04e00c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 30 Jun 2026 23:26:24 -0400 Subject: [PATCH 09/41] Gaslight: bumped version and updated timestamp in code header --- Gaslight/Gaslight.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 3efb82fc3..0b9c00cc1 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,6 +1,6 @@ // ============================================================================= -// Gaslight v2.0.0 -// Last Updated: 2026-06-25 +// Gaslight v2.1.0 +// Last Updated: 2026-06-30 // Author: Kenan Millet // // Description: From 702794bc45aecfaacc4ea439f92ac826179b27bb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Wed, 1 Jul 2026 17:09:55 -0400 Subject: [PATCH 10/41] Gaslight v2.1.0 release prep: versioned copy, script.json, README, help text - Created 2.1.0 versioned copy - script.json: version 2.1.0, added 2.0.0 to previousversions, new commands in description - README: added sync/desync commands, view modes, initiative tracking sections - HELP_TEXT: full command reference with view modes + initiative note --- Gaslight/2.1.0/Gaslight.js | 3265 ++++++++++++++++++++++++++++++++++++ Gaslight/Gaslight.js | 14 +- Gaslight/README.md | 41 +- Gaslight/script.json | 6 +- 4 files changed, 3319 insertions(+), 7 deletions(-) create mode 100644 Gaslight/2.1.0/Gaslight.js diff --git a/Gaslight/2.1.0/Gaslight.js b/Gaslight/2.1.0/Gaslight.js new file mode 100644 index 000000000..47ebdd60d --- /dev/null +++ b/Gaslight/2.1.0/Gaslight.js @@ -0,0 +1,3265 @@ +// ============================================================================= +// Gaslight v2.1.0 +// Last Updated: 2026-06-30 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor and Mirror. Each player can see +// different things while token movement stays consistent across all copies. +// Commands auto-relay to all player pages transparently. +// +// Dependencies: Anchor, Mirror, SelectManager, RollCapture (optional) +// +// Commands: +// !gaslight setup Quick-configure from duplicates +// !gaslight split [--force] Activate a prepared group +// !gaslight merge [group] Tear down links, return players +// !gaslight test Dry-run linking resolution +// !gaslight link [|new] [ids...] Set gaslight_link on tokens +// !gaslight unlink [ids...|--group ] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight ungroup Remove page from group +// !gaslight stage [players...] Propagate tokens to player pages +// !gaslight view [player|master] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight eval [--dry-run] [--all|] Evaluate script pins +// !gaslight status Show current state +// !gaslight --help Command reference +// ============================================================================= + +/* global on, sendChat, getObj, findObjs, createObj, Campaign, playerIsGM, log, state, generateUUID */ + +var Gaslight = Gaslight || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Gaslight'; + const SCRIPT_VERSION = '2.1.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + const GLS_TAG = '[GLS]'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const stripGlsTag = (name) => { + return (name || '').replace(/^\[GLS\]\s*/i, '').trim(); + }; + + var relaying = new Set(); + var scripting = false; + + const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body); + }; + + const genId = () => { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + }; + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + activeGroups: {}, + config: { autoCommit: false, relayCommands: [] }, + view: 'master' + }; + } + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" + if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { + if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; + state[SCRIPT_NAME].version = SCRIPT_VERSION; + } + }; + + // ========================================================================= + // Config Storage — GM layer text objects + // ========================================================================= + + const getConfigsOnPage = (pageId) => { + const texts = findObjs({ _type: 'text', _pageid: pageId, layer: 'gmlayer' }); + const configs = []; + texts.forEach(t => { + const content = t.get('text') || ''; + if (!content.startsWith(CONFIG_HEADER)) return; + const data = parseConfig(content); + if (data) configs.push({ obj: t, data: data }); + }); + return configs; + }; + + const getGroupConfigOnPage = (pageId, groupName) => { + return getConfigsOnPage(pageId).find(c => c.data.group === groupName); + }; + + const parseConfig = (text) => { + const lines = text.split('\n').filter(l => l.trim() && l.trim() !== CONFIG_HEADER); + const data = {}; + lines.forEach(line => { + const idx = line.indexOf(':'); + if (idx === -1) return; + data[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, ''); + }); + return data.group ? data : null; + }; + + const serializeConfig = (data) => { + let text = CONFIG_HEADER + '\n'; + Object.entries(data).forEach(([key, val]) => { + if (val !== undefined && val !== '') text += key + ': ' + val + '\n'; + }); + return text.trim(); + }; + + const setConfigOnPage = (pageId, groupName, data) => { + const existing = getGroupConfigOnPage(pageId, groupName); + const fullData = Object.assign({ group: groupName }, data); + const text = serializeConfig(fullData); + if (existing) { + existing.obj.set('text', text); + } else { + createObj('text', { + pageid: pageId, + layer: 'gmlayer', + text: text, + left: 70, + top: 70, + font_size: 26, + font_family: 'Arial', + color: '#FFA500' + }); + } + }; + + // ========================================================================= + // Group Discovery + // ========================================================================= + + const discoverGroup = (groupName) => { + const pages = findObjs({ _type: 'page' }); + const result = { master: null, players: {} }; // players keyed by playerid → { pageId, name } + pages.forEach(page => { + const cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg) return; + if (cfg.data.player === 'GM') result.master = page.get('_id'); + else if (cfg.data.playerid) { + result.players[cfg.data.playerid] = { pageId: page.get('_id'), name: cfg.data.player }; + } + }); + return result; + }; + + // ========================================================================= + // Page Resolution + // ========================================================================= + + const resolvePageId = (msg, args) => { + // Check for --page argument + const pageIdx = args.indexOf('--page'); + if (pageIdx !== -1 && args[pageIdx + 1]) { + const pageName = args.splice(pageIdx, 2)[1]; + const page = findObjs({ _type: 'page', name: pageName })[0]; + if (page) return page.get('_id'); + } + // Fall back to selected token's page + if (msg.selected && msg.selected.length > 0) { + const obj = getObj(msg.selected[0]._type, msg.selected[0]._id); + if (obj) return obj.get('_pageid'); + } + // Last resort: player page + return Campaign().get('playerpageid'); + }; + + // ========================================================================= + // Party Detection + // ========================================================================= + + const getPartyTokens = (msg, masterPageId) => { + if (msg.selected && msg.selected.length > 0) { + return msg.selected.map(s => getObj(s._type, s._id)).filter(Boolean); + } + const characters = findObjs({ _type: 'character' }); + const partyChars = characters.filter(c => { + const tags = c.get('tags') || ''; + return tags.toLowerCase().includes('party'); + }); + if (partyChars.length > 0) { + const tokens = []; + partyChars.forEach(c => { + const t = findObjs({ _type: 'graphic', represents: c.get('_id'), _pageid: masterPageId, _subtype: 'token' }); + tokens.push.apply(tokens, t); + }); + return tokens.length > 0 ? tokens : null; + } + return null; + }; + + // ========================================================================= + // Player Resolution + // ========================================================================= + + const GM_ALIASES = ['gm', 'master']; + + /** + * Resolve a player arg to { id, name } or null. + * If ambiguous, whispers disambiguation buttons and returns 'ambiguous'. + * If GM alias, returns { id: 'GM', name: 'GM' }. + */ + const resolvePlayer = (msg, playerArg, cmdPrefix) => { + if (GM_ALIASES.indexOf(playerArg.toLowerCase()) !== -1) { + return { id: 'GM', name: 'GM' }; + } + + // Check if it's a player ID directly (starts with -) + if (playerArg.startsWith('-')) { + var byId = getObj('player', playerArg); + if (byId) return { id: byId.get('_id'), name: byId.get('_displayname') }; + reply(msg, 'Error', 'No player found with ID: ' + playerArg); + return null; + } + + // Search by display name + var players = findObjs({ _type: 'player' }); + var matches = players.filter(function(p) { + return p.get('_displayname').toLowerCase() === playerArg.toLowerCase(); + }); + + // Deduplicate by player ID (Roll20 can return duplicate player objects) + var uniqueById = {}; + matches.forEach(function(p) { uniqueById[p.get('_id')] = p; }); + matches = Object.values(uniqueById); + + if (matches.length === 1) { + return { id: matches[0].get('_id'), name: matches[0].get('_displayname') }; + } + if (matches.length === 0) { + reply(msg, 'Error', 'No player found named "' + playerArg + '".'); + return null; + } + + // Ambiguous — show disambiguation buttons + var out = 'Multiple players named "' + playerArg + '":
'; + matches.forEach(function(p) { + var chars = findObjs({ _type: 'character' }).filter(function(c) { + return (c.get('controlledby') || '').indexOf(p.get('_id')) !== -1; + }); + var charNames = chars.map(function(c) { return c.get('name'); }).join(', ') || 'no characters'; + out += '[' + p.get('_displayname') + ' (' + charNames + ')](' + cmdPrefix + ' ' + p.get('_id') + ')
'; + }); + reply(msg, 'Disambiguate', out); + return 'ambiguous'; + }; + + /** + * Find a player by name or ID (no disambiguation, used internally). + */ + const findPlayerByNameOrId = (nameOrId) => { + if (nameOrId === 'GM') return null; + if (nameOrId.startsWith('-')) return getObj('player', nameOrId); + var players = findObjs({ _type: 'player' }); + return players.find(function(p) { return p.get('_displayname').toLowerCase() === nameOrId.toLowerCase(); }); + }; + + // ========================================================================= + // Token GM Notes — gaslight_link + // ========================================================================= + + const getLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_link:\s*(\S+)/); + return match ? match[1].trim() : null; + }; + + const setLinkId = (token, linkId) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.match(/gaslight_link:\s*.+/)) { + notes = notes.replace(/gaslight_link:\s*.+/, LINK_KEY + ': ' + linkId); + } else { + notes = (notes ? notes + '\n' : '') + LINK_KEY + ': ' + linkId; + } + token.set('gmnotes', notes); + }; + + const removeLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/\n?gaslight_link:\s*.+/, '').trim(); + token.set('gmnotes', notes); + }; + + /** + * Auto-populate gaslight_link from character attribute if token doesn't already have one. + */ + /** + * Find a matching token on another page by gaslight_link, represents+name, or represents alone. + */ + const findMatchingToken = (sourceToken, targetPageId) => { + // By gaslight_link + var linkId = getLinkId(sourceToken); + if (linkId) { + var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + var match = targets.find(function(t) { return getLinkId(t) === linkId; }); + if (match) return match; + } + // By represents + name + var charId = sourceToken.get('represents'); + if (charId) { + var name = sourceToken.get('name'); + var byName = findObjs({ _type: 'graphic', _pageid: targetPageId, represents: charId, _subtype: 'token' }); + if (name) byName = byName.filter(function(t) { return t.get('name') === name; }); + if (byName.length === 1) return byName[0]; + } + return null; + }; + + /** + * Stage a single token to target pages using 3-step logic. + * Returns number of clones created. + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPageIds.forEach(function(pageId) { + var targets = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPageIds; + } + + var cloned = 0; + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + var newToken = createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + if (newToken) setLinkId(newToken, getLinkId(token)); + cloned++; + }); + return cloned; + }; + + const autoPopulateLinkId = (token) => { + if (getLinkId(token)) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr && attr.get('current')) { + setLinkId(token, attr.get('current')); + } + }; + + /** + * Read gaslight_sync from a token's gmnotes. + * Returns the raw string value, or null if not present. + */ + const getSyncConfigRaw = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_sync:\s*(.+)/); + return match ? match[1].trim() : null; + }; + + const setSyncConfig = (token, value) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.match(/gaslight_sync:\s*.*/)) { + notes = notes.replace(/gaslight_sync:\s*.*/, 'gaslight_sync: ' + value); + } else { + notes = (notes ? notes + '\n' : '') + 'gaslight_sync: ' + value; + } + token.set('gmnotes', notes); + }; + + const autoPopulateSyncConfig = (token) => { + if (getSyncConfigRaw(token) !== null) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr && attr.get('current') !== undefined && attr.get('current') !== null) { + setSyncConfig(token, attr.get('current')); + } + }; + + /** + * Read the gaslight_sync config for a token (from gmnotes only). + * Returns: + * null — not configured (default: sync all non-spatial) + * '' — explicitly empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (token) => { + var rawVal = null; + if (token && typeof token === 'object' && typeof token.get === 'function') { + rawVal = getSyncConfigRaw(token); + } else if (typeof token === 'string') { + // Legacy: character ID — read attribute directly (used internally only) + var attr = findObjs({ _type: 'attribute', _characterid: token, name: 'gaslight_sync' })[0]; + if (!attr) return null; + rawVal = (attr.get('current') || '').trim(); + } + if (rawVal === null || rawVal === undefined) return null; + if (rawVal === '') return ''; + // Parse comma-separated props, resolve groups + var parts = rawVal.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var includes = []; + var excludes = []; + parts.forEach(function(p) { + var isExclude = p.startsWith('!'); + var name = isExclude ? p.slice(1) : p; + var expanded; + if (name === 'base' || name === 'anchor') { + expanded = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[name]) { + expanded = Mirror.PROP_GROUPS[name]; + } else { + expanded = [name]; + } + if (isExclude) excludes = excludes.concat(expanded); + else includes = includes.concat(expanded); + }); + // If only excludes specified, start from all known props and subtract + var resolved; + if (includes.length === 0 && excludes.length > 0) { + var allProps = typeof Mirror !== 'undefined' ? Mirror.getKnownProps() : + ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'statusmarkers', 'tint_color', 'name', 'light_radius', 'light_dimradius', 'baseOpacity', 'currentSide']; + resolved = allProps.filter(function(p) { return excludes.indexOf(p) === -1; }); + } else { + resolved = includes.filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return resolved.filter(function(p, i) { return resolved.indexOf(p) === i; }); // dedupe + }; + + // ========================================================================= + // Token Linking Resolution + // ========================================================================= + + /** + * Resolve links from sourcePageId to targetPageId. + * Returns array of { source, target, step } objects. + * Unmatched sources returned as { source, target: null, step: 'unlinked' }. + */ + const resolveLinks = (sourcePageId, targetPageId) => { + const sourceTokens = findObjs({ _type: 'graphic', _pageid: sourcePageId, _subtype: 'token' }); + const targetTokens = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + const results = []; + const matchedTargets = new Set(); + + // Step 1: gaslight_link in GM notes + sourceTokens.forEach(src => { + const linkId = getLinkId(src); + if (!linkId) return; + const match = targetTokens.find(t => !matchedTargets.has(t.get('id')) && getLinkId(t) === linkId); + if (match) { + results.push({ source: src, target: match, step: 1 }); + matchedTargets.add(match.get('id')); + } + }); + + const unmatchedSources = sourceTokens.filter(s => + !results.some(r => r.source.get('id') === s.get('id')) + ); + + // Step 2: represents + name + const step2Sources = unmatchedSources.filter(s => s.get('represents')); + step2Sources.forEach(src => { + const charId = src.get('represents'); + const name = src.get('name'); + // Check uniqueness on source page + const samePairOnSource = sourceTokens.filter(t => + t.get('represents') === charId && t.get('name') === name && + !results.some(r => r.source.get('id') === t.get('id')) + ); + if (samePairOnSource.length !== 1) return; // ambiguous on source page + + const candidates = targetTokens.filter(t => + !matchedTargets.has(t.get('id')) && + t.get('represents') === charId && t.get('name') === name + ); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 2 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 3: represents + fingerprint + const unmatchedAfter2 = unmatchedSources.filter(s => + s.get('represents') && !results.some(r => r.source.get('id') === s.get('id')) + ); + const FINGERPRINT_PROPS = ['represents', 'left', 'top', 'width', 'height', 'rotation', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max']; + + unmatchedAfter2.forEach(src => { + const srcFP = FINGERPRINT_PROPS.map(p => String(src.get(p))); + const candidates = targetTokens.filter(t => { + if (matchedTargets.has(t.get('id'))) return false; + const tFP = FINGERPRINT_PROPS.map(p => String(t.get(p))); + return srcFP.every((v, i) => v === tFP[i]); + }); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 3 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 4: unlinked — only master-page represents tokens + unmatchedSources.forEach(src => { + if (!results.some(r => r.source.get('id') === src.get('id'))) { + if (src.get('represents')) { + results.push({ source: src, target: null, step: 4 }); + } + } + }); + + return results; + }; + + /** + * Check for warning conditions across all pages in a group. + * Returns array of { message, severity } where severity is 'info'|'warning'|'error'. + */ + const checkWarnings = (groupInfo) => { + const warnings = []; + const allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + + // Collect all gaslight_link IDs and their page locations + const linkIdPages = {}; // linkId → Set of pageIds + const linkIdDupes = {}; // pageId → Set of linkIds that appear more than once + allPageIds.forEach(function(pid) { + var tokens = findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }); + var seenOnPage = {}; + tokens.forEach(function(t) { + var lid = getLinkId(t); + if (!lid) return; + if (!linkIdPages[lid]) linkIdPages[lid] = new Set(); + linkIdPages[lid].add(pid); + // Check for duplicates on same page + if (seenOnPage[lid]) { + if (!linkIdDupes[pid]) linkIdDupes[pid] = new Set(); + linkIdDupes[pid].add(lid); + } + seenOnPage[lid] = true; + }); + }); + + // Error: duplicate gaslight_link on same page + Object.entries(linkIdDupes).forEach(function(entry) { + var pid = entry[0], dupes = entry[1]; + var page = getObj('page', pid); + var pageName = page ? page.get('name') : pid; + dupes.forEach(function(lid) { + warnings.push({ message: 'Duplicate gaslight_link "' + lid + '" on page "' + pageName + '"', severity: 'error' }); + }); + }); + + // Info/Warning: gaslight_link missing from pages + Object.entries(linkIdPages).forEach(function(entry) { + var lid = entry[0], pages = entry[1]; + if (pages.size === 1) { + warnings.push({ message: 'gaslight_link "' + lid + '" exists on only 1 page (likely mistake)', severity: 'warning' }); + } else if (pages.size < allPageIds.length) { + warnings.push({ message: 'gaslight_link "' + lid + '" missing from some pages', severity: 'info' }); + } + }); + + return warnings; + }; + + const formatWarnings = (warnings) => { + if (warnings.length === 0) return ''; + var out = '
Warnings:
'; + warnings.forEach(function(w) { + var icon = w.severity === 'error' ? '🔴' : w.severity === 'warning' ? '🟡' : 'ℹ️'; + out += icon + ' ' + w.message + '
'; + }); + return out; + }; + + // ========================================================================= + // Anchor Integration + // ========================================================================= + + const countControllersInGroup = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return 0; + const character = getObj('character', charId); + if (!character) return 0; + const controlledBy = character.get('controlledby') || ''; + if (controlledBy === 'all') return Object.keys(groupInfo.players).length; + const controllerIds = controlledBy.split(',').filter(Boolean); + const groupPlayerIds = new Set(Object.keys(groupInfo.players)); + return controllerIds.filter(id => groupPlayerIds.has(id)).length; + }; + + const getControllingPlayerName = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return null; + const character = getObj('character', charId); + if (!character) return null; + const controlledBy = character.get('controlledby') || ''; + if (!controlledBy) return null; + if (controlledBy === 'all') { + // All players control it — return first group player as representative + var firstPlayer = Object.keys(groupInfo.players)[0]; + return firstPlayer || null; + } + const controllerIds = controlledBy.split(',').filter(Boolean); + for (var i = 0; i < controllerIds.length; i++) { + if (groupInfo.players[controllerIds[i]]) return controllerIds[i]; + } + return null; + }; + + const stripSight = (token) => { + token.set({ has_bright_light_vision: false, has_night_vision: false, light_hassight: false }); + }; + + /** + * Set up Anchor links based on resolved token pairs. + * Also writes gaslight_link IDs to token GM notes for any pair matched + * via steps 2-3, so re-split/restart will catch them via step 1. + */ + const establishLinks = (groupName, groupInfo, allLinks) => { + const s = state[SCRIPT_NAME]; + if (!s.activeGroups[groupName]) { + s.activeGroups[groupName] = { + masterPageId: groupInfo.master, + playerPages: groupInfo.players, + linkedTokens: {} + }; + } + const active = s.activeGroups[groupName]; + + if (typeof Anchor === 'undefined') { + log(SCRIPT_NAME + ': ERROR \u2014 Anchor not loaded. Cannot establish links.'); + return; + } + + // Group all link results by gaslight_link ID + var linkGroups = {}; // linkId -> { id: tokenObj } + allLinks.forEach(function(link) { + if (!link.target) return; + var src = link.source; + var tgt = link.target; + + // Ensure both have a gaslight_link ID + var existingId = getLinkId(src) || getLinkId(tgt); + var linkId = existingId || genId(); + if (!getLinkId(src)) setLinkId(src, linkId); + if (!getLinkId(tgt)) setLinkId(tgt, linkId); + + if (!linkGroups[linkId]) linkGroups[linkId] = {}; + linkGroups[linkId][src.get('id')] = src; + linkGroups[linkId][tgt.get('id')] = tgt; + }); + + // For each link group, determine anchoring strategy + Object.values(linkGroups).forEach(function(tokenMap) { + var tokens = Object.values(tokenMap); + if (tokens.length < 2) return; + + // Find all controlling player IDs in the group for this token + var controllerIds = []; + // Check the character's controlledby — use first token's character as representative + var repCharId = null; + for (var i = 0; i < tokens.length; i++) { + if (tokens[i].get('represents')) { repCharId = tokens[i].get('represents'); break; } + } + if (repCharId) { + var repChar = getObj('character', repCharId); + if (repChar) { + var cb = repChar.get('controlledby') || ''; + if (cb === 'all') { + controllerIds = Object.keys(groupInfo.players); + } else { + var cbIds = cb.split(',').filter(Boolean); + controllerIds = cbIds.filter(function(id) { return !!groupInfo.players[id]; }); + } + } + } + + var ids = tokens.map(function(t) { return t.get('id'); }); + + // Check gaslight_sync — read from first token's gmnotes (falls back to character attr) + var syncProps = getGaslightSync(tokens[0]); + // syncProps: null = default (base spatial), '' = no sync at all, array = specific + + // If empty string, skip all linking for this group + if (syncProps === '') return; + + // Determine which props go to Anchor vs Mirror + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var needsAnchor = true; + var anchorComponents = null; // null = use Anchor defaults + var mirrorProps = null; // null = all non-anchor + if (Array.isArray(syncProps)) { + var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0; + // Pass specific components to Anchor if not the full default set + if (needsAnchor) { + anchorComponents = {}; + anchorRequested.forEach(function(p) { anchorComponents[p] = true; }); + } + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; + } + + // Set up Anchor links (spatial sync) + if (needsAnchor) { + if (controllerIds.length === 0) { + // NPC: master is parent, all others are children + var parent = tokens.find(function(t) { return t.get('_pageid') === groupInfo.master; }); + if (!parent) parent = tokens[0]; + tokens.forEach(function(t) { + if (t.get('id') === parent.get('id')) return; + Anchor.anchorObj(t.get('id'), parent.get('id'), anchorComponents); + }); + } else { + // Player-controlled: chain-link master + controlling players' pages + var chainPageIds = [groupInfo.master]; + controllerIds.forEach(function(pid) { + if (groupInfo.players[pid]) chainPageIds.push(groupInfo.players[pid].pageId); + }); + + var chainTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) !== -1; }); + var childTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) === -1; }); + + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds, anchorComponents); + } + + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id'), anchorComponents); + }); + } + } + } + + // Strip sight: only controlling players' pages keep sight + tokens.forEach(function(t) { + var pageId = t.get('_pageid'); + if (controllerIds.length > 0) { + // Keep sight only on pages belonging to controlling players + var isControllerPage = controllerIds.some(function(pid) { + return groupInfo.players[pid] && groupInfo.players[pid].pageId === pageId; + }); + if (!isControllerPage) stripSight(t); + } else { + // NPC: strip sight from children (not master) + if (pageId !== groupInfo.master) stripSight(t); + } + }); + + // Set up Mirror chain for non-spatial property sync + if (typeof Mirror !== 'undefined' && mirrorProps !== false) { + if (mirrorProps === null) { + // Default: sync all minus whatever Anchor is handling + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + Mirror.chainLink(ids, null, mirrorExcludes); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props + Mirror.chainLink(ids, mirrorProps); + } + } + + // Track links for merge teardown + ids.forEach(function(id) { + if (!active.linkedTokens[id]) active.linkedTokens[id] = []; + }); + ids.forEach(function(id) { + ids.forEach(function(otherId) { + if (id !== otherId) active.linkedTokens[id].push(otherId); + }); + }); + }); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + /** + * Quick setup: auto-configure a group from duplicate pages. + * !gaslight setup [--selected | player1 player2 ...] + * Expects N+1 pages with the same name (or name prefix). Assigns master + players. + */ + const doSetup = (msg, args) => { + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + var groupName = args.shift(); + + // Determine players: selected tokens + named args, fallback to party tags + var playerIds = []; + + // From selected tokens + if (msg.selected && msg.selected.length > 0) { + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (!obj) return; + var charId = obj.get('represents'); + if (!charId) return; + var character = getObj('character', charId); + if (!character) return; + var cb = character.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + // From named args + args.forEach(function(name) { + var resolved = resolvePlayer(msg, name, CMD + ' setup ' + groupName); + if (resolved && resolved !== 'ambiguous' && resolved.id !== 'GM') { + if (playerIds.indexOf(resolved.id) === -1) playerIds.push(resolved.id); + } + }); + + // Fallback: party-tagged characters (only if no selected and no args) + if (playerIds.length === 0) { + var characters = findObjs({ _type: 'character' }); + characters.forEach(function(c) { + var tags = c.get('tags') || ''; + if (!tags.toLowerCase().includes('party')) return; + var cb = c.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + if (playerIds.length === 0) { reply(msg, 'Error', 'No players found. Use --selected, provide names, or tag party characters.'); return; } + + // Find the master page (where selected token is, or current player page) + var masterPageId = resolvePageId(msg, []); + var masterPage = getObj('page', masterPageId); + if (!masterPage) { reply(msg, 'Error', 'Could not determine master page. Select a token on the master page.'); return; } + var masterName = masterPage.get('name'); + + // Find candidate pages: same base name (strip recursive "Copy of " prefixes), or already has this group's config + var allPages = findObjs({ _type: 'page' }); + var stripCopyOf = function(name) { + while (name.indexOf('Copy of ') === 0) name = name.slice(8); + return name; + }; + var candidates = allPages.filter(function(p) { + var name = stripCopyOf(p.get('name')); + if (name === masterName) return true; + // Check if page already has config for this group + var cfg = getGroupConfigOnPage(p.get('_id'), groupName); + if (cfg) return true; + return false; + }); + + // We need N+1 pages (1 master + N players) + var needed = playerIds.length + 1; + if (candidates.length < needed) { + reply(msg, 'Error', 'Found ' + candidates.length + ' page(s) named "' + masterName + '..." but need ' + needed + ' (1 master + ' + playerIds.length + ' players). Duplicate the page ' + (needed - candidates.length) + ' more time(s).'); + return; + } + + // Assign: first candidate = master, rest = players (arbitrary order) + var masterCandidate = candidates.find(function(p) { return p.get('_id') === masterPageId; }) || candidates[0]; + var playerCandidates = candidates.filter(function(p) { return p.get('_id') !== masterCandidate.get('_id'); }).slice(0, playerIds.length); + + // Rename and configure + masterCandidate.set('name', masterName + ' (master)'); + setConfigOnPage(masterCandidate.get('_id'), groupName, { player: 'GM' }); + + var assignments = []; + playerIds.forEach(function(pid, i) { + var page = playerCandidates[i]; + var player = getObj('player', pid); + var playerName = player ? player.get('_displayname') : pid; + page.set('name', masterName + ' (' + playerName + ')'); + setConfigOnPage(page.get('_id'), groupName, { player: playerName, playerid: pid }); + assignments.push(playerName + ' → ' + page.get('name')); + }); + + var out = 'Group "' + groupName + '" set up:
'; + out += 'Master: ' + masterCandidate.get('name') + '
'; + out += assignments.join('
'); + out += '

Run !gaslight test ' + groupName + ' to verify, then !gaslight split ' + groupName + ' to activate.'; + reply(msg, 'Setup', out); + }; + + const doSplit = (msg, args) => { + var force = args.indexOf('--force') !== -1; + args = args.filter(function(a) { return a !== '--force'; }); + + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight split <group> [--force]'); return; } + + const groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + if (Object.keys(groupInfo.players).length === 0) { reply(msg, 'Error', 'No player pages for group "' + groupName + '".'); return; } + + // Auto-populate gaslight_link from character attributes + var allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); + }); + + // Resolve links + var allLinks = []; + var unlinkWarnings = []; + Object.values(groupInfo.players).forEach(function(pInfo) { + var links = resolveLinks(groupInfo.master, pInfo.pageId); + links.forEach(function(l) { + if (l.target) allLinks.push(l); + else unlinkWarnings.push(l); + }); + }); + + // Check warnings + var globalWarnings = checkWarnings(groupInfo); + var hasErrors = globalWarnings.some(function(w) { return w.severity === 'error'; }); + var hasIssues = hasErrors || unlinkWarnings.length > 0 || globalWarnings.length > 0; + + // Test-first behavior (unless --force) + if (!force && hasIssues) { + var out = 'Split Test: ' + groupName + '
'; + out += allLinks.length + ' link(s) would be established.
'; + if (unlinkWarnings.length > 0) { + out += '
🟡 ' + unlinkWarnings.length + ' token(s) could not be linked: ' + + unlinkWarnings.map(function(w) { return w.source.get('name') || w.source.get('id'); }).join(', ') + '
'; + } + out += formatWarnings(globalWarnings); + if (hasErrors) { + out += '
Split blocked due to errors. Fix the issues above and try again.'; + } else { + out += '
[Proceed](' + CMD + ' split ' + groupName + ' --force)'; + } + reply(msg, 'Split', out); + return; + } + + // Assign players to pages + var psp = Campaign().get('playerspecificpages') || {}; + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + var player = getObj('player', playerId); + if (player) psp[playerId] = pInfo.pageId; + else reply(msg, 'Warning', 'Player "' + pInfo.name + '" (' + playerId + ') not found.'); + }); + Campaign().set('playerspecificpages', psp); + + // Establish links + establishLinks(groupName, groupInfo, allLinks); + + // Enable relay by default on split + s.view = 'master'; + + var summary = 'Group "' + groupName + '" activated. ' + + Object.keys(groupInfo.players).length + ' player(s), ' + + allLinks.length + ' link(s) established.'; + if (unlinkWarnings.length > 0) { + summary += '
' + unlinkWarnings.length + ' token(s) could not be linked: ' + + unlinkWarnings.map(function(w) { return w.source.get('name') || w.source.get('id'); }).join(', '); + } + summary += formatWarnings(globalWarnings); + reply(msg, 'Split', summary); + + // Build trigger map and register Fetch compProps for scripting engine + buildTriggerMap(); + registerAllCompProps(); + + // Focus-ping each player to their character token on their page + setTimeout(function() { + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + // Find a token on the player's page that they control + var playerTokens = findObjs({ _type: 'graphic', _pageid: pInfo.pageId, _subtype: 'token' }); + var charToken = playerTokens.find(function(t) { + var charId = t.get('represents'); + if (!charId) return false; + var character = getObj('character', charId); + if (!character) return false; + var cb = character.get('controlledby') || ''; + return cb === 'all' || cb.split(',').indexOf(playerId) !== -1; + }); + if (charToken) { + sendPing(charToken.get('left'), charToken.get('top'), pInfo.pageId, playerId, true, [playerId]); + } + }); + }, 500); + }; + + const doMerge = (msg, args) => { + const s = state[SCRIPT_NAME]; + const groupName = args[0]; + const groupsToMerge = groupName ? [groupName] : Object.keys(s.activeGroups); + if (groupsToMerge.length === 0) { reply(msg, 'Error', 'No active groups to merge.'); return; } + + groupsToMerge.forEach(function(gn) { + var active = s.activeGroups[gn]; + if (!active) { reply(msg, 'Warning', 'Group "' + gn + '" is not active.'); return; } + + if (typeof Anchor !== 'undefined') { + var allLinkedIds = new Set(); + Object.keys(active.linkedTokens).forEach(function(id) { allLinkedIds.add(id); }); + Object.values(active.linkedTokens).forEach(function(ids) { + ids.forEach(function(id) { allLinkedIds.add(id); }); + }); + allLinkedIds.forEach(function(id) { Anchor.removeAnchor(id); }); + } + if (typeof Mirror !== 'undefined') { + var allIds = new Set(); + Object.keys(active.linkedTokens).forEach(function(id) { allIds.add(id); }); + Object.values(active.linkedTokens).forEach(function(ids) { + ids.forEach(function(id) { allIds.add(id); }); + }); + allIds.forEach(function(id) { Mirror.unlink([id]); }); + } + + var psp = Campaign().get('playerspecificpages') || {}; + Object.keys(active.playerPages).forEach(function(playerId) { + delete psp[playerId]; + }); + Campaign().set('playerspecificpages', Object.keys(psp).length > 0 ? psp : false); + delete s.activeGroups[gn]; + }); + + reply(msg, 'Merge', 'Merged ' + groupsToMerge.length + ' group(s). Players returned to shared page.'); + }; + + const doTest = (msg, args) => { + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight test <group>'); return; } + + const groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + + var out = 'Link Test: ' + groupName + '
'; + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + out += '
Master → ' + pInfo.name + ':
'; + var links = resolveLinks(groupInfo.master, pInfo.pageId); + links.forEach(function(l) { + var srcName = l.source.get('name') || l.source.get('id'); + if (l.target) { + var tgtName = l.target.get('name') || l.target.get('id'); + out += '✓ ' + srcName + ' → ' + tgtName + ' (step ' + l.step + ')
'; + } else { + out += '🟡 ' + srcName + ' — no match found
'; + } + }); + if (links.length === 0) out += '(no linkable tokens)
'; + }); + + // Global warnings + out += formatWarnings(checkWarnings(groupInfo)); + + reply(msg, out); + }; + + const doLink = (msg, args) => { + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--ignore-selected'; }); + + // Determine link name + var linkId; + if (args.length > 0 && args[0] === 'new') { + linkId = genId(); + args.shift(); + } else if (args.length > 0 && !args[0].startsWith('-')) { + // Check if first arg is a token ID or a link name + var maybeToken = getObj('graphic', args[0]); + if (!maybeToken) { + linkId = args.shift(); + } + } + + // Gather tokens (deduplicated by ID) + var tokenMap = {}; + if (!ignoreSelected && msg.selected) { + msg.selected.forEach(function(s) { + var obj = getObj(s._type, s._id); + if (obj) tokenMap[obj.get('id')] = obj; + }); + } + args.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj) tokenMap[obj.get('id')] = obj; + }); + var tokens = Object.values(tokenMap); + + if (tokens.length === 0) { reply(msg, 'Error', 'No tokens specified.'); return; } + + // If no linkId provided, use existing from first token or generate + if (!linkId) { + linkId = getLinkId(tokens[0]) || genId(); + } + + tokens.forEach(function(t) { setLinkId(t, linkId); }); + reply(msg, 'Link', tokens.length + ' token(s) linked as "' + linkId + '".'); + }; + + const doUnlink = (msg, args) => { + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--ignore-selected'; }); + + // Unlink entire group + var groupIdx = args.indexOf('--group'); + if (groupIdx !== -1) { + var groupName = args[groupIdx + 1]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight unlink --group <group>'); return; } + var groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + var count = 0; + var allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { + if (getLinkId(t)) { removeLinkId(t); count++; } + }); + }); + reply(msg, 'Unlink', 'Removed gaslight_link from ' + count + ' token(s) across group "' + groupName + '".'); + return; + } + + var tokens = []; + if (!ignoreSelected && msg.selected) { + msg.selected.forEach(function(s) { + var obj = getObj(s._type, s._id); + if (obj) tokens.push(obj); + }); + } + args.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj) tokens.push(obj); + }); + + if (tokens.length === 0) { reply(msg, 'Error', 'No tokens specified.'); return; } + tokens.forEach(removeLinkId); + reply(msg, 'Unlink', tokens.length + ' token(s) unlinked.'); + }; + + const doSync = (msg, args) => { + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + + // No args — show current config + if (args.length === 0) { + var results = tokens.map(function(t) { + var raw = getSyncConfigRaw(t); + var name = t.get('name') || t.get('id'); + return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); + }); + reply(msg, 'Sync', results.join('
')); + return; + } + + var subCmd = args[0].toLowerCase(); + + // Reset — re-copy from character attribute + if (subCmd === 'reset') { + tokens.forEach(function(t) { + var charId = t.get('represents'); + var charVal = ''; + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr && attr.get('current') !== undefined && attr.get('current') !== null) { + charVal = attr.get('current').trim(); + } + } + setSyncConfig(t, charVal); + }); + reply(msg, 'Sync', 'Reset gaslight_sync to character default on ' + tokens.length + ' token(s).'); + return; + } + + // "all" — explicitly sync everything + if (subCmd === 'all') { + tokens.forEach(function(t) { setSyncConfig(t, 'all'); }); + reply(msg, 'Sync', 'Set to sync all properties on ' + tokens.length + ' token(s).'); + return; + } + + // Otherwise, treat all args as props to add to sync list + var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + props.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Sync', 'Added [' + props.join(', ') + '] to sync on ' + tokens.length + ' token(s).'); + }; + + const doDesync = (msg, args) => { + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync '); return; } + + // "all" — disable all syncing (set empty string = no sync) + if (args[0].toLowerCase() === 'all') { + tokens.forEach(function(t) { setSyncConfig(t, ''); }); + reply(msg, 'Desync', 'Disabled all syncing on ' + tokens.length + ' token(s). Use !gaslight sync reset to restore.'); + return; + } + + var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); + tokens.forEach(function(t) { + var raw = getSyncConfigRaw(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + excludeProps.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setSyncConfig(t, existing.join(', ')); + }); + reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' token(s).'); + }; + + const doGroup = (msg, args) => { + if (args.length < 2) { reply(msg, 'Error', 'Usage: !gaslight group <group> <player|GM>'); return; } + const groupName = args.shift(); + const playerArg = args.join(' ').replace(/^["']|["']$/g, ''); + const pageId = resolvePageId(msg, []); + const page = getObj('page', pageId); + const pageName = page ? page.get('name') : 'unknown'; + + var resolved = resolvePlayer(msg, playerArg, CMD + ' group ' + groupName); + if (!resolved || resolved === 'ambiguous') return; + + var configData; + if (resolved.id === 'GM') { + configData = { player: 'GM' }; + } else { + configData = { player: resolved.name, playerid: resolved.id }; + } + setConfigOnPage(pageId, groupName, configData); + reply(msg, 'Config', 'Page "' + pageName + '" (' + pageId + ') assigned to group "' + groupName + '" for ' + resolved.name + '.'); + }; + + /** + * Set the current view mode. + * !gaslight view [player|master] + */ + const doView = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + // Show current view + var current; + if (s.view === null) current = 'off (relay disabled)'; + else if (s.view === 'master') current = 'master (relay to all)'; + else { + current = Object.values(s.activeGroups).reduce(function(name, g) { + if (name) return name; + var entry = g.playerPages[s.view]; + return entry ? entry.name : null; + }, null) || s.view; + } + reply(msg, 'View', 'Current view: ' + current + ''); + return; + } + var arg = args.join(' ').replace(/^["']|["']$/g, ''); + if (arg.toLowerCase() === 'master' || arg.toLowerCase() === 'gm' || arg.toLowerCase() === 'all') { + s.view = 'master'; + reply(msg, 'View', 'Switched to master view. Commands relay to all player pages.'); + } else if (arg.toLowerCase() === 'none' || arg.toLowerCase() === 'off') { + s.view = null; + reply(msg, 'View', 'Relay disabled. Commands stay on master only.'); + } else { + // Resolve player + var resolved = resolvePlayer(msg, arg, CMD + ' view'); + if (!resolved || resolved === 'ambiguous') return; + s.view = resolved.id; + reply(msg, 'View', 'Switched to ' + resolved.name + ' view. Commands will auto-target their linked tokens.'); + } + }; + + /** + * Relay a command to linked tokens on specific views. + * !gaslight relay + * Views: player names, "all", "master"/"GM" + */ + const doRelay = (msg, args) => { + var s = state[SCRIPT_NAME]; + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) to relay from.'); return; } + + // Split args: views are everything before first command-prefixed arg (! # %), command is the rest + var views = []; + var commandArgs = []; + var foundCmd = false; + args.forEach(function(a) { + if (!foundCmd && (a.startsWith('!') || a.startsWith('#') || a.startsWith('%'))) foundCmd = true; + if (foundCmd) commandArgs.push(a); + else views.push(a); + }); + + if (views.length === 0) { reply(msg, 'Error', 'Specify view target(s): player names, "all", or "master". Usage: !gaslight relay <views> <!command>'); return; } + if (commandArgs.length === 0) { reply(msg, 'Error', 'No command provided. Command must start with !, #, or %'); return; } + var command = commandArgs.join(' '); + + // Resolve views + var includeMaster = false; + var targetPlayerIds = []; + views.forEach(function(v) { + var lower = v.toLowerCase().replace(/^["']|["']$/g, ''); + if (lower === 'all') { + targetPlayerIds = Object.keys(s.activeGroups).reduce(function(acc, gn) { + return acc.concat(Object.keys(s.activeGroups[gn].playerPages)); + }, []); + includeMaster = true; + } else if (lower === 'master' || lower === 'gm') { + includeMaster = true; + } else { + // Resolve as player name + Object.values(s.activeGroups).forEach(function(active) { + Object.entries(active.playerPages).forEach(function(entry) { + if (entry[1].name && entry[1].name.toLowerCase() === lower) { + if (targetPlayerIds.indexOf(entry[0]) === -1) targetPlayerIds.push(entry[0]); + } + }); + }); + } + }); + targetPlayerIds = targetPlayerIds.filter(function(id, i) { return targetPlayerIds.indexOf(id) === i; }); + + var sender = 'player|' + msg.playerid; + + var relayed = executeRelay(sender, tokens, command, targetPlayerIds, includeMaster); + reply(msg, 'Relay', 'Relayed to ' + relayed + ' token(s).'); + }; + + /** + * Relay execution: replaces token IDs in command with linked counterparts per + * target page and appends {& select} for SelectManager cross-page targeting. + */ + const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { + var s = state[SCRIPT_NAME]; + var relayed = 0; + var tokenIds = tokens.map(function(t) { return t.get('id'); }); + + if (includeMaster) { + relaying.add(relayKey(command, sender, tokenIds)); + sendChat(sender, command + ' {& select ' + tokenIds.join(', ') + '}'); + relayed += tokenIds.length; + } + + targetPlayerIds.forEach(function(playerId) { + var linkedIds = []; + var newCmd = command; + + Object.values(s.activeGroups).forEach(function(active) { + var playerPage = active.playerPages[playerId]; + if (!playerPage) return; + + tokenIds.forEach(function(tokenId) { + // Find all linked counterparts + var allLinked = (active.linkedTokens[tokenId] || []).slice(); + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) { + allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + } + }); + // Filter to ones on this player's page + var onPage = allLinked.filter(function(id, i, arr) { + if (arr.indexOf(id) !== i || id === tokenId) return false; + var obj = getObj('graphic', id); + return obj && obj.get('_pageid') === playerPage.pageId; + }); + onPage.forEach(function(id) { + newCmd = newCmd.split(tokenId).join(id); + if (linkedIds.indexOf(id) === -1) linkedIds.push(id); + }); + }); + }); + + if (linkedIds.length > 0) { + relaying.add(relayKey(newCmd, sender, linkedIds)); + sendChat(sender, newCmd + ' {& select ' + linkedIds.join(', ') + '}'); + relayed += linkedIds.length; + } + }); + + return relayed; + }; + + /** + * Stage selected tokens: duplicate to player pages and link. + * !gaslight stage [playerName1 playerName2 ...] + */ + const doStage = (msg, args) => { + var s = state[SCRIPT_NAME]; + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) to stage.'); return; } + + // Find which active group this page belongs to + var pageId = tokens[0].get('_pageid'); + var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId || Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); }); + if (!activeEntry) { reply(msg, 'Error', 'Token is not on an active gaslit page.'); return; } + var groupName = activeEntry[0]; + var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; + + // Determine target players + var targetPlayerIds = []; + if (args.length > 0) { + args.forEach(function(name) { + var resolved = Object.entries(groupInfo.players).find(function(e) { + return e[1].name && e[1].name.toLowerCase() === name.toLowerCase(); + }); + if (resolved) targetPlayerIds.push(resolved[0]); + else reply(msg, 'Warning', 'Player "' + name + '" not found in group.'); + }); + } else { + targetPlayerIds = Object.keys(groupInfo.players); + } + + if (targetPlayerIds.length === 0) { reply(msg, 'Error', 'No valid target players.'); return; } + + var staged = 0; + tokens.forEach(function(token) { + var sourcePageId = token.get('_pageid'); + var targetPages = targetPlayerIds + .map(function(pid) { return groupInfo.players[pid].pageId; }) + .filter(function(pid) { return pid !== sourcePageId; }); + // Include master if source is not master + if (sourcePageId !== groupInfo.master) targetPages.push(groupInfo.master); + staged += stageTokenToPages(token, targetPages); + }); + + // Re-run linking for this group to pick up the new tokens + if (staged > 0) { + var groupDiscovered = discoverGroup(groupName); + var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); + }); + var allLinks = []; + Object.values(groupDiscovered.players).forEach(function(pInfo) { + var links = resolveLinks(groupDiscovered.master, pInfo.pageId); + links.forEach(function(l) { if (l.target) allLinks.push(l); }); + }); + establishLinks(groupName, groupDiscovered, allLinks); + } + + reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); + }; + + /** + * Auto-stage: when a token is added to a gaslit page and its character has gaslight_stage=1. + */ + const onTokenAdded = (obj) => { + var s = state[SCRIPT_NAME]; + var charId = obj.get('represents'); + if (!charId) return; + + // Check gaslight_stage attribute + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_stage' })[0]; + if (!attr || attr.get('current') !== '1') return; + + // Find which active group this page belongs to + var pageId = obj.get('_pageid'); + var activeEntry = Object.entries(s.activeGroups).find(function(e) { + if (e[1].masterPageId === pageId) return true; + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); + }); + if (!activeEntry) return; + + var groupName = activeEntry[0]; + var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; + + // Clone to all OTHER pages (master + players, excluding source page) + var targetPages = []; + if (pageId !== groupInfo.master) targetPages.push(groupInfo.master); + Object.values(groupInfo.players).forEach(function(pInfo) { + if (pInfo.pageId !== pageId) targetPages.push(pInfo.pageId); + }); + stageTokenToPages(obj, targetPages); + + // Re-link after a short delay to let createObj finish + setTimeout(function() { + var groupDiscovered = discoverGroup(groupName); + var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); + }); + var allLinks = []; + Object.values(groupDiscovered.players).forEach(function(pInfo) { + var links = resolveLinks(groupDiscovered.master, pInfo.pageId); + links.forEach(function(l) { if (l.target) allLinks.push(l); }); + }); + establishLinks(groupName, groupDiscovered, allLinks); + }, 500); + }; + + const doConfig = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + var cmds = s.config.relayCommands.length > 0 ? s.config.relayCommands.join(', ') : '(none)'; + reply(msg, 'Config', 'relay-commands: ' + cmds); + return; + } + var sub = args.shift(); + if (sub === 'relay-add') { + if (args.length === 0) { reply(msg, 'Error', 'Specify command(s) to add.'); return; } + args.forEach(function(cmd) { + if (s.config.relayCommands.indexOf(cmd) === -1) s.config.relayCommands.push(cmd); + }); + reply(msg, 'Config', 'relay-commands: ' + s.config.relayCommands.join(', ')); + } else if (sub === 'relay-remove') { + if (args.length === 0) { reply(msg, 'Error', 'Specify command(s) to remove.'); return; } + s.config.relayCommands = s.config.relayCommands.filter(function(c) { return args.indexOf(c) === -1; }); + reply(msg, 'Config', 'relay-commands: ' + (s.config.relayCommands.length > 0 ? s.config.relayCommands.join(', ') : '(none)')); + } else if (sub === 'relay-list') { + var cmds = s.config.relayCommands.length > 0 ? s.config.relayCommands.join(', ') : '(none)'; + reply(msg, 'Config', 'relay-commands: ' + cmds); + } else { + reply(msg, 'Error', 'Usage: !gaslight config [relay-add|relay-remove|relay-list] [commands...]'); + } + }; + + const doStatus = (msg) => { + const s = state[SCRIPT_NAME]; + const groups = Object.keys(s.activeGroups); + + // Also show all configured groups (not just active) + const allGroups = discoverAllGroups(); + var out = 'Configured Groups:
'; + if (Object.keys(allGroups).length === 0) { + out += '(none)
'; + } else { + Object.entries(allGroups).forEach(function(entry) { + var gn = entry[0], info = entry[1]; + var masterName = info.master ? (getObj('page', info.master) || {get:function(){return '?';}}).get('name') : 'NO MASTER'; + var playerNames = Object.values(info.players).join(', ') || 'none'; + out += '' + gn + ': master="' + masterName + '", players=' + playerNames + + (groups.indexOf(gn) !== -1 ? ' [ACTIVE]' : '') + '
'; + }); + } + + if (groups.length > 0) { + out += '
Active Splits:
'; + groups.forEach(function(gn) { + var g = s.activeGroups[gn]; + out += '' + gn + ': ' + + Object.keys(g.playerPages).length + ' player(s), ' + + Object.keys(g.linkedTokens).length + ' parent(s)
'; + }); + } + reply(msg, out); + }; + + /** + * Discover ALL groups across all pages (not just one group). + */ + const discoverAllGroups = () => { + const pages = findObjs({ _type: 'page' }); + const groups = {}; + pages.forEach(function(page) { + var configs = getConfigsOnPage(page.get('_id')); + configs.forEach(function(c) { + var gn = c.data.group; + if (!groups[gn]) groups[gn] = { master: null, players: {} }; + if (c.data.player === 'GM') groups[gn].master = page.get('_id'); + else if (c.data.playerid) groups[gn].players[c.data.playerid] = c.data.player; + }); + }); + return groups; + }; + + const doUngroup = (msg, args) => { + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight ungroup <group> <player|GM|--all>'); return; } + args = args.slice(1); + + if (args.indexOf('--all') !== -1) { + var removed = 0; + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (cfg) { cfg.obj.remove(); removed++; } + }); + reply(msg, 'Ungroup', 'Removed all ' + removed + ' config(s) for group "' + groupName + '".'); + return; + } + + var playerArg = args.join(' ').replace(/^["']|["']$/g, ''); + if (!playerArg) { reply(msg, 'Error', 'Specify a player name, GM, or --all.'); return; } + + // First try matching directly against stored player name in config + var found = false; + if (playerArg.toLowerCase() === 'gm' || playerArg.toLowerCase() === 'master') { + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (cfg && cfg.data.player === 'GM') { + cfg.obj.remove(); + found = true; + reply(msg, 'Ungroup', 'Removed GM (master) from group "' + groupName + '" (page: ' + page.get('name') + ').'); + } + }); + } else { + // Try matching by stored player name first + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg || cfg.data.player === 'GM') return; + if (cfg.data.player.toLowerCase() === playerArg.toLowerCase()) { + cfg.obj.remove(); + found = true; + reply(msg, 'Ungroup', 'Removed "' + cfg.data.player + '" from group "' + groupName + '" (page: ' + page.get('name') + ').'); + } + }); + + // If no match by stored name, try resolving as a player and match by ID + if (!found) { + var resolved = resolvePlayer(msg, playerArg, CMD + ' ungroup ' + groupName); + if (!resolved || resolved === 'ambiguous') return; + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg || cfg.data.player === 'GM') return; + if (cfg.data.playerid === resolved.id) { + cfg.obj.remove(); + found = true; + reply(msg, 'Ungroup', 'Removed "' + resolved.name + '" from group "' + groupName + '" (page: ' + page.get('name') + ').'); + } + }); + } + } + + if (!found) { + reply(msg, 'Error', 'No config found for "' + playerArg + '" in group "' + groupName + '".'); + } + }; + + const checkDanglingGroups = () => { + const allGroups = discoverAllGroups(); + var dangling = []; + Object.entries(allGroups).forEach(function(entry) { + if (!entry[1].master) dangling.push(entry[0]); + }); + if (dangling.length > 0) { + var out = '⚠️ Dangling groups with no master page:
'; + dangling.forEach(function(gn) { + out += '' + gn + ': '; + out += '!gaslight ungroup ' + gn + ' --all to remove, or '; + out += '!gaslight group ' + gn + ' GM to assign a master.
'; + }); + sendChat(SCRIPT_NAME, '/w gm ' + out); + } + }; + + const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' + + '' + CMD + ' setup <group> -- Quick-configure from duplicated pages
' + + '' + CMD + ' split <group> -- Activate group
' + + '' + CMD + ' merge [group] -- Tear down links
' + + '' + CMD + ' test <group> -- Dry-run linking
' + + '' + CMD + ' link [name|new] [ids...] -- Link tokens
' + + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + + '' + CMD + ' view [master|off|<player>] -- Control relay targeting
' + + '' + CMD + ' relay <views> <!cmd> -- Relay command to views
' + + '' + CMD + ' group <group> <player|GM> -- Assign page
' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' stage [players...] -- Propagate tokens to player pages
' + + '' + CMD + ' config [relay-add|relay-remove|relay-list] -- Configure relay
' + + '' + CMD + ' status -- Show state
' + + '' + CMD + ' --help -- This help
' + + '
View modes:
' + + 'master -- relay to all (default on split)
' + + 'off -- relay disabled (GM-only changes)
' + + '<player> -- relay to one player only
' + + '
Initiative: Linked tokens auto-sync in turn order. Non-master children are auto-skipped on turn advance.
'; + + // ========================================================================= + // Scripting Engine — Fetch Integration + // ========================================================================= + + // Module-level evaluation context for Fetch compProp resolution + var evaluationContext = { scope: 'token', targetId: null, viewerPlayerId: null }; + + /** + * Read a gl_ field from a token's gmnotes. + */ + const readGlField = (gmnotes, fieldName) => { + var notes = gmnotes || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + var rx = new RegExp(fieldName + '\\s*:\\s*(\\S+)'); + var match = notes.match(rx); + return match ? match[1] : ''; + }; + + /** + * Register a gl_ field as a Fetch compProp on the graphic type. + * Resolution depends on evaluationContext.scope. + */ + const registerGlCompProp = (fieldName) => { + if (typeof Fetch === 'undefined' || !Fetch.CustomPropsByType) return; + if (Fetch.CustomPropsByType.graphic.compProps[fieldName]) return; + + var valFn = function(o) { + if (evaluationContext.scope === 'token') { + return readGlField(o.gmnotes, fieldName); + } else { + var charId = o.represents; + if (!charId) return ''; + return getAttrByName(charId, fieldName) || ''; + } + }; + + Fetch.CustomPropsByType.graphic.compProps[fieldName] = { nicks: [], val: valFn }; + // Also inject into the cached PropContainers so Fetch uses it immediately + if (Fetch.PropContainers && Fetch.PropContainers.graphic) { + Fetch.PropContainers.graphic[fieldName] = valFn; + } + log(SCRIPT_NAME + ': registered Fetch compProp "' + fieldName + '"'); + }; + + /** + * Scan a script for gl_ references and register compProps for each. + */ + const registerCompPropsFromScript = (content) => { + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + var rx = /@\([^)]*\.(gl_[a-zA-Z0-9_]+)\)/g; + var match; + while ((match = rx.exec(text)) !== null) { + registerGlCompProp(match[1]); + } + }; + + /** + * Scan all active script handouts and register compProps. + * Called on split and when handouts change. + */ + const registerAllCompProps = () => { + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + getPinScript(pin, function(content) { + if (content) registerCompPropsFromScript(content); + }); + }); + }); + }); + }; + + // ========================================================================= + // Scripting Engine — Trigger Map + // ========================================================================= + + // triggerMap: attributeName → [{ pinId, pageId }] + var triggerMap = {}; + + /** + * Parse a script's conditional blocks to find referenced attributes for auto-triggering. + * Looks for @(target.gl_*) and @(viewer.*) inside {& if} blocks. + */ + const parseTriggersFromScript = (content) => { + var triggers = []; + // Strip HTML for parsing + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + // Find content inside {& if ...} blocks (simple regex — catches most cases) + var ifRx = /\{&\s*if\s+(.+?)\}/gi; + var match; + while ((match = ifRx.exec(text)) !== null) { + var condition = match[1]; + // Find @(target.*) and @(viewer.*) references in the condition + var refRx = /@\((?:target|viewer)\.([^)]+)\)/g; + var refMatch; + while ((refMatch = refRx.exec(condition)) !== null) { + var field = refMatch[1]; + if (triggers.indexOf(field) === -1) triggers.push(field); + } + } + return triggers; + }; + + /** + * Build the trigger map for all active script pins. + * Called on split, and when handouts change. + */ + const buildTriggerMap = () => { + triggerMap = {}; + var s = state[SCRIPT_NAME]; + + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + parsePinConfig(pin, function(config) { + if (!config) return; + var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); + var manualOnly = config.triggers.some(function(t) { return t === 'manual only'; }); + + if (manualOnly) return; + + if (explicitTriggers.length > 0) { + explicitTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + } else { + getPinScript(pin, function(content) { + if (!content) return; + var autoTriggers = parseTriggersFromScript(content); + var ignored = config.triggers.filter(function(t) { return t.startsWith('ignore '); }).map(function(t) { return t.slice(7).trim(); }); + autoTriggers = autoTriggers.filter(function(t) { return ignored.indexOf(t) === -1; }); + + autoTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + }); + } + }); + }); + }); + }); + }; + + /** + * Handle attribute changes — check trigger map and re-evaluate affected pins. + */ + const onAttributeChanged = (obj) => { + var attrName = obj.get('name'); + var entries = triggerMap[attrName]; + if (!entries || entries.length === 0) return; + + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }; + + /** + * Handle token property changes — check trigger map for graphic properties. + */ + const onGraphicPropChanged = (obj, prev) => { + var changed = Object.keys(prev).filter(function(k) { return !k.startsWith('_') && prev[k] !== obj.get(k) && k !== 'gmnotes'; }); + if (changed.length === 0) return; + + var triggered = false; + changed.forEach(function(prop) { + var entries = triggerMap[prop]; + if (!entries || entries.length === 0) return; + if (triggered) return; // only evaluate once per change event + triggered = true; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }); + }; + const onGmNotesChanged = (obj, prev) => { + if (!prev || !prev.gmnotes) return; + var oldNotes = prev.gmnotes || ''; + var newNotes = obj.get('gmnotes') || ''; + try { oldNotes = decodeURIComponent(oldNotes); } catch(e) {} + try { newNotes = decodeURIComponent(newNotes); } catch(e) {} + + // Parse gl_ fields from old and new + var glRx = /gl_([a-zA-Z0-9_]+)\s*[=:]\s*(.+)/g; + var oldFields = {}; + var newFields = {}; + var m; + while ((m = glRx.exec(oldNotes)) !== null) oldFields['gl_' + m[1]] = m[2].trim(); + glRx.lastIndex = 0; + while ((m = glRx.exec(newNotes)) !== null) newFields['gl_' + m[1]] = m[2].trim(); + + // Find changed fields + var changedFields = Object.keys(newFields).filter(function(k) { return oldFields[k] !== newFields[k]; }); + // Also check removed fields + Object.keys(oldFields).forEach(function(k) { if (!(k in newFields) && changedFields.indexOf(k) === -1) changedFields.push(k); }); + + changedFields.forEach(function(field) { + var entries = triggerMap[field]; + if (!entries || entries.length === 0) return; + // Find the master page counterpart of this token + var tokenId = obj.get('id'); + var masterTokenId = null; + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(active) { + // Check if this token is linked; find the master copy + var allLinked = active.linkedTokens[tokenId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked = allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i; }); + allLinked.forEach(function(id) { + var t = getObj('graphic', id); + if (t && t.get('_pageid') === active.masterPageId) masterTokenId = id; + }); + // If the token itself is on master + if (obj.get('_pageid') === active.masterPageId) masterTokenId = tokenId; + }); + + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false, masterTokenId, obj.get('_pageid')); + }); + }); + }; + + // ========================================================================= + // Scripting Engine + // ========================================================================= + + /** + * Read a handout's notes content (async → callback pattern). + * Returns content via callback since Roll20 requires it for notes/gmnotes. + */ + const getHandoutContent = (handoutId, callback) => { + var handout = getObj('handout', handoutId); + if (!handout) { callback(null); return; } + handout.get('notes', function(notes) { + if (!notes) { callback(''); return; } + var text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + callback(text); + }); + }; + + /** + * Find pins on a page that are gaslight script pins. + * A pin is a script pin if: + * - It links to a handout (script in handout notes, config in handout gmNotes or pin gmNotes) + * - OR it has ---GASLIGHT-SCRIPT--- in its own gmNotes (self-contained) + */ + const findScriptPins = (pageId) => { + var pins = findObjs({ _type: 'pin', _pageid: pageId }); + return pins.filter(function(pin) { + if (pin.get('link') && pin.get('linkType') === 'handout') return true; + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf('---GASLIGHT-SCRIPT---') !== -1; + }); + }; + + /** + * Parse pin configuration. Checks pin gmNotes first, falls back to linked handout gmNotes. + */ + const parsePinConfig = (pin, callback) => { + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + + // If pin has its own config, use it + if (notes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(notes)); + return; + } + + // Fall back to linked handout's gmNotes + var handoutId = pin.get('link'); + if (handoutId) { + var handout = getObj('handout', handoutId); + if (handout) { + handout.get('gmnotes', function(gmnotes) { + gmnotes = gmnotes || ''; + try { gmnotes = decodeURIComponent(gmnotes); } catch(e) {} + if (gmnotes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(gmnotes)); + } else { + // No config found, use defaults + callback({ scope: 'token', filter: 'all', triggers: [] }); + } + }); + return; + } + } + callback(null); + }; + + /** + * Parse config text into structured object. + */ + const parseConfigText = (text) => { + var config = { scope: 'token', filter: 'all', triggers: [] }; + // Strip HTML and normalize line breaks + text = text.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + text.split('\n').forEach(function(line) { + line = line.trim(); + if (line.startsWith('scope:')) config.scope = line.slice(6).trim(); + else if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); + else if (line.startsWith('trigger:')) config.triggers.push(line.slice(8).trim()); + }); + return config; + }; + + /** + * Get the script content for a pin. + * Linked pin: from handout notes. Self-contained: from pin notes. + */ + const getPinScript = (pin, callback) => { + var handoutId = pin.get('link'); + if (handoutId) { + getHandoutContent(handoutId, callback); + } else { + // Self-contained: script in pin notes + var notes = pin.get('notes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + callback(notes); + } + }; + + /** + * Get target tokens for evaluation based on pin config filter. + */ + const getTargetTokens = (pageId, config, activeGroups) => { + var tokens = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var filter = config.filter.toLowerCase(); + if (filter === 'all') return tokens; + if (filter === 'npc') { + return tokens.filter(function(t) { + var charId = t.get('represents'); + if (!charId) return false; + var character = getObj('character', charId); + if (!character) return false; + var cb = character.get('controlledby') || ''; + return !cb || cb === ''; + }); + } + if (filter.startsWith('has ')) { + var field = filter.slice(4).trim(); + return tokens.filter(function(t) { + // Check gmnotes + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1) return true; + // Check character attribute + var charId = t.get('represents'); + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: field })[0]; + if (attr) return true; + } + return false; + }); + } + return tokens; + }; + + /** + * Find the linked counterpart of a token on a specific page. + */ + const findLinkedTokenOnPage = (sourceToken, targetPageId) => { + var s = state[SCRIPT_NAME]; + var sourceId = sourceToken.get('id'); + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = active.linkedTokens[sourceId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(sourceId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i && id !== sourceId; }).forEach(function(id) { + linkedIds.push(id); + }); + }); + for (var i = 0; i < linkedIds.length; i++) { + var obj = getObj('graphic', linkedIds[i]); + if (obj && obj.get('_pageid') === targetPageId) return obj; + } + return null; + }; + + /** + * Evaluate a script for a specific target token and viewer. + * Resolves target to the linked copy on the viewer's page. + */ + // ─── Viewer Aggregation ──────────────────────────────────────────────────── + + const OPS = ['>=', '<=', '!=', '!~', '=', '~', '>', '<']; + + /** + * Find `search` in `str` starting at `startIdx`, skipping quoted regions. + * Returns index or -1. + */ + const findUnquoted = (str, search, startIdx) => { + var inQuote = null; + for (var i = startIdx || 0; i <= str.length - search.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (str.slice(i, i + search.length) === search) return i; + } + return -1; + }; + + /** + * Find the matching close paren for an open paren at `start`. + * Skips quoted strings. Returns index of closing paren or -1. + */ + const findCloseParen = (str, start) => { + var depth = 0; + var inQuote = null; + for (var i = start; i < str.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (ch === '(') depth++; + else if (ch === ')') { depth--; if (depth === 0) return i; } + } + return -1; + }; + + /** + * Extract operator and RHS starting at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced ), ||, &&, or }. + * Returns { op, rhs, end } or null. + */ + const extractOpRhs = (str, pos) => { + var rest = str.slice(pos).replace(/^\s*/, ''); + var offset = pos + (str.slice(pos).length - rest.length); + for (var i = 0; i < OPS.length; i++) { + if (rest.startsWith(OPS[i])) { + var afterOp = rest.slice(OPS[i].length).replace(/^\s*/, ''); + var opEnd = offset + OPS[i].length + (rest.slice(OPS[i].length).length - afterOp.length); + var rhs = ''; + var depth = 0; + var inQ = null; + var j = 0; + for (; j < afterOp.length; j++) { + var c = afterOp[j]; + if (inQ) { if (c === inQ) inQ = null; rhs += c; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; rhs += c; continue; } + if (c === '(') { depth++; rhs += c; continue; } + if (c === ')') { if (depth === 0) break; depth--; rhs += c; continue; } + if (depth === 0 && j + 1 < afterOp.length && (afterOp.slice(j, j + 2) === '||' || afterOp.slice(j, j + 2) === '&&')) break; + if (c === '}') break; + rhs += c; + } + return { op: OPS[i], rhs: rhs.trim(), end: opEnd + j }; + } + } + return null; + }; + + /** + * Extract operator and LHS ending at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced (, ||, &&, or {&. + * Returns { op, lhs, start } or null. + */ + const extractOpLhs = (str, pos) => { + var before = str.slice(0, pos).replace(/\s*$/, ''); + for (var i = 0; i < OPS.length; i++) { + if (before.endsWith(OPS[i])) { + var beforeOp = before.slice(0, -OPS[i].length).replace(/\s*$/, ''); + var lhs = ''; + var depth = 0; + var inQ = null; + var j = beforeOp.length - 1; + for (; j >= 0; j--) { + var c = beforeOp[j]; + if (inQ) { if (c === inQ) inQ = null; lhs = c + lhs; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; lhs = c + lhs; continue; } + if (c === ')') { depth++; lhs = c + lhs; continue; } + if (c === '(') { if (depth === 0) break; depth--; lhs = c + lhs; continue; } + if (j > 0 && (beforeOp.slice(j - 1, j + 1) === '||' || beforeOp.slice(j - 1, j + 1) === '&&')) { break; } + lhs = c + lhs; + } + return { op: OPS[i], lhs: lhs.trim(), start: j + 1 }; + } + } + return null; + }; + + /** + * Expand any()/all()/max()/min() viewer aggregates. + * Sweep 1: any (LHS then RHS) + * Sweep 2: all (LHS then RHS) + * Sweep 3: max/min (resolve to literal) + */ + const expandAggregates = (content, ids, namespace) => { + if (ids.length === 0) return content; + content = expandAggregate(content, 'any', '||', ids, namespace); + content = expandAggregate(content, 'all', '&&', ids, namespace); + content = resolveMaxMin(content, ids, namespace); + content = resolveJoin(content, ids, namespace); + return content; + }; + + const expandAggregate = (content, funcName, joiner, ids, namespace) => { + var search = funcName + '('; + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + funcName.length); + if (closeIdx === -1) break; + + var inner = content.slice(idx + funcName.length + 1, closeIdx); + // Only expand if this aggregate contains our namespace + if (inner.indexOf('@(' + namespace + '.') === -1) { idx = findUnquoted(content, search, idx + 1); continue; } + + var beforeAgg = content.slice(0, idx); + var afterAgg = content.slice(closeIdx + 1); + + var opRhs = extractOpRhs(afterAgg, 0); + if (opRhs) { + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.') + ' ' + opRhs.op + ' ' + opRhs.rhs; + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg.slice(opRhs.end); + } else { + var opLhs = extractOpLhs(beforeAgg, beforeAgg.length); + if (opLhs) { + var expanded = '(' + ids.map(function(id) { + return opLhs.lhs + ' ' + opLhs.op + ' ' + inner.replace(nsRx, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg.slice(0, opLhs.start) + expanded + afterAgg; + } else { + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg; + } + } + + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + + const resolveMaxMin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; + ['max', 'min'].forEach(function(fn) { + var search = fn + '('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + fn.length); + if (closeIdx === -1) break; + var inner = content.slice(idx + fn.length + 1, closeIdx); + if (inner.indexOf(nsCheck) !== -1) { + var expanded = '{& math ' + fn + '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); + }).join(', ') + ')}'; + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + }); + return content; + }; + + const resolveJoin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; + var search = 'join('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + 4); + if (closeIdx === -1) break; + var inner = content.slice(idx + 5, closeIdx); + if (inner.indexOf(nsCheck) !== -1) { + // Check for optional delimiter: join(@(viewer.field), ",") + var parts = inner.split(','); + var field = parts[0].trim(); + var delim = ' '; + if (parts.length > 1) { + var rawDelim = parts.slice(1).join(',').trim(); + delim = rawDelim.replace(/^['"`]|['"`]$/g, ''); + } + var expanded = ids.map(function(id) { + return field.replace(nsRx, '@(' + id + '.'); + }).join(delim); + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, viewerPageId, config, msg, dryRun) => { + // Find the linked token on the viewer's page + var viewerTarget = findLinkedTokenOnPage(targetToken, viewerPageId); + if (!viewerTarget) return; + + // Set evaluation context for Fetch compProp resolution + evaluationContext.scope = config.scope || 'token'; + evaluationContext.targetId = viewerTarget.get('id'); + evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page + + var content = scriptContent; + // Resolve @(target.gl_*) ourselves since Fetch compProps don't fire for sendChat messages + content = content.replace(/@\(target\.(gl_[a-zA-Z0-9_]+)\)/g, function(match, field) { + var val = ''; + if (config.scope === 'token') { + val = readGlField(viewerTarget.get('gmnotes'), field); + } + if (!val) { + var charId = viewerTarget.get('represents'); + val = charId ? (getAttrByName(charId, field) || '') : ''; + } + return val; + }); + // Replace remaining @(target.*) with token ID — Fetch resolves native props + content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); + // Resolve viewer tokens for aggregation + var viewerTokens = findObjs({ _type: 'graphic', _pageid: viewerPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return cb === 'all' || cb.split(',').indexOf(viewerPlayerId) !== -1; + }); + var viewerIds = viewerTokens.map(function(t) { return t.get('id'); }); + + // Resolve GM tokens on master page for gm.* aggregation + var masterPageId = targetToken.get('_pageid'); + var gmTokens = findObjs({ _type: 'graphic', _pageid: masterPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return !cb || cb.split(',').every(function(id) { return id.trim() === '' || playerIsGM(id.trim()); }); + }); + var gmIds = gmTokens.map(function(t) { return t.get('id'); }); + + // Expand any()/all()/max()/min() aggregates for viewer.* and gm.* + content = expandAggregates(content, viewerIds, 'viewer'); + content = expandAggregates(content, gmIds, 'gm'); + + // Error check: bare @(viewer.*) or @(gm.*) without aggregate + if (content.indexOf('@(viewer.') !== -1) { + whisper('⚠️ Script error: @(viewer.*) must be inside any(), all(), max(), or min()'); + return; + } + if (content.indexOf('@(gm.') !== -1) { + whisper('⚠️ Script error: @(gm.*) must be inside any(), all(), max(), or min()'); + return; + } + + var lines = content.split('\n').map(function(l) { + var ci = l.indexOf('//'); + return (ci !== -1 ? l.slice(0, ci) : l).trim(); + }).filter(function(l) { + return l && (l.startsWith('!') || l.startsWith('{&')); + }); + + if (dryRun) { + lines.forEach(function(l) { + sendChat('player|' + msg.playerid, CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' + l); + }); + } else { + var fullCmd = lines.join('\n'); + if (fullCmd) { + var senderId = msg.playerid; + if (senderId === 'API') { + var gmPlayer = findObjs({ _type: 'player' }).find(function(p) { return playerIsGM(p.get('_id')); }); + if (gmPlayer) senderId = gmPlayer.get('_id'); + } + sendChat('', CMD + ' --script-lock', null, { noarchive: true }); + sendChat(getPlayerName(senderId), fullCmd); + sendChat('', CMD + ' --script-unlock', null, { noarchive: true }); + } + } + }; + + /** + * Evaluate all scripts on pins for a given page. + */ + const evaluatePins = (pins, msg, dryRun, targetTokenId, sourcePageId) => { + var s = state[SCRIPT_NAME]; + pins.forEach(function(pin) { + var pageId = pin.get('_pageid'); + + // Find the active group for this page + var activeEntry = Object.entries(s.activeGroups).find(function(e) { + return e[1].masterPageId === pageId || Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); + }); + if (!activeEntry) return; + + var groupInfo = activeEntry[1]; + + // Determine which viewers to evaluate for based on pin placement + var viewers; + if (pageId === groupInfo.masterPageId) { + viewers = Object.entries(groupInfo.playerPages); + } else { + var playerEntry = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === pageId; }); + viewers = playerEntry ? [playerEntry] : []; + } + // If triggered from a specific player page, narrow to that viewer only + if (sourcePageId && sourcePageId !== groupInfo.masterPageId) { + var sourceViewer = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === sourcePageId; }); + if (sourceViewer) viewers = [sourceViewer]; + } + if (viewers.length === 0) return; + + // Get targets from master page (source of truth for token list) + var targets = getTargetTokens(groupInfo.masterPageId, { filter: 'all' }, s.activeGroups); + + parsePinConfig(pin, function(config) { + if (!config) return; + // Re-filter targets based on config + targets = getTargetTokens(groupInfo.masterPageId, config, s.activeGroups); + // If triggered by a specific token, only evaluate that one + if (targetTokenId) { + targets = targets.filter(function(t) { return t.get('id') === targetTokenId; }); + if (targets.length === 0) return; + } + + getPinScript(pin, function(content) { + if (!content) return; + // Strip HTML tags from content + content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + if (dryRun) { + var handout = pin.get('link') ? getObj('handout', pin.get('link')) : null; + var pinTitle = stripGlsTag(pin.get('title') || (handout && handout.get('name')) || pin.get('_id')); + sendChat('player|' + msg.playerid, CMD + ' --echo-header ' + pinTitle); + } + + // Evaluate for each viewer + target combination + viewers.forEach(function(entry) { + var viewerPlayerId = entry[0]; + var viewerPageId = entry[1].pageId; + targets.forEach(function(target) { + evaluateScript(content, target, viewerPlayerId, viewerPageId, config, msg, dryRun); + }); + }); + }); + }); + }); + }; + + /** + * !gaslight eval [--dry] [--all | ] + * With pins selected: evaluate those pins. + * With --all: evaluate all active pins. + * With handout name: evaluate all pins linked to that handout. + */ + const doEval = (msg, args) => { + var dryRun = args.indexOf('--dry-run') !== -1; + args = args.filter(function(a) { return a !== '--dry-run'; }); + + var pins = []; + + if (args.indexOf('--all') !== -1) { + // All active gaslit pages + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + pins = pins.concat(findScriptPins(pid)); + }); + }); + } else if (args.length > 0) { + // By handout name + var handoutName = args.join(' '); + var handout = findObjs({ _type: 'handout', name: handoutName })[0]; + if (!handout) { reply(msg, 'Error', 'Handout "' + handoutName + '" not found.'); return; } + var allPins = findObjs({ _type: 'pin' }); + pins = allPins.filter(function(p) { return p.get('link') === handout.get('_id'); }); + } else if (msg.selected && msg.selected.length > 0) { + // Selected pins + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (obj && obj.get('_type') === 'pin') pins.push(obj); + }); + } + + if (pins.length === 0) { reply(msg, 'Error', 'No pins found. Select pins, provide a handout name, or use --all.'); return; } + + reply(msg, 'Eval', 'Evaluating ' + pins.length + ' pin(s)' + (dryRun ? ' (dry run)' : '') + '...'); + evaluatePins(pins, msg, dryRun); + }; + + // ========================================================================= + // Command Router + // ========================================================================= + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (msg.content.split(' ')[0] !== CMD) return; + if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; + + const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const sub = (args.shift() || '').toLowerCase(); + + switch (sub) { + case 'setup': doSetup(msg, args); break; + case 'split': doSplit(msg, args); break; + case 'merge': doMerge(msg, args); break; + case 'test': doTest(msg, args); break; + case 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'sync': doSync(msg, args); break; + case 'desync': doDesync(msg, args); break; + case 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'relay': doRelay(msg, args); break; + case 'view': doView(msg, args); break; + case 'stage': doStage(msg, args); break; + case 'config': doConfig(msg, args); break; + case 'eval': doEval(msg, args); break; + case 'status': doStatus(msg); break; + case '--script-lock': + scripting = true; + // WORKAROUND: API sendChat sets playerid='API', Fetch denies char access. + // Temporarily enable playerscanids. TODO: remove when Fetch treats API as GM. + if (state.Fetch && state.Fetch.settings) { + state[SCRIPT_NAME]._fetchPcidBackup = state.Fetch.settings.playerscanids; + state.Fetch.settings.playerscanids = true; + } + return; + case '--script-unlock': + scripting = false; + if (state.Fetch && state.Fetch.settings && state[SCRIPT_NAME].hasOwnProperty('_fetchPcidBackup')) { + state.Fetch.settings.playerscanids = state[SCRIPT_NAME]._fetchPcidBackup; + delete state[SCRIPT_NAME]._fetchPcidBackup; + } + return; + case '--assign-capture': { + // Format: --assign-capture ... + var acRollName = args[0]; + var acCharId = args[1]; + var acCaptures = {}; + args.slice(2).forEach(function(a) { + var eq = a.indexOf('='); + if (eq > 0) acCaptures[a.slice(0, eq)] = a.slice(eq + 1); + }); + var acTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === acCharId; + }); + if (acTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + acTokens.forEach(function(t) { writeCapturesToToken(t, acRollName, acCaptures); }); + reply(msg, 'Capture', 'Assigned ' + acRollName + ' to ' + acTokens.length + ' token(s).'); + return; + } + case '--clear-capture': { + // Format: --clear-capture + var ccRollName = args[0]; + var ccCharId = args[1]; + var ccTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === ccCharId; + }); + if (ccTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + ccTokens.forEach(function(t) { + var gmnotes = decodeURIComponent(t.get('gmnotes') || ''); + gmnotes = gmnotes.replace(new RegExp('(^|\\n)gl_' + ccRollName + '_[^=]+=([^\\n]*)', 'g'), ''); + t.set('gmnotes', gmnotes.trim()); + }); + reply(msg, 'Capture', 'Cleared ' + ccRollName + ' overrides from ' + ccTokens.length + ' token(s).'); + return; + } + case '--echo': { + // Internal: dry-run echo. Format: !gaslight --echo + var echoRaw = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); + var [echoViewerId, echoTargetId] = echoRaw.split(' '); + var echoCmd = echoRaw.slice(echoViewerId.length + 1 + echoTargetId.length + 1); + var echoViewer = getObj('player', echoViewerId); + var echoTarget = getObj('graphic', echoTargetId); + var viewerName = echoViewer ? echoViewer.get('_displayname') : echoViewerId; + var echoTargetName = echoTarget ? echoTarget.get('name') : ''; + var targetDisplay = echoTargetName ? echoTargetName + ' ' + echoTargetId + '' : '' + echoTargetId + ''; + reply(msg, 'Eval', 'Dry run
Target: ' + targetDisplay + '
Viewer: ' + viewerName + ' ' + echoViewerId + '
' + echoCmd + ''); + break; + } + case '--echo-header': { + // Internal: dry-run pin header + var headerContent = msg.content.slice(msg.content.indexOf('--echo-header') + 13).trim(); + reply(msg, 'Eval', 'Pin: ' + headerContent); + break; + } + case '--dump-html': { + // Debug: dump raw content to console for selected pins/tokens or named character + if (args.length > 0) { + var charName = args.join(' '); + var charObj = findObjs({ _type: 'character', name: charName })[0]; + if (charObj) { + charObj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char "' + charName + '" bio]: ' + JSON.stringify(bio)); }); + charObj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char "' + charName + '" gmnotes]: ' + JSON.stringify(gn)); }); + } else { + reply(msg, 'Error', 'Character "' + charName + '" not found.'); + } + break; + } + var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + sel.forEach(function(obj) { + var type = obj.get('_type') || obj.get('type'); + if (type === 'pin') { + var handoutId = obj.get('link'); + if (handoutId) { + var ho = getObj('handout', handoutId); + if (ho) { + ho.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [handout gmnotes]: ' + JSON.stringify(gn)); }); + ho.get('notes', function(n) { log(SCRIPT_NAME + ' [handout notes]: ' + JSON.stringify(n)); }); + } + } else { + log(SCRIPT_NAME + ' [pin gmNotes]: ' + JSON.stringify(obj.get('gmNotes'))); + log(SCRIPT_NAME + ' [pin notes]: ' + JSON.stringify(obj.get('notes'))); + } + } else if (type === 'graphic') { + log(SCRIPT_NAME + ' [token ' + (obj.get('name') || obj.get('id')) + ' gmnotes]: ' + JSON.stringify(obj.get('gmnotes'))); + } else if (type === 'character') { + obj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' bio]: ' + JSON.stringify(bio)); }); + obj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' gmnotes]: ' + JSON.stringify(gn)); }); + } + }); + break; + } + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // RollCapture Integration + // ========================================================================= + + const registerWithRollCapture = () => { + if (typeof RollCapture === 'undefined' || !RollCapture.onCapture) return; + RollCapture.onCapture(SCRIPT_NAME, onCaptureReceived); + }; + + const onCaptureReceived = (event) => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + + var { charName, charId, rollName, captures, playerId, msg } = event; + var selected = (msg && msg.selected) || []; + + // Always write to character attribute + if (charId) { + Object.entries(captures).forEach(function(entry) { + var attrName = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var attr = findObjs({ type: 'attribute', _characterid: charId, name: attrName })[0]; + if (val === undefined) { + if (attr) attr.remove(); + } else { + if (attr) attr.set('current', String(val)); + else createObj('attribute', { _characterid: charId, name: attrName, current: String(val) }); + } + }); + } + + // Token assignment — only count tokens representing this character + var tokens = selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === charId; + }); + + // Fallback: if no selection, find tokens of this character on master pages + if (tokens.length === 0 && charId) { + var masterPageIds = Object.values(s.activeGroups).map(function(g) { return g.masterPageId; }); + tokens = findObjs({ _type: 'graphic', _subtype: 'token', represents: charId }).filter(function(t) { + return masterPageIds.indexOf(t.get('_pageid')) !== -1; + }); + } + + if (tokens.length === 1) { + writeCapturesToToken(tokens[0], rollName, captures); + } else { + // Only prompt if any captured field is referenced by an active script + var hasRelevantTrigger = Object.keys(captures).some(function(cap) { + return triggerMap['gl_' + rollName + '_' + cap]; + }); + if (hasRelevantTrigger) { + var captureArgs = Object.entries(captures).map(function(e) { return e[0] + '=' + e[1]; }).join(' '); + whisper('**' + charName + '** rolled **' + rollName + '**: ' + captureArgs + + '
[Assign to selected](' + CMD + ' --assign-capture ' + rollName + ' ' + charId + ' ' + captureArgs + ')' + + ' [Clear overrides](' + CMD + ' --clear-capture ' + rollName + ' ' + charId + ')'); + } + } + + // Manually trigger pin evaluation for changed capture fields + var fakeMsg = { playerid: playerId || 'API', who: 'API', type: 'api' }; + var pins = Object.keys(captures).reduce(function(acc, cap) { + var entries = triggerMap['gl_' + rollName + '_' + cap] || []; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (pin && acc.indexOf(pin) === -1) acc.push(pin); + }); + return acc; + }, []); + if (pins.length > 0) evaluatePins(pins, fakeMsg, false); + }; + + const writeCapturesToToken = (token, rollName, captures) => { + var gmnotes = decodeURIComponent(token.get('gmnotes') || ''); + Object.entries(captures).forEach(function(entry) { + var field = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var rx = new RegExp('(^|\\n)' + field + '=[^\\n]*'); + if (val === undefined) { + gmnotes = gmnotes.replace(rx, ''); + } else if (gmnotes.match(rx)) { + gmnotes = gmnotes.replace(rx, '$1' + field + '=' + val); + } else { + gmnotes = gmnotes.trim() + '\n' + field + '=' + val; + } + }); + token.set('gmnotes', gmnotes); + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const HANDOUT_NAME = 'Help: Gaslight'; + const HANDOUT_AVATAR = 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385'; + + const createHelpHandout = () => { + var existing = findObjs({ type: 'handout', name: HANDOUT_NAME }); + var h = existing.length > 0 ? existing[0] : createObj('handout', { name: HANDOUT_NAME, avatar: HANDOUT_AVATAR }); + if (HANDOUT_AVATAR) h.set('avatar', HANDOUT_AVATAR); + h.set('notes', [ + '

Gaslight v' + SCRIPT_VERSION + '

', + '

Per-player map perception. Split players onto individual page copies with synchronized tokens. Each player can see different things while movement stays consistent.

', + '

Quick Start

', + '
    ', + '
  1. Create your master page with all tokens placed.
  2. ', + '
  3. Duplicate it once per player (Roll20 built-in Duplicate Page).
  4. ', + '
  5. Select party tokens on the master page, run: !gaslight setup mygroup — this auto-detects duplicates, assigns pages to players, and configures the group.
  6. ', + '
  7. Run !gaslight test mygroup — dry-run that shows how tokens will link without activating anything. Fix any warnings before proceeding.
  8. ', + '
  9. Run !gaslight split mygroup — activates the group: links tokens across pages, moves players to their individual pages, and begins syncing.
  10. ', + '
  11. When done: !gaslight merge — tears down all links, returns players to the banner page.
  12. ', + '
', + '

Commands

', + '

!gaslight setup <group> — Quick-configure from duplicate pages

', + '

!gaslight split <group> [--force] — Activate group

', + '

!gaslight merge [group] — Tear down links, return players

', + '

!gaslight test <group> — Dry-run linking

', + '

!gaslight link [name|new] [ids...] — Manually link tokens

', + '

!gaslight unlink [ids...|--group <g>] — Remove links

', + '

!gaslight group <g> <player|GM> — Assign page to group

', + '

!gaslight ungroup <g> <player|--all> — Remove from group

', + '

!gaslight stage [players...] — Propagate tokens to player pages

', + '

!gaslight view [player|master] — Switch relay view

', + '

!gaslight relay <views> <!command> — Relay command to specific views

', + '

!gaslight config [relay-add|relay-remove|relay-list] — Configure relay commands

', + '

!gaslight eval [--dry-run] [--all|<handout>] — Evaluate script pins

', + '

!gaslight status — Show state

', + '

Auto-Relay

', + '

Any API command that references master-page linked tokens (via selection or token IDs in the command) is automatically relayed to all player pages. Token IDs in the command are replaced with their linked counterparts on each page. No configuration needed.

', + '

Player-page commands are page-local by default. A command run against tokens on a player page only affects that page. To have player-page commands relay to other player pages and master, add them to relay-commands: !gaslight config relay-add !token-mod

', + '

Selective Relay

', + '

Use !gaslight relay to send a command to specific players only. Useful when you are on a player page or want to exclude certain players:

', + '

!gaslight relay Alice Bob !token-mod --set layer|objects — only Alice and Bob see a door open; Charlie does not.

', + '

!gaslight relay all !token-mod --set bar1_value|10 — relay to all player pages (useful when running from a player page instead of master).

', + '

Token Linking

', + '

Tokens are linked across pages automatically by:

', + '
    ', + '
  1. gaslight_link in token GM notes (explicit)
  2. ', + '
  3. Same represents + name (unique pair per page)
  4. ', + '
  5. Same represents + position fingerprint
  6. ', + '
', + '

Sync Control

', + '

Set the gaslight_sync attribute on a character to control what stays in sync:

', + '
    ', + '
  • Absent — full sync (position + all properties). Default for most tokens.
  • ', + '
  • Empty — no sync at all. Use for tokens that are completely independent per player (e.g. a hallucination only one player sees).
  • ', + '
  • base — position/rotation/scale only. Use for NPCs whose appearance differs per player (e.g. a disguised shapechanger) but still moves together.
  • ', + '
  • base, bars — position + HP/bars. Use for enemies with different names or art per player but shared health pools.
  • ', + '
  • base, bars, light — position + HP + light. Standard for most combat tokens where you want per-player auras/names but shared position and health.
  • ', + '
  • !anchor — sync all properties except position. Use for a token that appears in different locations per player (e.g. an illusory wall) but keeps the same stats.
  • ', + '
', + '

Staging

', + '

Token changes and deletion propagate automatically across linked pages. However, token creation does not — new tokens placed on one page are not automatically copied to others.

', + '

Use !gaslight stage with tokens selected to duplicate them to all player pages and link them. Alternatively, set gaslight_stage = 1 on a character to auto-stage whenever a token representing that character is placed.

', + '

Scripting

', + '

Gaslight scripts are reactive automation stored in handouts, activated via pins on the map. Scripts evaluate per-viewer per-target and fire API commands conditionally.

', + '

Setup: Create a handout with your script. Place a pin on the master page, link it to the handout. Add config to the pin\'s GM notes:

', + '
---GASLIGHT-SCRIPT---\nscope: token\nfilter: has gl_stealth_result
', + '

Script syntax:

', + '
// Comments start with //\n!token-mod --ids @(target.token_id) --set {& if (any(@(viewer.passive_wisdom)) >= @(target.gl_stealth_result))} layer|objects {& else} layer|gmlayer {& end}
', + '

Variables:

', + '
    ', + '
  • @(target.*) — the token being evaluated (linked per viewer page)
  • ', + '
  • @(target.gl_*) — captured values (falls back to character attribute)
  • ', + '
', + '

Aggregate functions (required for viewer.*/gm.*):

', + '
    ', + '
  • any(@(viewer.field)) op value — true if any viewer token passes
  • ', + '
  • all(@(viewer.field)) op value — true if all pass
  • ', + '
  • max(@(viewer.field)) — highest value across viewer tokens
  • ', + '
  • min(@(viewer.field)) — lowest value
  • ', + '
  • join(@(viewer.token_id)) — space-separated IDs for commands
  • ', + '
', + '

Triggers: Scripts auto-detect triggers from @(target.gl_*) references. Override with pin GM notes: trigger: on change gl_stealth_result or trigger: manual only.

', + '

Evaluation: !gaslight eval (selected pins), !gaslight eval --all, or !gaslight eval <handout name>. Add --dry-run to preview without executing.

', + '

RollCapture integration: Install RollCapture to automatically capture roll results into gl_* attributes, which trigger script re-evaluation.

', + ].join('')); + }; + + const checkInstall = () => { + ensureState(); + // Crash recovery: revert Fetch playerscanids if we crashed mid-script + if (state[SCRIPT_NAME].hasOwnProperty('_fetchPcidBackup') && state.Fetch && state.Fetch.settings) { + state.Fetch.settings.playerscanids = state[SCRIPT_NAME]._fetchPcidBackup; + delete state[SCRIPT_NAME]._fetchPcidBackup; + } + createHelpHandout(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) buildTriggerMap(); + }; + + /** + * When a linked token is deleted, delete its counterparts on other pages. + */ + var destroying = false; + const onTokenDestroyed = (obj) => { + if (destroying) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find if this token is tracked in any active group + var linkedIds = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + // Clean up tracking + delete active.linkedTokens[tokenId]; + linkedIds.forEach(function(id) { + if (active.linkedTokens[id]) { + active.linkedTokens[id] = active.linkedTokens[id].filter(function(lid) { return lid !== tokenId; }); + } + }); + } else { + // Check if it's in someone else's list + Object.entries(active.linkedTokens).forEach(function(entry) { + var idx = entry[1].indexOf(tokenId); + if (idx !== -1) { + entry[1].splice(idx, 1); + if (!linkedIds) linkedIds = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + + // Remove Anchor/Mirror links and delete counterparts + destroying = true; + linkedIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + if (typeof Mirror !== 'undefined') Mirror.unlink([id]); + var target = getObj('graphic', id); + if (target) target.remove(); + }); + destroying = false; + }; + + /** + * Universal relay interceptor. Automatically relays commands to linked tokens: + * - If selected tokens or IDs in command reference master-page linked tokens + * AND no player-page tokens are selected/referenced → relay to all player pages. + * - If player-page tokens are involved → only relay if command is in relayCommands. + */ + const viewInterceptor = (msg) => { + if (msg.type !== 'api') return; + if (scripting) return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var content = msg.content.trim(); + if (!content) return; + var firstWord = content.split(' ')[0]; + if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; + + // Check relaying set to prevent loops + var selectedIds = (msg.selected || []).map(function(sel) { return sel._id; }); + var key = relayKey(content, 'player|' + msg.playerid, selectedIds); + if (relaying.delete(key)) return; + if (content.indexOf('{& select') !== -1) return; + + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + + // Scan command for token IDs that belong to linked groups + var idRx = /-[A-Za-z0-9_-]{19}/g; + var idsInCommand = (content.match(idRx) || []).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); + + // Classify: which IDs/tokens are on master pages vs player pages? + var masterTokens = []; + var hasPlayerPageRef = false; + var activeEntry = null; + + // Check selected tokens + tokens.forEach(function(t) { + var pid = t.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + masterTokens.push(t); + if (!activeEntry) activeEntry = entry; + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + // Check IDs in command text + idsInCommand.forEach(function(id) { + // Skip IDs already accounted for by selection + if (tokens.some(function(t) { return t.get('id') === id; })) return; + var obj = getObj('graphic', id); + if (!obj) return; + var pid = obj.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + // Check if this token is actually linked + var linked = entry[1].linkedTokens[id] || []; + var isLinked = linked.length > 0 || Object.values(entry[1].linkedTokens).some(function(arr) { return arr.indexOf(id) !== -1; }); + if (isLinked) { + masterTokens.push(obj); + if (!activeEntry) activeEntry = entry; + } + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + if (masterTokens.length === 0 && !hasPlayerPageRef) return; + + // Universal relay: master-page refs, no player-page refs + if (masterTokens.length > 0 && !hasPlayerPageRef) { + var viewPlayerId = s.view; + if (!viewPlayerId) return; // view off — no relay + var targetPlayerIds = viewPlayerId === 'master' + ? Object.keys(activeEntry[1].playerPages) + : [viewPlayerId]; + executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); + return; + } + + // Player-page involved: only relay if relayCommands allows it + if (hasPlayerPageRef && s.config.relayCommands.indexOf(firstWord) !== -1) { + // Find source player page + var sourcePlayerId = null; + var entry = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + var srcToken = tokens.find(function(t) { return t.get('_pageid') === pp[1].pageId; }); + if (srcToken) { entry = e; sourcePlayerId = pp[0]; } + }); + }); + if (!entry) return; + var targetPlayerIds = Object.keys(entry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, content, targetPlayerIds, true); + } + }; + + // ========================================================================= + // Initiative Tracking — sync turn order across linked tokens + // ========================================================================= + + var _suppressTurnSync = false; + + /** + * Get all linked token IDs for a given token across all active groups. + * Returns { linkedIds: [...], isMaster: bool, masterPageId: string|null } + */ + const getLinkedInfo = (tokenId) => { + var s = state[SCRIPT_NAME]; + var linkedIds = []; + var isMaster = false; + var masterPageId = null; + Object.values(s.activeGroups).forEach(function(active) { + var ids = active.linkedTokens[tokenId]; + if (ids && ids.length > 0) { + linkedIds = linkedIds.concat(ids); + masterPageId = active.masterPageId; + var obj = getObj('graphic', tokenId); + if (obj && obj.get('_pageid') === active.masterPageId) isMaster = true; + } + }); + return { linkedIds: linkedIds, isMaster: isMaster, masterPageId: masterPageId }; + }; + + /** + * Determine if a token ID is a master token (on master page) in any active group. + */ + const isMasterToken = (tokenId) => { + var s = state[SCRIPT_NAME]; + var obj = getObj('graphic', tokenId); + if (!obj) return false; + var pageId = obj.get('_pageid'); + return Object.values(s.activeGroups).some(function(active) { + return active.masterPageId === pageId && !!active.linkedTokens[tokenId]; + }); + }; + + /** + * Check if two token IDs are linked (in the same link group). + */ + const areLinked = (tokenIdA, tokenIdB) => { + var info = getLinkedInfo(tokenIdA); + return info.linkedIds.indexOf(tokenIdB) !== -1; + }; + + /** + * Handle turnorder changes: sync linked tokens, apply [GM] tags, auto-skip children. + */ + const onTurnOrderChanged = (obj, prev) => { + if (_suppressTurnSync) return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + + var newOrder = JSON.parse(obj.get('turnorder') || '[]'); + var oldOrder = JSON.parse(prev.turnorder || '[]'); + + // Detect additions: entries in newOrder not in oldOrder + var oldIds = new Set(oldOrder.map(function(e) { return e.id; })); + var newIds = new Set(newOrder.map(function(e) { return e.id; })); + var added = newOrder.filter(function(e) { return e.id && e.id !== '-1' && !oldIds.has(e.id); }); + var removed = oldOrder.filter(function(e) { return e.id && e.id !== '-1' && !newIds.has(e.id); }); + + var modified = false; + + var modified = false; + + // Handle additions: add linked counterparts + added.forEach(function(entry) { + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + // Add linked tokens that aren't already in the order + var existingIds = new Set(newOrder.map(function(e) { return e.id; })); + info.linkedIds.forEach(function(linkedId) { + if (existingIds.has(linkedId)) return; + var linkedObj = getObj('graphic', linkedId); + var pushEntry = { id: linkedId, pr: entry.pr }; + if (linkedObj) pushEntry._pageid = linkedObj.get('_pageid'); + newOrder.push(pushEntry); + modified = true; + }); + }); + + // Handle removals: remove linked counterparts + removed.forEach(function(entry) { + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + var before = newOrder.length; + newOrder = newOrder.filter(function(e) { + return info.linkedIds.indexOf(e.id) === -1; + }); + if (newOrder.length !== before) modified = true; + }); + + // Handle value changes: sync initiative values across linked tokens + newOrder.forEach(function(entry) { + if (!entry.id || entry.id === '-1') return; + var oldEntry = oldOrder.find(function(e) { return e.id === entry.id; }); + if (!oldEntry || oldEntry.pr === entry.pr) return; + // Value changed — propagate to linked tokens + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + info.linkedIds.forEach(function(linkedId) { + var linkedEntry = newOrder.find(function(e) { return e.id === linkedId; }); + if (linkedEntry && linkedEntry.pr !== entry.pr) { + linkedEntry.pr = entry.pr; + modified = true; + } + }); + }); + + // Reorder: group linked tokens together, master first (only on additions) + if (added.length > 0) { + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + + // Auto-skip: if turn advanced and new top is linked to the entry that just left the top + // Auto-skip: if current top is a linked non-master token, skip in the correct direction + if (newOrder.length > 1 && newOrder[0].id && newOrder[0].id !== '-1') { + var topId = newOrder[0].id; + var topInfo = getLinkedInfo(topId); + if (topInfo.linkedIds.length > 0 && !topInfo.isMaster) { + // Detect direction + var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; + var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; + + var startId = topId; + var safety = newOrder.length; + + if (isForward) { + // Rotate forward: shift to end until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.push(newOrder.shift()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + modified = true; + } else if (isBackward) { + // Rotate backward: pop from end to front until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.unshift(newOrder.pop()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + modified = true; + } else if (added.length === 0 && removed.length === 0) { + // Neither forward nor backward and no add/remove — likely a sort; reorder groups + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + } + } else if (added.length === 0 && removed.length === 0 && newOrder.length > 1) { + // No child on top, but check if sort happened (order changed without add/remove) + var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; + var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; + if (!isForward && !isBackward && JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) { + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + } + + if (modified) { + var finalJson = JSON.stringify(newOrder); + _suppressTurnSync = true; + Campaign().set('turnorder', finalJson); + _suppressTurnSync = false; + } + }; + + /** + * Reorder initiative: group linked tokens together with master first in each group. + */ + const reorderInitiative = (order) => { + var s = state[SCRIPT_NAME]; + var result = []; + var placed = new Set(); + + order.forEach(function(entry) { + if (placed.has(entry.id)) return; + if (!entry.id || entry.id === '-1') { + result.push(entry); + placed.add(entry.id); + return; + } + + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) { + // Not a linked token — just add it + result.push(entry); + placed.add(entry.id); + return; + } + + // Find all entries in this link group + var groupIds = [entry.id].concat(info.linkedIds); + var groupEntries = order.filter(function(e) { + return groupIds.indexOf(e.id) !== -1 && !placed.has(e.id); + }); + + // Sort: master first, then children + groupEntries.sort(function(a, b) { + var aIsMaster = isMasterToken(a.id) ? 0 : 1; + var bIsMaster = isMasterToken(b.id) ? 0 : 1; + return aIsMaster - bIsMaster; + }); + + groupEntries.forEach(function(e) { + result.push(e); + placed.add(e.id); + }); + }); + + return result; + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('chat:message', viewInterceptor); + on('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!rollcapture-ready') registerWithRollCapture(); + }); + registerWithRollCapture(); + on('add:graphic', onTokenAdded); + on('destroy:graphic', onTokenDestroyed); + on('change:attribute', onAttributeChanged); + on('change:graphic', onGraphicPropChanged); + on('change:graphic:gmnotes', onGmNotesChanged); + on('change:campaign:turnorder', onTurnOrderChanged); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 0b9c00cc1..47ebdd60d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1691,15 +1691,27 @@ var Gaslight = Gaslight || (() => { }; const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' + + '' + CMD + ' setup <group> -- Quick-configure from duplicated pages
' + '' + CMD + ' split <group> -- Activate group
' + '' + CMD + ' merge [group] -- Tear down links
' + '' + CMD + ' test <group> -- Dry-run linking
' + '' + CMD + ' link [name|new] [ids...] -- Link tokens
' + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + + '' + CMD + ' view [master|off|<player>] -- Control relay targeting
' + + '' + CMD + ' relay <views> <!cmd> -- Relay command to views
' + '' + CMD + ' group <group> <player|GM> -- Assign page
' + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' stage [players...] -- Propagate tokens to player pages
' + + '' + CMD + ' config [relay-add|relay-remove|relay-list] -- Configure relay
' + '' + CMD + ' status -- Show state
' - + '' + CMD + ' --help -- This help
'; + + '' + CMD + ' --help -- This help
' + + '
View modes:
' + + 'master -- relay to all (default on split)
' + + 'off -- relay disabled (GM-only changes)
' + + '<player> -- relay to one player only
' + + '
Initiative: Linked tokens auto-sync in turn order. Non-master children are auto-skipped on turn advance.
'; // ========================================================================= // Scripting Engine — Fetch Integration diff --git a/Gaslight/README.md b/Gaslight/README.md index f4e291a98..ef4702cd5 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -37,11 +37,13 @@ Per-player map perception for Roll20. Split players onto individual copies of a | `!gaslight test ` | Dry-run linking resolution | | `!gaslight link [name\|new] [ids...]` | Manually link tokens | | `!gaslight unlink [ids...\|--group ]` | Remove links | +| `!gaslight sync [props\|all\|reset]` | Manage sync whitelist per token | +| `!gaslight desync [props\|all]` | Exclude props from sync per token | +| `!gaslight view [master\|off\|]` | Control command relay targeting | +| `!gaslight relay ` | Relay command to views | | `!gaslight group ` | Assign page to group | | `!gaslight ungroup ` | Remove page from group | | `!gaslight stage [players...]` | Propagate tokens to player pages | -| `!gaslight view [player\|master]` | Switch relay view | -| `!gaslight relay ` | Relay command to views | | `!gaslight config [relay-add\|relay-remove\|relay-list] [cmds]` | Configure relay | | `!gaslight status` | Show state | | `!gaslight --help` | Command reference | @@ -56,7 +58,7 @@ Per-player map perception for Roll20. Split players onto individual copies of a ## Sync Behavior -Controlled by `gaslight_sync` character attribute: +Controlled by `gaslight_sync` in token GM notes (auto-populated from character attribute on token placement/split): - **Absent** → Anchor (spatial) + Mirror (all non-spatial) - **Empty** → no sync at all - **`"base"`** → Anchor only (position, rotation, scale, flip) @@ -64,10 +66,31 @@ Controlled by `gaslight_sync` character attribute: - **`"!anchor"`** → Mirror everything except spatial - **`"anchor, !left"`** → Anchor minus left, Mirror nothing extra +### Per-Token Sync Commands + +| Command | Description | +|---------|-------------| +| `!gaslight sync` | Show current sync config for selected token(s) | +| `!gaslight sync ` | Add props to sync whitelist | +| `!gaslight sync all` | Explicitly sync everything | +| `!gaslight sync reset` | Re-copy from character attribute | +| `!gaslight desync ` | Exclude specific props from sync | +| `!gaslight desync all` | Disable all syncing (link preserved) | + ## Command Relay Any API command that references master-page linked tokens (via selection or token IDs in the command) is automatically relayed to all player pages with token IDs replaced by their linked counterparts. This happens transparently — no configuration needed. +### View Modes + +Control where commands relay to: + +| Command | Effect | +|---------|--------| +| `!gaslight view master` | Relay to all player pages (default on split) | +| `!gaslight view off` | Relay disabled — changes stay on master only | +| `!gaslight view ` | Relay only to that player's page | + **Rules:** - Master-page tokens selected or IDs in command → auto-relay to all player pages - Player-page tokens involved → only relay if the command is in `relayCommands` list @@ -77,6 +100,18 @@ Any API command that references master-page linked tokens (via selection or toke **Player auto-relay:** `!gaslight config relay-add !token-mod` — allow player-page commands to relay to other pages. +## Initiative Tracking + +Gaslight automatically syncs the turn order across linked tokens: + +- **Add**: When a token is added to initiative, all linked copies are added at the same value +- **Remove**: Removing a token removes all linked copies +- **Value sync**: Initiative value changes propagate to all linked copies +- **Auto-skip**: When the turn advances to a non-master linked token, Gaslight skips forward/backward to the next master or unlinked token +- **Sort-aware**: After sorting initiative, groups are reordered with master tokens on top + +The GM only interacts with master-page tokens in the turn tracker. Players see their own copies on their page. Linked children are skipped automatically when advancing turns. + ## Staging - `!gaslight stage` — propagate selected tokens to all player pages diff --git a/Gaslight/script.json b/Gaslight/script.json index 2c7736a71..1138b344b 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -1,9 +1,9 @@ { "name": "Gaslight", "script": "Gaslight.js", - "version": "2.0.0", - "previousversions": ["1.0.0", "1.1.0"], - "description": "[Wiki Page](https://wiki.roll20.net/Script:Gaslight)\n\nPer-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", + "version": "2.1.0", + "previousversions": ["1.0.0", "1.1.0", "2.0.0"], + "description": "[Wiki Page](https://wiki.roll20.net/Script:Gaslight)\n\nPer-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\n**v2.1.0 — New features:**\n- Initiative tracking: linked tokens auto-added to turn order, auto-skip children on turn advance, direction-aware (forward/backward), sort-aware\n- Token-level sync control: `!gaslight sync` / `!gaslight desync` commands to manage per-token sync config\n- View off mode: `!gaslight view off` disables command relay (GM-only changes)\n- State migration from v2.0.0\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight sync [props|all|reset]` -- Manage sync whitelist per token\n- `!gaslight desync [props|all]` -- Exclude props from sync per token\n- `!gaslight view [master|off|]` -- Control command relay targeting\n- `!gaslight relay ` -- Relay command to specific views\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", "dependencies": ["Anchor", "Mirror", "SelectManager", "RollCapture"], From 83f403f85344ec37043d7629af38aad2be865120 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 09:21:53 -0400 Subject: [PATCH 11/41] Gaslight: fix initiative drag reorder (master-anchored grouping) - reorderInitiative skips children, only anchors at master position - Auto-skip only fires on forward/backward turn advance - Manual drag with child on top triggers reorder instead of skip - Standalone reorder detection runs when master on top + order changed --- Gaslight/Gaslight.js | 66 ++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 47ebdd60d..d44ee1976 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3139,33 +3139,34 @@ var Gaslight = Gaslight || (() => { var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; - var startId = topId; - var safety = newOrder.length; - - if (isForward) { - // Rotate forward: shift to end until master/unlinked is on top - while (safety-- > 0 && newOrder.length > 1) { - newOrder.push(newOrder.shift()); - var cId = newOrder[0].id; - if (!cId || cId === '-1') break; - if (cId === startId) break; - var cInfo = getLinkedInfo(cId); - if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; - } - modified = true; - } else if (isBackward) { - // Rotate backward: pop from end to front until master/unlinked is on top - while (safety-- > 0 && newOrder.length > 1) { - newOrder.unshift(newOrder.pop()); - var cId = newOrder[0].id; - if (!cId || cId === '-1') break; - if (cId === startId) break; - var cInfo = getLinkedInfo(cId); - if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + if (isForward || isBackward) { + var startId = topId; + var safety = newOrder.length; + + if (isForward) { + // Rotate forward: shift to end until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.push(newOrder.shift()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + } else { + // Rotate backward: pop from end to front until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.unshift(newOrder.pop()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } } modified = true; - } else if (added.length === 0 && removed.length === 0) { - // Neither forward nor backward and no add/remove — likely a sort; reorder groups + } else { + // Not forward/backward — manual drag. Reorder to group children after master. var reordered = reorderInitiative(newOrder); if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { newOrder = reordered; @@ -3173,11 +3174,13 @@ var Gaslight = Gaslight || (() => { } } } - } else if (added.length === 0 && removed.length === 0 && newOrder.length > 1) { - // No child on top, but check if sort happened (order changed without add/remove) - var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; - var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; - if (!isForward && !isBackward && JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) { + } + + // Reorder detection: if order changed without forward/backward/add/remove, regroup + if (added.length === 0 && removed.length === 0 && newOrder.length > 1 && !modified) { + var isForward2 = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; + var isBackward2 = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; + if (!isForward2 && !isBackward2 && JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) { var reordered = reorderInitiative(newOrder); if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { newOrder = reordered; @@ -3218,6 +3221,9 @@ var Gaslight = Gaslight || (() => { return; } + // Skip children — they'll be pulled in when we reach their master + if (!isMasterToken(entry.id)) return; + // Find all entries in this link group var groupIds = [entry.id].concat(info.linkedIds); var groupEntries = order.filter(function(e) { From 50a59faabb77726751b84f8ddc952c1600e242cf Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 09:35:54 -0400 Subject: [PATCH 12/41] Gaslight: simplify initiative reorder - always ensure master-first grouping - Remove complex sort/drag detection heuristics - Always run reorderInitiative as final step before writing - Idempotent: no-op if groups already correct - Fixes reverse-sort leaving children above master --- Gaslight/Gaslight.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d44ee1976..4671770a1 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3176,17 +3176,11 @@ var Gaslight = Gaslight || (() => { } } - // Reorder detection: if order changed without forward/backward/add/remove, regroup - if (added.length === 0 && removed.length === 0 && newOrder.length > 1 && !modified) { - var isForward2 = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; - var isBackward2 = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; - if (!isForward2 && !isBackward2 && JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) { - var reordered = reorderInitiative(newOrder); - if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { - newOrder = reordered; - modified = true; - } - } + // Always ensure linked tokens are grouped below their master + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; } if (modified) { From 133cb0068b828f301b9c705e6a4aade8cd2255b4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Wed, 1 Jul 2026 20:46:09 -0400 Subject: [PATCH 13/41] Gaslight: HUD system + view indicator element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HUD infrastructure: state.hud tracks preferences + element IDs - !gaslight hud [element] [on|off|reset] — toggle/reset HUD elements - View HUD: foreground text showing relay state (OFF/ALL/player) - Position/size persists when GM moves element (change:text listener) - Delete = off (destroy:text listener, whispers to GM) - Merge destroys elements, split recreates if preference is on - Startup recovery: recreate missing elements, clean orphans - Reset: clears saved position, turns on, moves to center-top default --- Gaslight/Gaslight.js | 199 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 4671770a1..98f283d12 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -80,10 +80,12 @@ var Gaslight = Gaslight || (() => { state[SCRIPT_NAME] = { activeGroups: {}, config: { autoCommit: false, relayCommands: [] }, - view: 'master' + view: 'master', + hud: { view: false } }; } if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: false }; // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; @@ -1002,6 +1004,9 @@ var Gaslight = Gaslight || (() => { // Enable relay by default on split s.view = 'master'; + // Recreate HUD elements if enabled + if (s.hud.view) updateViewHud(); + var summary = 'Group "' + groupName + '" activated. ' + Object.keys(groupInfo.players).length + ' player(s), ' + allLinks.length + ' link(s) established.'; @@ -1073,6 +1078,9 @@ var Gaslight = Gaslight || (() => { }); reply(msg, 'Merge', 'Merged ' + groupsToMerge.length + ' group(s). Players returned to shared page.'); + + // Destroy HUD elements (preference preserved, will recreate on next split) + removeViewHud(); }; const doTest = (msg, args) => { @@ -1317,6 +1325,7 @@ var Gaslight = Gaslight || (() => { s.view = resolved.id; reply(msg, 'View', 'Switched to ' + resolved.name + ' view. Commands will auto-target their linked tokens.'); } + updateViewHud(); }; /** @@ -2555,6 +2564,7 @@ var Gaslight = Gaslight || (() => { case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; + case 'hud': doHud(msg, args); break; case 'eval': doEval(msg, args); break; case 'status': doStatus(msg); break; case '--script-lock': @@ -2857,6 +2867,17 @@ var Gaslight = Gaslight || (() => { log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) buildTriggerMap(); + + // HUD recovery: if enabled but object is missing, recreate; if disabled but object exists, clean up + var s = state[SCRIPT_NAME]; + if (s.hud.view) { + var existing = findHudElement('view'); + if (!existing) updateViewHud(); + } else if (s.hud.viewId) { + var orphan = getObj('text', s.hud.viewId); + if (orphan) orphan.remove(); + s.hud.viewId = null; + } }; /** @@ -3240,6 +3261,180 @@ var Gaslight = Gaslight || (() => { return result; }; + // ========================================================================= + // HUD System — on-canvas indicators + // ========================================================================= + + const HUD_PREFIX = 'gaslight_hud_'; + + /** + * Get the master page ID from the first active group. + */ + const getHudPageId = () => { + var s = state[SCRIPT_NAME]; + var group = Object.values(s.activeGroups)[0]; + return group ? group.masterPageId : null; + }; + + /** + * Find an existing HUD text object by stored ID. + */ + const findHudElement = (name) => { + var s = state[SCRIPT_NAME]; + var id = s.hud[name + 'Id']; + if (!id) return null; + return getObj('text', id) || null; + }; + + /** + * Create or update the view HUD element. + */ + const updateViewHud = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.view) return; + + var pageId = getHudPageId(); + if (!pageId) return; + + var page = getObj('page', pageId); + if (!page) return; + + // Determine display text + var label; + if (s.view === null) { + label = '🔴 RELAY OFF'; + } else if (s.view === 'master') { + label = '🟢 RELAY: ALL'; + } else { + // Resolve player name + var playerName = s.view; + Object.values(s.activeGroups).forEach(function(g) { + var entry = g.playerPages[s.view]; + if (entry && entry.name) playerName = entry.name; + }); + label = '🔵 VIEW: ' + playerName; + } + + // Find existing or create new + var existing = findHudElement('view'); + if (existing) { + existing.set('text', label); + } else { + // Use stored position/size or defaults + var pos = (s.hud.viewPos) || {}; + var pageWidth = page.get('width') * 70; + var obj = createObj('text', { + _pageid: pageId, + layer: 'foreground', + text: label, + left: pos.left || Math.round(pageWidth / 2), + top: pos.top || 100, + font_size: pos.font_size || 40, + color: '#ffffff', + font_family: 'Contrail One', + }); + s.hud.viewId = obj.get('id'); + } + }; + + /** + * Remove the view HUD element (programmatic). Clears ID first so destroy handler ignores it. + */ + const removeViewHud = () => { + var s = state[SCRIPT_NAME]; + var id = s.hud.viewId; + s.hud.viewId = null; + if (id) { + var obj = getObj('text', id); + if (obj) obj.remove(); + } + }; + + /** + * Handle change:text — persist HUD element position/size if moved by GM. + */ + const onHudTextChanged = (obj) => { + var s = state[SCRIPT_NAME]; + var id = obj.get('id'); + if (id === s.hud.viewId) { + s.hud.viewPos = { + left: obj.get('left'), + top: obj.get('top'), + font_size: obj.get('font_size'), + }; + } + }; + + /** + * Handle destroy:text — if a HUD element is deleted, treat as turning it off. + */ + const onHudTextDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + var id = obj.get('id'); + if (id === s.hud.viewId) { + s.hud.view = false; + s.hud.viewId = null; + sendChat(SCRIPT_NAME, '/w gm HUD: view is now off'); + } + }; + + /** + * Handle !gaslight hud command. + */ + const doHud = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + // Show current HUD state + var status = Object.entries(s.hud).filter(function(e) { return typeof e[1] === 'boolean'; }).map(function(e) { + return '' + e[0] + ': ' + (e[1] ? 'on' : 'off'); + }).join('
'); + reply(msg, 'HUD', status); + return; + } + + var element = args[0].toLowerCase(); + var toggle = args[1] ? args[1].toLowerCase() : null; + + if (s.hud[element] === undefined) { + reply(msg, 'Error', 'Unknown HUD element: ' + element + '. Available: ' + Object.keys(s.hud).filter(function(k) { return typeof s.hud[k] === 'boolean'; }).join(', ')); + return; + } + + if (toggle === 'on') { + s.hud[element] = true; + } else if (toggle === 'off') { + s.hud[element] = false; + } else if (toggle === 'reset') { + // Clear stored position, turn on, move to defaults + delete s.hud[element + 'Pos']; + s.hud[element] = true; + if (element === 'view') { + var existing = findHudElement('view'); + if (existing) { + var pageId = getHudPageId(); + var page = pageId ? getObj('page', pageId) : null; + var pageWidth = page ? page.get('width') * 70 : 1400; + existing.set({ left: Math.round(pageWidth / 2), top: 100, font_size: 40 }); + } else { + updateViewHud(); + } + } + reply(msg, 'HUD', '' + element + ' reset to default position.'); + return; + } else { + // Toggle + s.hud[element] = !s.hud[element]; + } + + // Apply + if (element === 'view') { + if (s.hud.view) updateViewHud(); + else removeViewHud(); + } + + reply(msg, 'HUD', '' + element + ' is now ' + (s.hud[element] ? 'on' : 'off')); + }; + const registerEventHandlers = () => { on('chat:message', handleInput); on('chat:message', viewInterceptor); @@ -3253,6 +3448,8 @@ var Gaslight = Gaslight || (() => { on('change:graphic', onGraphicPropChanged); on('change:graphic:gmnotes', onGmNotesChanged); on('change:campaign:turnorder', onTurnOrderChanged); + on('change:text', onHudTextChanged); + on('destroy:text', onHudTextDestroyed); }; return { checkInstall, registerEventHandlers }; From 6d092fff3202b5251f896f4d155eae9a65f03c04 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 06:29:37 -0400 Subject: [PATCH 14/41] Gaslight: initiative HUD v1 (frame + mirrored tokens + overflow hide) - Frame path on foreground layer with token entries - Mirror-linked to master tokens (syncs name, status, tint) - Current turn at top, overflow hidden via baseOpacity + showname - Updates on turnorder change - Delete any piece = toggle off - !gaslight hud initiative [on|off|reset] --- Gaslight/Gaslight.js | 281 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 278 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 98f283d12..9eafef670 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -81,11 +81,12 @@ var Gaslight = Gaslight || (() => { activeGroups: {}, config: { autoCommit: false, relayCommands: [] }, view: 'master', - hud: { view: false } + hud: { view: false, initiative: false } }; } if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; - if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: false }; + if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: false, initiative: false }; + if (state[SCRIPT_NAME].hud.initiative === undefined) state[SCRIPT_NAME].hud.initiative = false; // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; @@ -3210,6 +3211,9 @@ var Gaslight = Gaslight || (() => { Campaign().set('turnorder', finalJson); _suppressTurnSync = false; } + + // Update initiative HUD if enabled + if (state[SCRIPT_NAME].hud.initiative) updateInitiativeHud(); }; /** @@ -3350,6 +3354,228 @@ var Gaslight = Gaslight || (() => { } }; + // ---- Initiative HUD ---- + + const INIT_HUD_TOKEN_SIZE = 50; + const INIT_HUD_PADDING = 10; + + /** + * Get the deduped turn order (master tokens only, skip children). + */ + const getHudTurnOrder = () => { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + return order.filter(function(entry) { + if (!entry.id || entry.id === '-1') return true; + return isMasterToken(entry.id); + }); + }; + + /** + * Draw a rectangle path on the foreground layer. + */ + const createFramePath = (pageId, left, top, width, height) => { + var hw = width / 2, hh = height / 2; + var pathData = JSON.stringify([ + ['M', -hw, -hh], + ['L', hw, -hh], + ['L', hw, hh], + ['L', -hw, hh], + ['L', -hw, -hh] + ]); + return createObj('path', { + _pageid: pageId, + layer: 'foreground', + path: pathData, + left: left, + top: top, + width: width, + height: height, + stroke: '#ffffff', + stroke_width: 3, + fill: 'transparent', + scaleX: 1, + scaleY: 1, + }); + }; + + /** + * Build/rebuild the initiative HUD. + */ + const updateInitiativeHud = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative) return; + + var pageId = getHudPageId(); + if (!pageId) return; + + var page = getObj('page', pageId); + if (!page) return; + + var order = getHudTurnOrder(); + if (!s.hud.initData) { + s.hud.initData = { frameId: null, entries: [], pos: null, frameSize: null }; + } + var data = s.hud.initData; + + var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_PADDING; + + // Create frame if missing + if (!data.frameId || !getObj('path', data.frameId)) { + var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; + var frameHeight = (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * 6 + INIT_HUD_PADDING; + var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; + var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height); + data.frameId = frame.get('id'); + data.pos = pos; + data.frameSize = frameSize; + } + + var frame = getObj('path', data.frameId); + if (!frame) return; + + var frameLeft = frame.get('left'); + var frameTop = frame.get('top'); + var frameHeight = data.frameSize ? data.frameSize.height : frame.get('height'); + var frameTopEdge = frameTop - frameHeight / 2; + + // Remove old entries no longer in the order + var orderIds = order.map(function(e) { return e.id; }); + var toRemove = []; + data.entries.forEach(function(entry, i) { + if (orderIds.indexOf(entry.sourceId) === -1) toRemove.push(i); + }); + toRemove.reverse().forEach(function(i) { + var entry = data.entries[i]; + var tok = getObj('graphic', entry.tokenId); + var txt = getObj('text', entry.textId); + if (tok) { if (typeof Mirror !== 'undefined') Mirror.unlink([tok.get('id')]); tok.remove(); } + if (txt) txt.remove(); + data.entries.splice(i, 1); + }); + + // Add new entries not yet in the HUD + var existingSourceIds = data.entries.map(function(e) { return e.sourceId; }); + order.forEach(function(entry) { + if (!entry.id || entry.id === '-1') return; + if (existingSourceIds.indexOf(entry.id) !== -1) return; + + var sourceToken = getObj('graphic', entry.id); + if (!sourceToken) return; + + var hudToken = createObj('graphic', { + _pageid: pageId, + layer: 'foreground', + imgsrc: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), + left: frameLeft, + top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, + width: INIT_HUD_TOKEN_SIZE, + height: INIT_HUD_TOKEN_SIZE, + showname: true, + name: sourceToken.get('name'), + baseOpacity: 1, + }); + + var hudText = createObj('text', { + _pageid: pageId, + layer: 'foreground', + text: String(entry.pr || ''), + left: frameLeft + frameWidth / 2 + 15, + top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, + font_size: 16, + color: '#ffffff', + font_family: 'Contrail One', + }); + + // Mirror-link: sync name, status, tint + if (typeof Mirror !== 'undefined') { + Mirror.link([sourceToken.get('id'), hudToken.get('id')], ['name', 'statusmarkers', 'tint_color'], { soft: true }); + } + + data.entries.push({ + sourceId: entry.id, + tokenId: hudToken.get('id'), + textId: hudText.get('id'), + }); + }); + + reflowInitiativeHud(); + }; + + /** + * Reflow initiative HUD: current turn at top, overflow hidden. + */ + const reflowInitiativeHud = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + + var frame = getObj('path', data.frameId); + if (!frame) return; + + var order = getHudTurnOrder(); + var frameLeft = frame.get('left'); + var frameTop = frame.get('top'); + var frameHeight = data.frameSize ? data.frameSize.height : frame.get('height'); + var frameTopEdge = frameTop - frameHeight / 2; + var frameBotEdge = frameTop + frameHeight / 2; + var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_PADDING; + + order.forEach(function(entry, i) { + if (!entry.id || entry.id === '-1') return; + var hudEntry = data.entries.find(function(e) { return e.sourceId === entry.id; }); + if (!hudEntry) return; + + var tok = getObj('graphic', hudEntry.tokenId); + var txt = getObj('text', hudEntry.textId); + if (!tok) return; + + var yPos = frameTopEdge + INIT_HUD_PADDING + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * i + INIT_HUD_TOKEN_SIZE / 2; + + var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && + (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + + tok.set({ + left: frameLeft, + top: yPos, + baseOpacity: visible ? 1 : 0, + showname: visible, + }); + if (txt) { + txt.set({ + left: frameLeft + frameWidth / 2 + 15, + top: yPos, + color: visible ? '#ffffff' : 'transparent', + }); + } + }); + }; + + /** + * Remove the initiative HUD (programmatic). + */ + const removeInitiativeHud = () => { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData; + if (!data) return; + + var frameId = data.frameId; + var entries = data.entries.slice(); + // Clear IDs first so destroy handler ignores + data.frameId = null; + data.entries = []; + + if (frameId) { + var frame = getObj('path', frameId); + if (frame) frame.remove(); + } + entries.forEach(function(entry) { + var tok = getObj('graphic', entry.tokenId); + var txt = getObj('text', entry.textId); + if (tok) { if (typeof Mirror !== 'undefined') Mirror.unlink([tok.get('id')]); tok.remove(); } + if (txt) txt.remove(); + }); + }; + /** * Handle change:text — persist HUD element position/size if moved by GM. */ @@ -3376,6 +3602,44 @@ var Gaslight = Gaslight || (() => { s.hud.viewId = null; sendChat(SCRIPT_NAME, '/w gm HUD: view is now off'); } + // Initiative HUD text + if (s.hud.initData && s.hud.initData.entries) { + var match = s.hud.initData.entries.find(function(e) { return e.textId === id; }); + if (match) { + s.hud.initiative = false; + removeInitiativeHud(); + sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + } + } + }; + + /** + * Handle destroy:graphic — if an initiative HUD token is deleted, turn off. + */ + const onHudGraphicDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initData || !s.hud.initData.entries) return; + var id = obj.get('id'); + var match = s.hud.initData.entries.find(function(e) { return e.tokenId === id; }); + if (match) { + s.hud.initiative = false; + removeInitiativeHud(); + sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + } + }; + + /** + * Handle destroy:path — if the initiative frame is deleted, turn off. + */ + const onHudPathDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initData) return; + if (obj.get('id') === s.hud.initData.frameId) { + s.hud.initiative = false; + s.hud.initData.frameId = null; + removeInitiativeHud(); + sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + } }; /** @@ -3418,6 +3682,13 @@ var Gaslight = Gaslight || (() => { } else { updateViewHud(); } + } else if (element === 'initiative') { + if (s.hud.initData) { + s.hud.initData.pos = null; + s.hud.initData.frameSize = null; + } + removeInitiativeHud(); + updateInitiativeHud(); } reply(msg, 'HUD', '' + element + ' reset to default position.'); return; @@ -3430,6 +3701,9 @@ var Gaslight = Gaslight || (() => { if (element === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); + } else if (element === 'initiative') { + if (s.hud.initiative) updateInitiativeHud(); + else removeInitiativeHud(); } reply(msg, 'HUD', '' + element + ' is now ' + (s.hud[element] ? 'on' : 'off')); @@ -3443,13 +3717,14 @@ var Gaslight = Gaslight || (() => { }); registerWithRollCapture(); on('add:graphic', onTokenAdded); - on('destroy:graphic', onTokenDestroyed); on('change:attribute', onAttributeChanged); on('change:graphic', onGraphicPropChanged); on('change:graphic:gmnotes', onGmNotesChanged); on('change:campaign:turnorder', onTurnOrderChanged); on('change:text', onHudTextChanged); on('destroy:text', onHudTextDestroyed); + on('destroy:graphic', function(obj) { onTokenDestroyed(obj); onHudGraphicDestroyed(obj); }); + on('destroy:path', onHudPathDestroyed); }; return { checkInstall, registerEventHandlers }; From 656b24032f306bf951fb5cf8162bd1b6eb86d799 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 08:53:14 -0400 Subject: [PATCH 15/41] Gaslight: initiative HUD - custom turns (pins), overflow, pr-value matching - Increased padding to 30 for nameplate space - Custom turns rendered as pins (square, transparent, title for name) - Pins moved off-screen (-5000) when overflowing frame - Hybrid diff: tokens kept (lerp), pins count-diffed - Reflow updates initiative text values + pin titles - Custom entry matching by pr value (prevents text swaps) - Direction-aware reflow passed from turn order handler - Destroy handlers for pin/graphic/path/text --- Gaslight/Gaslight.js | 212 +++++++++++++++++++++++++++++++------------ 1 file changed, 154 insertions(+), 58 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 9eafef670..4c1a359d4 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3213,7 +3213,14 @@ var Gaslight = Gaslight || (() => { } // Update initiative HUD if enabled - if (state[SCRIPT_NAME].hud.initiative) updateInitiativeHud(); + if (state[SCRIPT_NAME].hud.initiative) { + var hudDirection = 'none'; + if (oldOrder.length > 0 && newOrder.length > 0) { + if (newOrder[newOrder.length - 1].id === oldOrder[0].id) hudDirection = 'forward'; + else if (newOrder[0].id === oldOrder[oldOrder.length - 1].id) hudDirection = 'backward'; + } + updateInitiativeHud(hudDirection); + } }; /** @@ -3357,7 +3364,7 @@ var Gaslight = Gaslight || (() => { // ---- Initiative HUD ---- const INIT_HUD_TOKEN_SIZE = 50; - const INIT_HUD_PADDING = 10; + const INIT_HUD_PADDING = 30; /** * Get the deduped turn order (master tokens only, skip children). @@ -3401,7 +3408,7 @@ var Gaslight = Gaslight || (() => { /** * Build/rebuild the initiative HUD. */ - const updateInitiativeHud = () => { + const updateInitiativeHud = (direction) => { var s = state[SCRIPT_NAME]; if (!s.hud.initiative) return; @@ -3439,72 +3446,117 @@ var Gaslight = Gaslight || (() => { var frameTopEdge = frameTop - frameHeight / 2; // Remove old entries no longer in the order - var orderIds = order.map(function(e) { return e.id; }); + // For tokens: match by ID. For customs: if order has fewer customs than we have, remove extras. + var orderTokenIds = order.filter(function(e) { return e.id && e.id !== '-1'; }).map(function(e) { return e.id; }); + var orderCustomCount = order.filter(function(e) { return !e.id || e.id === '-1'; }).length; + var currentCustomCount = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).length; + var toRemove = []; + var customsToRemove = currentCustomCount - orderCustomCount; data.entries.forEach(function(entry, i) { - if (orderIds.indexOf(entry.sourceId) === -1) toRemove.push(i); + if (entry.sourceId && entry.sourceId.startsWith('custom:')) { + if (customsToRemove > 0) { toRemove.push(i); customsToRemove--; } + } else if (orderTokenIds.indexOf(entry.sourceId) === -1) { + toRemove.push(i); + } }); toRemove.reverse().forEach(function(i) { var entry = data.entries[i]; - var tok = getObj('graphic', entry.tokenId); + var tok = getObj('graphic', entry.tokenId) || getObj('pin', entry.tokenId); var txt = getObj('text', entry.textId); - if (tok) { if (typeof Mirror !== 'undefined') Mirror.unlink([tok.get('id')]); tok.remove(); } - if (txt) txt.remove(); + // Splice first so destroy handler ignores data.entries.splice(i, 1); + if (tok) { if (typeof Mirror !== 'undefined' && entry.sourceId && !entry.sourceId.startsWith('custom:')) Mirror.unlink([tok.get('id')]); tok.remove(); } + if (txt) txt.remove(); }); // Add new entries not yet in the HUD - var existingSourceIds = data.entries.map(function(e) { return e.sourceId; }); + var existingTokenIds = data.entries.filter(function(e) { return !e.sourceId.startsWith('custom:'); }).map(function(e) { return e.sourceId; }); + var customsToAdd = orderCustomCount - data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).length; + order.forEach(function(entry) { - if (!entry.id || entry.id === '-1') return; - if (existingSourceIds.indexOf(entry.id) !== -1) return; + var isCustom = !entry.id || entry.id === '-1'; + + if (isCustom) { + if (customsToAdd <= 0) return; + customsToAdd--; + + var hudPin = createObj('pin', { + _pageid: pageId, + x: frameLeft, + y: -5000, + title: entry.custom || 'Custom', + shape: 'square', + bgColor: 'transparent', + useTextIcon: true, + textIcon: '', + }); - var sourceToken = getObj('graphic', entry.id); - if (!sourceToken) return; + var pinText = createObj('text', { + _pageid: pageId, + layer: 'foreground', + text: String(entry.pr || ''), + left: -5000, + top: -5000, + font_size: 16, + color: '#ffffff', + font_family: 'Contrail One', + }); - var hudToken = createObj('graphic', { - _pageid: pageId, - layer: 'foreground', - imgsrc: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), - left: frameLeft, - top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, - width: INIT_HUD_TOKEN_SIZE, - height: INIT_HUD_TOKEN_SIZE, - showname: true, - name: sourceToken.get('name'), - baseOpacity: 1, - }); + data.entries.push({ + sourceId: 'custom:' + Date.now() + ':' + Math.random().toString(36).slice(2, 6), + tokenId: hudPin ? hudPin.get('id') : null, + textId: pinText.get('id'), + }); + } else { + if (existingTokenIds.indexOf(entry.id) !== -1) return; + + var sourceToken = getObj('graphic', entry.id); + if (!sourceToken) return; + + var hudToken = createObj('graphic', { + _pageid: pageId, + layer: 'foreground', + imgsrc: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), + left: frameLeft, + top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, + width: INIT_HUD_TOKEN_SIZE, + height: INIT_HUD_TOKEN_SIZE, + showname: true, + name: sourceToken.get('name'), + baseOpacity: 1, + }); - var hudText = createObj('text', { - _pageid: pageId, - layer: 'foreground', - text: String(entry.pr || ''), - left: frameLeft + frameWidth / 2 + 15, - top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, - font_size: 16, - color: '#ffffff', - font_family: 'Contrail One', - }); + var hudText = createObj('text', { + _pageid: pageId, + layer: 'foreground', + text: String(entry.pr || ''), + left: frameLeft + frameWidth / 2 + 15, + top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, + font_size: 16, + color: '#ffffff', + font_family: 'Contrail One', + }); - // Mirror-link: sync name, status, tint - if (typeof Mirror !== 'undefined') { - Mirror.link([sourceToken.get('id'), hudToken.get('id')], ['name', 'statusmarkers', 'tint_color'], { soft: true }); - } + if (typeof Mirror !== 'undefined') { + Mirror.link([sourceToken.get('id'), hudToken.get('id')], ['name', 'statusmarkers', 'tint_color'], { soft: true }); + } - data.entries.push({ - sourceId: entry.id, - tokenId: hudToken.get('id'), - textId: hudText.get('id'), - }); + data.entries.push({ + sourceId: entry.id, + tokenId: hudToken.get('id'), + textId: hudText.get('id'), + }); + } }); - reflowInitiativeHud(); + reflowInitiativeHud(direction); }; /** * Reflow initiative HUD: current turn at top, overflow hidden. */ - const reflowInitiativeHud = () => { + const reflowInitiativeHud = (direction) => { var s = state[SCRIPT_NAME]; if (!s.hud.initiative || !s.hud.initData) return; var data = s.hud.initData; @@ -3520,31 +3572,74 @@ var Gaslight = Gaslight || (() => { var frameBotEdge = frameTop + frameHeight / 2; var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_PADDING; + // Match order entries to HUD entries: tokens by ID, customs sorted by current position + var tokenMap = {}; + data.entries.forEach(function(e) { + if (e.sourceId && !e.sourceId.startsWith('custom:')) tokenMap[e.sourceId] = e; + }); + var customEntries = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + + // Sort customs by current Y position so they slide in the right direction + // Separate visible from hidden (off-screen at -5000) + var visibleCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return obj && obj.get('y') > -1000; + }); + var hiddenCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return !obj || obj.get('y') <= -1000; + }); + // Match visible customs to order slots by their current pr value + var sortedCustoms = []; + var usedVisible = new Set(); + var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + customOrderEntries.forEach(function(orderEntry) { + var prVal = String(orderEntry.pr || ''); + var match = visibleCustoms.findIndex(function(e, i) { + if (usedVisible.has(i)) return false; + var txt = getObj('text', e.textId); + return txt && txt.get('text') === prVal; + }); + if (match !== -1) { + sortedCustoms.push(visibleCustoms[match]); + usedVisible.add(match); + } else if (hiddenCustoms.length > 0) { + sortedCustoms.push(hiddenCustoms.shift()); + } + }); + // Any remaining unmatched visible customs + visibleCustoms.forEach(function(e, i) { + if (!usedVisible.has(i)) sortedCustoms.push(e); + }); + sortedCustoms = sortedCustoms.concat(hiddenCustoms); + var customIdx = 0; + order.forEach(function(entry, i) { - if (!entry.id || entry.id === '-1') return; - var hudEntry = data.entries.find(function(e) { return e.sourceId === entry.id; }); + var isCustom = !entry.id || entry.id === '-1'; + var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; if (!hudEntry) return; - var tok = getObj('graphic', hudEntry.tokenId); + var tok = getObj('graphic', hudEntry.tokenId) || getObj('pin', hudEntry.tokenId); var txt = getObj('text', hudEntry.textId); if (!tok) return; var yPos = frameTopEdge + INIT_HUD_PADDING + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * i + INIT_HUD_TOKEN_SIZE / 2; - var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); - tok.set({ - left: frameLeft, - top: yPos, - baseOpacity: visible ? 1 : 0, - showname: visible, - }); + if (tok.get('type') === 'graphic') { + tok.set({ left: frameLeft, top: yPos, baseOpacity: visible ? 1 : 0, showname: visible }); + } else { + // Pin: move off-screen if not visible, update title + tok.set({ x: visible ? frameLeft : -5000, y: visible ? yPos : -5000, title: entry.custom || 'Custom' }); + } + if (txt) { txt.set({ left: frameLeft + frameWidth / 2 + 15, top: yPos, color: visible ? '#ffffff' : 'transparent', + text: String(entry.pr || ''), }); } }); @@ -3569,9 +3664,9 @@ var Gaslight = Gaslight || (() => { if (frame) frame.remove(); } entries.forEach(function(entry) { - var tok = getObj('graphic', entry.tokenId); + var tok = getObj('graphic', entry.tokenId) || getObj('pin', entry.tokenId); var txt = getObj('text', entry.textId); - if (tok) { if (typeof Mirror !== 'undefined') Mirror.unlink([tok.get('id')]); tok.remove(); } + if (tok) { if (typeof Mirror !== 'undefined' && entry.sourceId && !entry.sourceId.startsWith('custom:')) Mirror.unlink([tok.get('id')]); tok.remove(); } if (txt) txt.remove(); }); }; @@ -3725,6 +3820,7 @@ var Gaslight = Gaslight || (() => { on('destroy:text', onHudTextDestroyed); on('destroy:graphic', function(obj) { onTokenDestroyed(obj); onHudGraphicDestroyed(obj); }); on('destroy:path', onHudPathDestroyed); + on('destroy:pin', onHudGraphicDestroyed); }; return { checkInstall, registerEventHandlers }; From 28b38cff2ebb077ad5273fc14be3c5f318b6bc16 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 08:58:34 -0400 Subject: [PATCH 16/41] Gaslight: initiative HUD - direction-based shift for custom pins - Forward/backward: shift all custom pin/text by one slot (no matching needed) - Full reflow (sort/add/remove): pr-value matching fallback - Much simpler, eliminates text swap glitch on turn advance --- Gaslight/Gaslight.js | 158 +++++++++++++++++++++++++++---------------- 1 file changed, 101 insertions(+), 57 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 4c1a359d4..4e16785c0 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3572,77 +3572,121 @@ var Gaslight = Gaslight || (() => { var frameBotEdge = frameTop + frameHeight / 2; var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_PADDING; - // Match order entries to HUD entries: tokens by ID, customs sorted by current position + // Match order entries to HUD entries var tokenMap = {}; data.entries.forEach(function(e) { if (e.sourceId && !e.sourceId.startsWith('custom:')) tokenMap[e.sourceId] = e; }); var customEntries = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); - // Sort customs by current Y position so they slide in the right direction - // Separate visible from hidden (off-screen at -5000) - var visibleCustoms = customEntries.filter(function(e) { - var obj = getObj('pin', e.tokenId); - return obj && obj.get('y') > -1000; - }); - var hiddenCustoms = customEntries.filter(function(e) { - var obj = getObj('pin', e.tokenId); - return !obj || obj.get('y') <= -1000; - }); - // Match visible customs to order slots by their current pr value - var sortedCustoms = []; - var usedVisible = new Set(); - var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); - customOrderEntries.forEach(function(orderEntry) { - var prVal = String(orderEntry.pr || ''); - var match = visibleCustoms.findIndex(function(e, i) { - if (usedVisible.has(i)) return false; + if (direction === 'forward' || direction === 'backward') { + // Simple shift: move all custom pins/text by one slot + var shift = direction === 'forward' + ? -(INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) + : (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING); + customEntries.forEach(function(e) { + var pin = getObj('pin', e.tokenId); var txt = getObj('text', e.textId); - return txt && txt.get('text') === prVal; + if (pin) { + var newY = pin.get('y') + shift; + var visible = (newY - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && + (newY + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + pin.set({ x: visible ? frameLeft : -5000, y: visible ? newY : -5000 }); + } + if (txt) { + var newTop = txt.get('top') + shift; + var visible = (newTop - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && + (newTop + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + txt.set({ top: newTop, color: visible ? '#ffffff' : 'transparent' }); + } }); - if (match !== -1) { - sortedCustoms.push(visibleCustoms[match]); - usedVisible.add(match); - } else if (hiddenCustoms.length > 0) { - sortedCustoms.push(hiddenCustoms.shift()); - } - }); - // Any remaining unmatched visible customs - visibleCustoms.forEach(function(e, i) { - if (!usedVisible.has(i)) sortedCustoms.push(e); - }); - sortedCustoms = sortedCustoms.concat(hiddenCustoms); - var customIdx = 0; - order.forEach(function(entry, i) { - var isCustom = !entry.id || entry.id === '-1'; - var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; - if (!hudEntry) return; + // Reflow only token entries by ID + order.forEach(function(entry, i) { + if (!entry.id || entry.id === '-1') return; + var hudEntry = tokenMap[entry.id]; + if (!hudEntry) return; - var tok = getObj('graphic', hudEntry.tokenId) || getObj('pin', hudEntry.tokenId); - var txt = getObj('text', hudEntry.textId); - if (!tok) return; + var tok = getObj('graphic', hudEntry.tokenId); + var txt = getObj('text', hudEntry.textId); + if (!tok) return; - var yPos = frameTopEdge + INIT_HUD_PADDING + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * i + INIT_HUD_TOKEN_SIZE / 2; - var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && - (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + var yPos = frameTopEdge + INIT_HUD_PADDING + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * i + INIT_HUD_TOKEN_SIZE / 2; + var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && + (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); - if (tok.get('type') === 'graphic') { tok.set({ left: frameLeft, top: yPos, baseOpacity: visible ? 1 : 0, showname: visible }); - } else { - // Pin: move off-screen if not visible, update title - tok.set({ x: visible ? frameLeft : -5000, y: visible ? yPos : -5000, title: entry.custom || 'Custom' }); - } - - if (txt) { - txt.set({ - left: frameLeft + frameWidth / 2 + 15, - top: yPos, - color: visible ? '#ffffff' : 'transparent', - text: String(entry.pr || ''), + if (txt) { + txt.set({ + left: frameLeft + frameWidth / 2 + 15, + top: yPos, + color: visible ? '#ffffff' : 'transparent', + text: String(entry.pr || ''), + }); + } + }); + } else { + // Full reflow (sort/add/remove): match customs by pr value + var visibleCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return obj && obj.get('y') > -1000; + }); + var hiddenCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return !obj || obj.get('y') <= -1000; + }); + var sortedCustoms = []; + var usedVisible = new Set(); + var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + customOrderEntries.forEach(function(orderEntry) { + var prVal = String(orderEntry.pr || ''); + var match = visibleCustoms.findIndex(function(e, i) { + if (usedVisible.has(i)) return false; + var txt = getObj('text', e.textId); + return txt && txt.get('text') === prVal; }); - } - }); + if (match !== -1) { + sortedCustoms.push(visibleCustoms[match]); + usedVisible.add(match); + } else if (hiddenCustoms.length > 0) { + sortedCustoms.push(hiddenCustoms.shift()); + } + }); + visibleCustoms.forEach(function(e, i) { + if (!usedVisible.has(i)) sortedCustoms.push(e); + }); + sortedCustoms = sortedCustoms.concat(hiddenCustoms); + var customIdx = 0; + + order.forEach(function(entry, i) { + var isCustom = !entry.id || entry.id === '-1'; + var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; + if (!hudEntry) return; + + var tok = getObj('graphic', hudEntry.tokenId) || getObj('pin', hudEntry.tokenId); + var txt = getObj('text', hudEntry.textId); + if (!tok) return; + + var yPos = frameTopEdge + INIT_HUD_PADDING + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * i + INIT_HUD_TOKEN_SIZE / 2; + var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && + (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + + if (tok.get('type') === 'graphic') { + tok.set({ left: frameLeft, top: yPos, baseOpacity: visible ? 1 : 0, showname: visible }); + } else { + tok.set({ x: visible ? frameLeft : -5000, y: visible ? yPos : -5000, title: entry.custom || 'Custom' }); + } + + if (txt) { + txt.set({ + left: frameLeft + frameWidth / 2 + 15, + top: yPos, + color: visible ? '#ffffff' : 'transparent', + text: String(entry.pr || ''), + }); + } + }); + } }; /** From 2e6a7666467d7013e39dfa9515eabfd62f1861a6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 11:24:01 -0400 Subject: [PATCH 17/41] Gaslight: initiative HUD polish - pathv2 frame, pin positioning, sizing - Switch frame from path to pathv2 (rec shape, two-point format) - Fix frame dimension reading (x/y + points) - Pin Y offset helper (hudPinY) for bottom-middle anchor - hudSlotY helper for consistent position calculation - INIT_HUD_EDGE_PADDING (15px) for top/bottom frame inset - INIT_HUD_H_PADDING (10px) for frame width - Default frame fits 5 tokens (5*50 + 4*30 + 2*15 = 400px) - Pin shape: circle, scale: 2.0 - Fix double-offset bug in direction shift --- Gaslight/Gaslight.js | 99 ++++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 4e16785c0..9ac1a92a0 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3365,6 +3365,20 @@ var Gaslight = Gaslight || (() => { const INIT_HUD_TOKEN_SIZE = 50; const INIT_HUD_PADDING = 30; + const INIT_HUD_EDGE_PADDING = 15; + const INIT_HUD_H_PADDING = 10; + + /** + * Calculate Y position for a HUD entry at a given slot index. + * First token's top edge sits at 5px from frame top edge. + * For pins, add INIT_HUD_TOKEN_SIZE/2 since their anchor is bottom-middle. + */ + const hudSlotY = (frameTopEdge, index) => { + return frameTopEdge + INIT_HUD_TOKEN_SIZE / 2 + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * index; + }; + const hudPinY = (frameTopEdge, index) => { + return hudSlotY(frameTopEdge, index) + INIT_HUD_TOKEN_SIZE / 2; + }; /** * Get the deduped turn order (master tokens only, skip children). @@ -3381,27 +3395,16 @@ var Gaslight = Gaslight || (() => { * Draw a rectangle path on the foreground layer. */ const createFramePath = (pageId, left, top, width, height) => { - var hw = width / 2, hh = height / 2; - var pathData = JSON.stringify([ - ['M', -hw, -hh], - ['L', hw, -hh], - ['L', hw, hh], - ['L', -hw, hh], - ['L', -hw, -hh] - ]); - return createObj('path', { + return createObj('pathv2', { _pageid: pageId, layer: 'foreground', - path: pathData, - left: left, - top: top, - width: width, - height: height, + shape: 'rec', + x: left, + y: top, + points: JSON.stringify([[0, 0], [width, height]]), stroke: '#ffffff', stroke_width: 3, fill: 'transparent', - scaleX: 1, - scaleY: 1, }); }; @@ -3424,12 +3427,12 @@ var Gaslight = Gaslight || (() => { } var data = s.hud.initData; - var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_PADDING; + var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_H_PADDING; // Create frame if missing - if (!data.frameId || !getObj('path', data.frameId)) { + if (!data.frameId || !getObj('pathv2', data.frameId)) { var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; - var frameHeight = (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * 6 + INIT_HUD_PADDING; + var frameHeight = 5 * INIT_HUD_TOKEN_SIZE + 4 * INIT_HUD_PADDING + 2 * INIT_HUD_EDGE_PADDING; var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height); data.frameId = frame.get('id'); @@ -3437,12 +3440,13 @@ var Gaslight = Gaslight || (() => { data.frameSize = frameSize; } - var frame = getObj('path', data.frameId); + var frame = getObj('pathv2', data.frameId); if (!frame) return; - var frameLeft = frame.get('left'); - var frameTop = frame.get('top'); - var frameHeight = data.frameSize ? data.frameSize.height : frame.get('height'); + var frameLeft = frame.get('x'); + var frameTop = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; var frameTopEdge = frameTop - frameHeight / 2; // Remove old entries no longer in the order @@ -3486,10 +3490,11 @@ var Gaslight = Gaslight || (() => { x: frameLeft, y: -5000, title: entry.custom || 'Custom', - shape: 'square', + shape: 'circle', bgColor: 'transparent', useTextIcon: true, textIcon: '', + scale: 2.0, }); var pinText = createObj('text', { @@ -3561,16 +3566,17 @@ var Gaslight = Gaslight || (() => { if (!s.hud.initiative || !s.hud.initData) return; var data = s.hud.initData; - var frame = getObj('path', data.frameId); + var frame = getObj('pathv2', data.frameId); if (!frame) return; var order = getHudTurnOrder(); - var frameLeft = frame.get('left'); - var frameTop = frame.get('top'); - var frameHeight = data.frameSize ? data.frameSize.height : frame.get('height'); - var frameTopEdge = frameTop - frameHeight / 2; - var frameBotEdge = frameTop + frameHeight / 2; - var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_PADDING; + var frameLeft = frame.get('x'); + var frameTop = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; + var frameTopEdge = frameTop - frameHeight / 2 + INIT_HUD_EDGE_PADDING; + var frameBotEdge = frameTop + frameHeight / 2 - INIT_HUD_EDGE_PADDING; + var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_H_PADDING; // Match order entries to HUD entries var tokenMap = {}; @@ -3588,15 +3594,16 @@ var Gaslight = Gaslight || (() => { var pin = getObj('pin', e.tokenId); var txt = getObj('text', e.textId); if (pin) { - var newY = pin.get('y') + shift; - var visible = (newY - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && - (newY + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); - pin.set({ x: visible ? frameLeft : -5000, y: visible ? newY : -5000 }); + var newPinY = pin.get('y') + shift; + var slotY = newPinY - INIT_HUD_TOKEN_SIZE / 2; // slot center (for visibility check) + var visible = (slotY - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && + (slotY + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); + pin.set({ x: visible ? frameLeft : -5000, y: visible ? newPinY : -5000 }); } if (txt) { var newTop = txt.get('top') + shift; - var visible = (newTop - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && - (newTop + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + var visible = (newTop - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && + (newTop + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); txt.set({ top: newTop, color: visible ? '#ffffff' : 'transparent' }); } }); @@ -3611,9 +3618,9 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = frameTopEdge + INIT_HUD_PADDING + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * i + INIT_HUD_TOKEN_SIZE / 2; - var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && - (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + var yPos = hudSlotY(frameTopEdge, i); + var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && + (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); tok.set({ left: frameLeft, top: yPos, baseOpacity: visible ? 1 : 0, showname: visible }); if (txt) { @@ -3667,14 +3674,14 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = frameTopEdge + INIT_HUD_PADDING + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * i + INIT_HUD_TOKEN_SIZE / 2; - var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge - 5) && - (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge + 5); + var yPos = hudSlotY(frameTopEdge, i); + var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && + (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); if (tok.get('type') === 'graphic') { tok.set({ left: frameLeft, top: yPos, baseOpacity: visible ? 1 : 0, showname: visible }); } else { - tok.set({ x: visible ? frameLeft : -5000, y: visible ? yPos : -5000, title: entry.custom || 'Custom' }); + tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameTopEdge, i) : -5000, title: entry.custom || 'Custom' }); } if (txt) { @@ -3704,7 +3711,7 @@ var Gaslight = Gaslight || (() => { data.entries = []; if (frameId) { - var frame = getObj('path', frameId); + var frame = getObj('pathv2', frameId); if (frame) frame.remove(); } entries.forEach(function(entry) { @@ -3863,7 +3870,7 @@ var Gaslight = Gaslight || (() => { on('change:text', onHudTextChanged); on('destroy:text', onHudTextDestroyed); on('destroy:graphic', function(obj) { onTokenDestroyed(obj); onHudGraphicDestroyed(obj); }); - on('destroy:path', onHudPathDestroyed); + on('destroy:pathv2', onHudPathDestroyed); on('destroy:pin', onHudGraphicDestroyed); }; From 27bfeb3e1267f2bdbb0a2e97c366365e8be3f919 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 12:20:03 -0400 Subject: [PATCH 18/41] Gaslight: initiative HUD - fix custom pin/text wrap + direction detection - Rotation-based direction detection (anchor on first non-custom token) - Detect direction BEFORE skip/reorder modifies newOrder - Custom text wraps circularly: min wraps to max position (forward), max wraps to min (backward) - Text position is source of truth for pin visibility - Fixes custom pin/text disappearing and not coming back --- Gaslight/Gaslight.js | 69 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 9ac1a92a0..4edf04add 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3088,6 +3088,31 @@ var Gaslight = Gaslight || (() => { var newOrder = JSON.parse(obj.get('turnorder') || '[]'); var oldOrder = JSON.parse(prev.turnorder || '[]'); + // Detect direction early (before skip/reorder modifies newOrder) + var hudDirection = 'none'; + if (oldOrder.length === newOrder.length && newOrder.length > 0) { + var targetEntry = newOrder.find(function(e) { return e.id && e.id !== '-1'; }); + if (targetEntry) { + var newIdx = newOrder.indexOf(targetEntry); + var oldIdx = -1; + for (var oi = 0; oi < oldOrder.length; oi++) { + if (oldOrder[oi].id === targetEntry.id) { oldIdx = oi; break; } + } + if (oldIdx !== -1 && oldIdx !== newIdx) { + var rotAmount = oldIdx - newIdx; + var len = newOrder.length; + // Normalize rotation + rotAmount = ((rotAmount % len) + len) % len; + var rotated = newOrder.slice(len - rotAmount).concat(newOrder.slice(0, len - rotAmount)); + var oldIdStr = oldOrder.map(function(e) { return e.id; }).join(','); + var rotIdStr = rotated.map(function(e) { return e.id; }).join(','); + if (rotIdStr === oldIdStr) { + hudDirection = rotAmount <= len / 2 ? 'forward' : 'backward'; + } + } + } + } + // Detect additions: entries in newOrder not in oldOrder var oldIds = new Set(oldOrder.map(function(e) { return e.id; })); var newIds = new Set(newOrder.map(function(e) { return e.id; })); @@ -3214,11 +3239,6 @@ var Gaslight = Gaslight || (() => { // Update initiative HUD if enabled if (state[SCRIPT_NAME].hud.initiative) { - var hudDirection = 'none'; - if (oldOrder.length > 0 && newOrder.length > 0) { - if (newOrder[newOrder.length - 1].id === oldOrder[0].id) hudDirection = 'forward'; - else if (newOrder[0].id === oldOrder[oldOrder.length - 1].id) hudDirection = 'backward'; - } updateInitiativeHud(hudDirection); } }; @@ -3586,26 +3606,43 @@ var Gaslight = Gaslight || (() => { var customEntries = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); if (direction === 'forward' || direction === 'backward') { - // Simple shift: move all custom pins/text by one slot + // Simple shift: move all custom pins/text by one slot, wrap at edges var shift = direction === 'forward' ? -(INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) : (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING); + + // Gather all HUD text Y positions (tokens + customs) + var allTextYs = data.entries.map(function(e) { + var t = getObj('text', e.textId); + return t ? t.get('top') : null; + }).filter(function(y) { return y !== null; }); + var minTextY = Math.min.apply(null, allTextYs); + var maxTextY = Math.max.apply(null, allTextYs); + customEntries.forEach(function(e) { var pin = getObj('pin', e.tokenId); var txt = getObj('text', e.textId); + if (!txt) return; + var currentY = txt.get('top'); + var newSlotY; + + if (direction === 'forward' && currentY === minTextY) { + // Wrapping: place at where the current max will be after shift + newSlotY = maxTextY; + } else if (direction === 'backward' && currentY === maxTextY) { + // Wrapping: place at where the current min will be after shift + newSlotY = minTextY; + } else { + newSlotY = currentY + shift; + } + + var visible = (newSlotY - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && + (newSlotY + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); + var newPinY = newSlotY + INIT_HUD_TOKEN_SIZE / 2; if (pin) { - var newPinY = pin.get('y') + shift; - var slotY = newPinY - INIT_HUD_TOKEN_SIZE / 2; // slot center (for visibility check) - var visible = (slotY - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && - (slotY + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); pin.set({ x: visible ? frameLeft : -5000, y: visible ? newPinY : -5000 }); } - if (txt) { - var newTop = txt.get('top') + shift; - var visible = (newTop - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && - (newTop + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); - txt.set({ top: newTop, color: visible ? '#ffffff' : 'transparent' }); - } + txt.set({ top: newSlotY, color: visible ? '#ffffff' : 'transparent' }); }); // Reflow only token entries by ID From 66f101fee0f896480bf2f0a71dd03116abcd201a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 12:48:20 -0400 Subject: [PATCH 19/41] Gaslight: initiative HUD - dynamic token sizing + frame resize behavior - Token size derived from frame width (frameWidth - 2*hPadding) - Frame resize narrower: reduce hPadding first (min 5), then shrink tokens - Frame resize wider: increase hPadding - Token resize: frame width adjusts to fit (hPadding preserved) - Frame move/resize persists to state - Tokens resize during reflow (width/height set) - Rename: INIT_HUD_PADDING -> INIT_HUD_TOKEN_PADDING, INIT_HUD_EDGE_PADDING -> INIT_HUD_V_PADDING --- Gaslight/Gaslight.js | 138 +++++++++++++++++++++++++++++++++---------- 1 file changed, 108 insertions(+), 30 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 4edf04add..be3d3b2d7 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3384,20 +3384,31 @@ var Gaslight = Gaslight || (() => { // ---- Initiative HUD ---- const INIT_HUD_TOKEN_SIZE = 50; - const INIT_HUD_PADDING = 30; - const INIT_HUD_EDGE_PADDING = 15; + const INIT_HUD_TOKEN_PADDING = 30; + const INIT_HUD_V_PADDING = 15; const INIT_HUD_H_PADDING = 10; /** * Calculate Y position for a HUD entry at a given slot index. - * First token's top edge sits at 5px from frame top edge. - * For pins, add INIT_HUD_TOKEN_SIZE/2 since their anchor is bottom-middle. + * For pins, add tokenSize/2 since their anchor is bottom-middle. */ - const hudSlotY = (frameTopEdge, index) => { - return frameTopEdge + INIT_HUD_TOKEN_SIZE / 2 + (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) * index; + const hudSlotY = (frameTopEdge, index, tokenSize) => { + return frameTopEdge + tokenSize / 2 + (tokenSize + INIT_HUD_TOKEN_PADDING) * index; }; - const hudPinY = (frameTopEdge, index) => { - return hudSlotY(frameTopEdge, index) + INIT_HUD_TOKEN_SIZE / 2; + const hudPinY = (frameTopEdge, index, tokenSize) => { + return hudSlotY(frameTopEdge, index, tokenSize) + tokenSize / 2; + }; + + /** + * Compute token size from frame width or stored override. + */ + const getHudTokenSize = (frame) => { + var s = state[SCRIPT_NAME]; + if (s.hud.initData && s.hud.initData.tokenSize) return s.hud.initData.tokenSize; + var points = JSON.parse(frame.get('points') || '[]'); + var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_H_PADDING; + var hPad = (s.hud.initData && s.hud.initData.hPadding) || INIT_HUD_H_PADDING; + return Math.max(10, frameWidth - 2 * hPad); }; /** @@ -3452,7 +3463,7 @@ var Gaslight = Gaslight || (() => { // Create frame if missing if (!data.frameId || !getObj('pathv2', data.frameId)) { var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; - var frameHeight = 5 * INIT_HUD_TOKEN_SIZE + 4 * INIT_HUD_PADDING + 2 * INIT_HUD_EDGE_PADDING; + var frameHeight = 5 * INIT_HUD_TOKEN_SIZE + 4 * INIT_HUD_TOKEN_PADDING + 2 * INIT_HUD_V_PADDING; var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height); data.frameId = frame.get('id'); @@ -3463,6 +3474,7 @@ var Gaslight = Gaslight || (() => { var frame = getObj('pathv2', data.frameId); if (!frame) return; + var tokenSize = getHudTokenSize(frame); var frameLeft = frame.get('x'); var frameTop = frame.get('y'); var points = JSON.parse(frame.get('points') || '[]'); @@ -3544,9 +3556,9 @@ var Gaslight = Gaslight || (() => { layer: 'foreground', imgsrc: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), left: frameLeft, - top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, - width: INIT_HUD_TOKEN_SIZE, - height: INIT_HUD_TOKEN_SIZE, + top: frameTopEdge + INIT_HUD_TOKEN_PADDING + tokenSize / 2, + width: tokenSize, + height: tokenSize, showname: true, name: sourceToken.get('name'), baseOpacity: 1, @@ -3557,7 +3569,7 @@ var Gaslight = Gaslight || (() => { layer: 'foreground', text: String(entry.pr || ''), left: frameLeft + frameWidth / 2 + 15, - top: frameTopEdge + INIT_HUD_PADDING + INIT_HUD_TOKEN_SIZE / 2, + top: frameTopEdge + INIT_HUD_TOKEN_PADDING + tokenSize / 2, font_size: 16, color: '#ffffff', font_family: 'Contrail One', @@ -3594,9 +3606,10 @@ var Gaslight = Gaslight || (() => { var frameTop = frame.get('y'); var points = JSON.parse(frame.get('points') || '[]'); var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; - var frameTopEdge = frameTop - frameHeight / 2 + INIT_HUD_EDGE_PADDING; - var frameBotEdge = frameTop + frameHeight / 2 - INIT_HUD_EDGE_PADDING; - var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_H_PADDING; + var frameTopEdge = frameTop - frameHeight / 2 + INIT_HUD_V_PADDING; + var frameBotEdge = frameTop + frameHeight / 2 - INIT_HUD_V_PADDING; + var tokenSize = getHudTokenSize(frame); + var frameWidth = tokenSize + 2 * INIT_HUD_H_PADDING; // Match order entries to HUD entries var tokenMap = {}; @@ -3608,8 +3621,8 @@ var Gaslight = Gaslight || (() => { if (direction === 'forward' || direction === 'backward') { // Simple shift: move all custom pins/text by one slot, wrap at edges var shift = direction === 'forward' - ? -(INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING) - : (INIT_HUD_TOKEN_SIZE + INIT_HUD_PADDING); + ? -(tokenSize + INIT_HUD_TOKEN_PADDING) + : (tokenSize + INIT_HUD_TOKEN_PADDING); // Gather all HUD text Y positions (tokens + customs) var allTextYs = data.entries.map(function(e) { @@ -3636,9 +3649,9 @@ var Gaslight = Gaslight || (() => { newSlotY = currentY + shift; } - var visible = (newSlotY - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && - (newSlotY + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); - var newPinY = newSlotY + INIT_HUD_TOKEN_SIZE / 2; + var visible = (newSlotY - tokenSize / 2 >= frameTopEdge) && + (newSlotY + tokenSize / 2 <= frameBotEdge); + var newPinY = newSlotY + tokenSize / 2; if (pin) { pin.set({ x: visible ? frameLeft : -5000, y: visible ? newPinY : -5000 }); } @@ -3655,11 +3668,11 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = hudSlotY(frameTopEdge, i); - var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && - (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); + var yPos = hudSlotY(frameTopEdge, i, tokenSize); + var visible = (yPos - tokenSize / 2 >= frameTopEdge) && + (yPos + tokenSize / 2 <= frameBotEdge); - tok.set({ left: frameLeft, top: yPos, baseOpacity: visible ? 1 : 0, showname: visible }); + tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); if (txt) { txt.set({ left: frameLeft + frameWidth / 2 + 15, @@ -3711,14 +3724,14 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = hudSlotY(frameTopEdge, i); - var visible = (yPos - INIT_HUD_TOKEN_SIZE / 2 >= frameTopEdge) && - (yPos + INIT_HUD_TOKEN_SIZE / 2 <= frameBotEdge); + var yPos = hudSlotY(frameTopEdge, i, tokenSize); + var visible = (yPos - tokenSize / 2 >= frameTopEdge) && + (yPos + tokenSize / 2 <= frameBotEdge); if (tok.get('type') === 'graphic') { - tok.set({ left: frameLeft, top: yPos, baseOpacity: visible ? 1 : 0, showname: visible }); + tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); } else { - tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameTopEdge, i) : -5000, title: entry.custom || 'Custom' }); + tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameTopEdge, i, tokenSize) : -5000, title: entry.custom || 'Custom' }); } if (txt) { @@ -3908,6 +3921,71 @@ var Gaslight = Gaslight || (() => { on('destroy:text', onHudTextDestroyed); on('destroy:graphic', function(obj) { onTokenDestroyed(obj); onHudGraphicDestroyed(obj); }); on('destroy:pathv2', onHudPathDestroyed); + on('change:pathv2', function(obj) { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + if (obj.get('id') === s.hud.initData.frameId) { + var data = s.hud.initData; + var points = JSON.parse(obj.get('points') || '[]'); + var newWidth = points.length >= 2 ? points[1][0] - points[0][0] : 0; + var newHeight = points.length >= 2 ? points[1][1] - points[0][1] : 0; + var oldWidth = data.frameSize ? data.frameSize.width : newWidth; + + // Width change logic: shrink → reduce padding first, then tokens; grow → grow padding + if (newWidth !== oldWidth) { + var delta = newWidth - oldWidth; + var currentHPad = data.hPadding || INIT_HUD_H_PADDING; + var currentTokenSize = data.tokenSize || INIT_HUD_TOKEN_SIZE; + var minHPad = 5; + + if (delta < 0) { + // Frame got narrower — reduce padding first, then shrink tokens + var padReduction = Math.min(Math.abs(delta) / 2, currentHPad - minHPad); + if (padReduction > 0) { + data.hPadding = currentHPad - padReduction; + delta += padReduction * 2; // remaining delta + } + if (delta < 0) { + // Still need to shrink — reduce token size + data.tokenSize = Math.max(10, currentTokenSize + delta); + } + } else { + // Frame got wider — increase h_padding + data.hPadding = currentHPad + delta / 2; + } + } + + // Save position and size to state + data.pos = { left: obj.get('x'), top: obj.get('y') }; + data.frameSize = { width: newWidth, height: newHeight }; + reflowInitiativeHud('none'); + } + }); + on('change:graphic', function(obj, prev) { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + // Check if this is a HUD token that was resized + var match = data.entries.find(function(e) { return e.tokenId === obj.get('id'); }); + if (!match || match.sourceId.startsWith('custom:')) return; + var newSize = obj.get('width'); + var oldSize = prev.width; + if (newSize !== oldSize && newSize > 0) { + // Token resized — update token size, resize frame to fit + data.tokenSize = newSize; + var hPad = data.hPadding || INIT_HUD_H_PADDING; + var newFrameWidth = newSize + 2 * hPad; + // Update frame + var frame = getObj('pathv2', data.frameId); + if (frame) { + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; + frame.set('points', JSON.stringify([[0, 0], [newFrameWidth, frameHeight]])); + data.frameSize = { width: newFrameWidth, height: frameHeight }; + } + reflowInitiativeHud('none'); + } + }); on('destroy:pin', onHudGraphicDestroyed); }; From 12230117fb7fac641fc25a7fbe7bd8ab3a2d3191 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 13:29:29 -0400 Subject: [PATCH 20/41] Gaslight: initiative HUD - configurable defaults, text styling, cleanup - Replace INIT_HUD_* constants with defaultInitHud object - Reset uses Object.assign({}, defaultInitHud) for clean slate - All sizing/padding reads from data.* with defaultInitHud fallback - Text font_size and font_family adjustable (GM changes one, all follow) - Text X offset stored relative to frame right edge - Frame width read from actual path points (not computed) - hudSlotY/hudPinY accept tokenPadding param - Remove debug logs --- Gaslight/Gaslight.js | 131 +++++++++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 43 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index be3d3b2d7..5c69d6571 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3383,20 +3383,25 @@ var Gaslight = Gaslight || (() => { // ---- Initiative HUD ---- - const INIT_HUD_TOKEN_SIZE = 50; - const INIT_HUD_TOKEN_PADDING = 30; - const INIT_HUD_V_PADDING = 15; - const INIT_HUD_H_PADDING = 10; + const defaultInitHud = { + tokenSize: 50, + tokenPadding: 30, + vPadding: 15, + hPadding: 10, + textOffset: 15, + textFontSize: 16, + textFontFamily: 'Contrail One', + entries: [], + frameId: null, + pos: null, + frameSize: null, + }; - /** - * Calculate Y position for a HUD entry at a given slot index. - * For pins, add tokenSize/2 since their anchor is bottom-middle. - */ - const hudSlotY = (frameTopEdge, index, tokenSize) => { - return frameTopEdge + tokenSize / 2 + (tokenSize + INIT_HUD_TOKEN_PADDING) * index; + const hudSlotY = (frameTopEdge, index, tokenSize, tokenPadding) => { + return frameTopEdge + tokenSize / 2 + (tokenSize + tokenPadding) * index; }; - const hudPinY = (frameTopEdge, index, tokenSize) => { - return hudSlotY(frameTopEdge, index, tokenSize) + tokenSize / 2; + const hudPinY = (frameTopEdge, index, tokenSize, tokenPadding) => { + return hudSlotY(frameTopEdge, index, tokenSize, tokenPadding) + tokenSize / 2; }; /** @@ -3406,8 +3411,8 @@ var Gaslight = Gaslight || (() => { var s = state[SCRIPT_NAME]; if (s.hud.initData && s.hud.initData.tokenSize) return s.hud.initData.tokenSize; var points = JSON.parse(frame.get('points') || '[]'); - var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_H_PADDING; - var hPad = (s.hud.initData && s.hud.initData.hPadding) || INIT_HUD_H_PADDING; + var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : defaultInitHud.tokenSize + 2 * defaultInitHud.hPadding; + var hPad = (s.hud.initData && s.hud.initData.hPadding) || defaultInitHud.hPadding; return Math.max(10, frameWidth - 2 * hPad); }; @@ -3453,17 +3458,16 @@ var Gaslight = Gaslight || (() => { if (!page) return; var order = getHudTurnOrder(); - if (!s.hud.initData) { - s.hud.initData = { frameId: null, entries: [], pos: null, frameSize: null }; - } + if (!s.hud.initData) s.hud.initData = {}; + if (!s.hud.initData.entries) s.hud.initData.entries = []; var data = s.hud.initData; - var frameWidth = INIT_HUD_TOKEN_SIZE + 2 * INIT_HUD_H_PADDING; + var frameWidth = defaultInitHud.tokenSize + 2 * defaultInitHud.hPadding; // Create frame if missing if (!data.frameId || !getObj('pathv2', data.frameId)) { var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; - var frameHeight = 5 * INIT_HUD_TOKEN_SIZE + 4 * INIT_HUD_TOKEN_PADDING + 2 * INIT_HUD_V_PADDING; + var frameHeight = 5 * defaultInitHud.tokenSize + 4 * defaultInitHud.tokenPadding + 2 * defaultInitHud.vPadding; var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height); data.frameId = frame.get('id'); @@ -3535,9 +3539,9 @@ var Gaslight = Gaslight || (() => { text: String(entry.pr || ''), left: -5000, top: -5000, - font_size: 16, + font_size: data.textFontSize || defaultInitHud.textFontSize, color: '#ffffff', - font_family: 'Contrail One', + font_family: data.textFontFamily || defaultInitHud.textFontFamily, }); data.entries.push({ @@ -3556,7 +3560,7 @@ var Gaslight = Gaslight || (() => { layer: 'foreground', imgsrc: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), left: frameLeft, - top: frameTopEdge + INIT_HUD_TOKEN_PADDING + tokenSize / 2, + top: frameTopEdge + (data.tokenPadding || defaultInitHud.tokenPadding) + tokenSize / 2, width: tokenSize, height: tokenSize, showname: true, @@ -3568,11 +3572,11 @@ var Gaslight = Gaslight || (() => { _pageid: pageId, layer: 'foreground', text: String(entry.pr || ''), - left: frameLeft + frameWidth / 2 + 15, - top: frameTopEdge + INIT_HUD_TOKEN_PADDING + tokenSize / 2, - font_size: 16, + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), + top: frameTopEdge + (data.tokenPadding || defaultInitHud.tokenPadding) + tokenSize / 2, + font_size: data.textFontSize || defaultInitHud.textFontSize, color: '#ffffff', - font_family: 'Contrail One', + font_family: data.textFontFamily || defaultInitHud.textFontFamily, }); if (typeof Mirror !== 'undefined') { @@ -3606,10 +3610,12 @@ var Gaslight = Gaslight || (() => { var frameTop = frame.get('y'); var points = JSON.parse(frame.get('points') || '[]'); var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; - var frameTopEdge = frameTop - frameHeight / 2 + INIT_HUD_V_PADDING; - var frameBotEdge = frameTop + frameHeight / 2 - INIT_HUD_V_PADDING; var tokenSize = getHudTokenSize(frame); - var frameWidth = tokenSize + 2 * INIT_HUD_H_PADDING; + var tokenPadding = data.tokenPadding || defaultInitHud.tokenPadding; + var vPadding = data.vPadding || defaultInitHud.vPadding; + var frameTopEdge = frameTop - frameHeight / 2 + vPadding; + var frameBotEdge = frameTop + frameHeight / 2 - vPadding; + var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : tokenSize + 2 * (data.hPadding || defaultInitHud.hPadding); // Match order entries to HUD entries var tokenMap = {}; @@ -3621,8 +3627,8 @@ var Gaslight = Gaslight || (() => { if (direction === 'forward' || direction === 'backward') { // Simple shift: move all custom pins/text by one slot, wrap at edges var shift = direction === 'forward' - ? -(tokenSize + INIT_HUD_TOKEN_PADDING) - : (tokenSize + INIT_HUD_TOKEN_PADDING); + ? -(tokenSize + tokenPadding) + : (tokenSize + tokenPadding); // Gather all HUD text Y positions (tokens + customs) var allTextYs = data.entries.map(function(e) { @@ -3668,14 +3674,14 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = hudSlotY(frameTopEdge, i, tokenSize); + var yPos = hudSlotY(frameTopEdge, i, tokenSize, tokenPadding); var visible = (yPos - tokenSize / 2 >= frameTopEdge) && (yPos + tokenSize / 2 <= frameBotEdge); tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); if (txt) { txt.set({ - left: frameLeft + frameWidth / 2 + 15, + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), top: yPos, color: visible ? '#ffffff' : 'transparent', text: String(entry.pr || ''), @@ -3724,19 +3730,19 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = hudSlotY(frameTopEdge, i, tokenSize); + var yPos = hudSlotY(frameTopEdge, i, tokenSize, tokenPadding); var visible = (yPos - tokenSize / 2 >= frameTopEdge) && (yPos + tokenSize / 2 <= frameBotEdge); if (tok.get('type') === 'graphic') { tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); } else { - tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameTopEdge, i, tokenSize) : -5000, title: entry.custom || 'Custom' }); + tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameTopEdge, i, tokenSize, tokenPadding) : -5000, title: entry.custom || 'Custom' }); } if (txt) { txt.set({ - left: frameLeft + frameWidth / 2 + 15, + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), top: yPos, color: visible ? '#ffffff' : 'transparent', text: String(entry.pr || ''), @@ -3785,6 +3791,47 @@ var Gaslight = Gaslight || (() => { font_size: obj.get('font_size'), }; } + // Initiative HUD text moved or styled — update stored settings + if (s.hud.initiative && s.hud.initData && s.hud.initData.entries) { + var match = s.hud.initData.entries.find(function(e) { return e.textId === id; }); + if (match) { + var data = s.hud.initData; + var frame = getObj('pathv2', data.frameId); + if (frame) { + var frameLeft = frame.get('x'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fw = pts.length >= 2 ? pts[1][0] - pts[0][0] : 70; + var rightEdge = frameLeft + fw / 2; + var newOffset = obj.get('left') - rightEdge; + data.textOffset = newOffset; + // Move all other texts to match offset + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('left', rightEdge + newOffset); + }); + } + // Font size/family changes + var newFontSize = obj.get('font_size'); + var newFontFamily = obj.get('font_family'); + if (newFontSize && newFontSize !== data.textFontSize) { + data.textFontSize = newFontSize; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('font_size', newFontSize); + }); + } + if (newFontFamily && newFontFamily !== data.textFontFamily) { + data.textFontFamily = newFontFamily; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('font_family', newFontFamily); + }); + } + } + } }; /** @@ -3879,11 +3926,8 @@ var Gaslight = Gaslight || (() => { updateViewHud(); } } else if (element === 'initiative') { - if (s.hud.initData) { - s.hud.initData.pos = null; - s.hud.initData.frameSize = null; - } removeInitiativeHud(); + s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); updateInitiativeHud(); } reply(msg, 'HUD', '' + element + ' reset to default position.'); @@ -3934,8 +3978,8 @@ var Gaslight = Gaslight || (() => { // Width change logic: shrink → reduce padding first, then tokens; grow → grow padding if (newWidth !== oldWidth) { var delta = newWidth - oldWidth; - var currentHPad = data.hPadding || INIT_HUD_H_PADDING; - var currentTokenSize = data.tokenSize || INIT_HUD_TOKEN_SIZE; + var currentHPad = data.hPadding || defaultInitHud.hPadding; + var currentTokenSize = data.tokenSize || defaultInitHud.tokenSize; var minHPad = 5; if (delta < 0) { @@ -3958,6 +4002,7 @@ var Gaslight = Gaslight || (() => { // Save position and size to state data.pos = { left: obj.get('x'), top: obj.get('y') }; data.frameSize = { width: newWidth, height: newHeight }; + log(SCRIPT_NAME + ' DEBUG pathv2 change: calling reflow'); reflowInitiativeHud('none'); } }); @@ -3973,7 +4018,7 @@ var Gaslight = Gaslight || (() => { if (newSize !== oldSize && newSize > 0) { // Token resized — update token size, resize frame to fit data.tokenSize = newSize; - var hPad = data.hPadding || INIT_HUD_H_PADDING; + var hPad = data.hPadding || defaultInitHud.hPadding; var newFrameWidth = newSize + 2 * hPad; // Update frame var frame = getObj('pathv2', data.frameId); From cc002e5813e857047984feec3adeadc3bce6de77 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 14:07:37 -0400 Subject: [PATCH 21/41] Gaslight: HUD styling - text color/stroke/font, frame fill/stroke/stroke_width - Text: color, stroke (glow), font_size, font_family all adjustable + persist - Frame: stroke, fill, stroke_width adjustable + persist - isdrawing=true on HUD tokens (no token menu/grid snap) - tooltipVisibleTo='' on pins (no tooltip popup) - All styling propagates to sibling elements on change - Stroke/color set to transparent when text is hidden (overflow) --- Gaslight/Gaslight.js | 55 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 5c69d6571..d68bd0bc1 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3391,6 +3391,11 @@ var Gaslight = Gaslight || (() => { textOffset: 15, textFontSize: 16, textFontFamily: 'Contrail One', + textColor: '#ffffff', + textStroke: '#000000', + frameStroke: '#ffffff', + frameFill: 'transparent', + frameStrokeWidth: 3, entries: [], frameId: null, pos: null, @@ -3430,7 +3435,7 @@ var Gaslight = Gaslight || (() => { /** * Draw a rectangle path on the foreground layer. */ - const createFramePath = (pageId, left, top, width, height) => { + const createFramePath = (pageId, left, top, width, height, data) => { return createObj('pathv2', { _pageid: pageId, layer: 'foreground', @@ -3438,9 +3443,9 @@ var Gaslight = Gaslight || (() => { x: left, y: top, points: JSON.stringify([[0, 0], [width, height]]), - stroke: '#ffffff', - stroke_width: 3, - fill: 'transparent', + stroke: (data && data.frameStroke) || defaultInitHud.frameStroke, + stroke_width: (data && data.frameStrokeWidth) || defaultInitHud.frameStrokeWidth, + fill: (data && data.frameFill) || defaultInitHud.frameFill, }); }; @@ -3469,7 +3474,7 @@ var Gaslight = Gaslight || (() => { var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; var frameHeight = 5 * defaultInitHud.tokenSize + 4 * defaultInitHud.tokenPadding + 2 * defaultInitHud.vPadding; var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; - var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height); + var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height, data); data.frameId = frame.get('id'); data.pos = pos; data.frameSize = frameSize; @@ -3531,6 +3536,7 @@ var Gaslight = Gaslight || (() => { useTextIcon: true, textIcon: '', scale: 2.0, + tooltipVisibleTo: '', }); var pinText = createObj('text', { @@ -3540,7 +3546,8 @@ var Gaslight = Gaslight || (() => { left: -5000, top: -5000, font_size: data.textFontSize || defaultInitHud.textFontSize, - color: '#ffffff', + color: data.textColor || defaultInitHud.textColor, + stroke: data.textStroke || defaultInitHud.textStroke, font_family: data.textFontFamily || defaultInitHud.textFontFamily, }); @@ -3566,6 +3573,7 @@ var Gaslight = Gaslight || (() => { showname: true, name: sourceToken.get('name'), baseOpacity: 1, + isdrawing: true, }); var hudText = createObj('text', { @@ -3575,7 +3583,8 @@ var Gaslight = Gaslight || (() => { left: frameLeft + frameWidth / 2 + (data.textOffset || 15), top: frameTopEdge + (data.tokenPadding || defaultInitHud.tokenPadding) + tokenSize / 2, font_size: data.textFontSize || defaultInitHud.textFontSize, - color: '#ffffff', + color: data.textColor || defaultInitHud.textColor, + stroke: data.textStroke || defaultInitHud.textStroke, font_family: data.textFontFamily || defaultInitHud.textFontFamily, }); @@ -3661,7 +3670,7 @@ var Gaslight = Gaslight || (() => { if (pin) { pin.set({ x: visible ? frameLeft : -5000, y: visible ? newPinY : -5000 }); } - txt.set({ top: newSlotY, color: visible ? '#ffffff' : 'transparent' }); + txt.set({ top: newSlotY, color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent' }); }); // Reflow only token entries by ID @@ -3683,7 +3692,7 @@ var Gaslight = Gaslight || (() => { txt.set({ left: frameLeft + frameWidth / 2 + (data.textOffset || 15), top: yPos, - color: visible ? '#ffffff' : 'transparent', + color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', text: String(entry.pr || ''), }); } @@ -3744,7 +3753,7 @@ var Gaslight = Gaslight || (() => { txt.set({ left: frameLeft + frameWidth / 2 + (data.textOffset || 15), top: yPos, - color: visible ? '#ffffff' : 'transparent', + color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', text: String(entry.pr || ''), }); } @@ -3830,6 +3839,24 @@ var Gaslight = Gaslight || (() => { if (otherTxt) otherTxt.set('font_family', newFontFamily); }); } + var newColor = obj.get('color'); + if (newColor && newColor !== 'transparent' && newColor !== data.textColor) { + data.textColor = newColor; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt && otherTxt.get('color') !== 'transparent') otherTxt.set('color', newColor); + }); + } + var newStroke = obj.get('stroke'); + if (newStroke && newStroke !== data.textStroke) { + data.textStroke = newStroke; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('stroke', newStroke); + }); + } } } }; @@ -4002,7 +4029,13 @@ var Gaslight = Gaslight || (() => { // Save position and size to state data.pos = { left: obj.get('x'), top: obj.get('y') }; data.frameSize = { width: newWidth, height: newHeight }; - log(SCRIPT_NAME + ' DEBUG pathv2 change: calling reflow'); + // Track frame styling + var stroke = obj.get('stroke'); + var fill = obj.get('fill'); + var strokeWidth = obj.get('stroke_width'); + if (stroke !== undefined) data.frameStroke = stroke; + if (fill !== undefined) data.frameFill = fill; + if (strokeWidth !== undefined) data.frameStrokeWidth = strokeWidth; reflowInitiativeHud('none'); } }); From f94aa6c4f338bc55e25f80d0c6990a53e5512b68 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 14:17:37 -0400 Subject: [PATCH 22/41] Gaslight: view HUD - configurable styling with defaultViewHud pattern - defaultViewHud object: leftNorm, top, fontSize, fontFamily, color, stroke - leftNorm: normalized 0-1 X position (stays centered on page resize) - All styling persists in state.hud.viewData - Reset clears viewData, restores all defaults - Matches initiative HUD pattern (change detection, propagation) --- Gaslight/Gaslight.js | 50 ++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d68bd0bc1..62ee77b13 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3352,17 +3352,18 @@ var Gaslight = Gaslight || (() => { existing.set('text', label); } else { // Use stored position/size or defaults - var pos = (s.hud.viewPos) || {}; + var vd = s.hud.viewData || {}; var pageWidth = page.get('width') * 70; var obj = createObj('text', { _pageid: pageId, layer: 'foreground', text: label, - left: pos.left || Math.round(pageWidth / 2), - top: pos.top || 100, - font_size: pos.font_size || 40, - color: '#ffffff', - font_family: 'Contrail One', + left: Math.round((vd.leftNorm || defaultViewHud.leftNorm) * pageWidth), + top: vd.top || defaultViewHud.top, + font_size: vd.fontSize || defaultViewHud.fontSize, + color: vd.color || defaultViewHud.color, + stroke: vd.stroke || defaultViewHud.stroke, + font_family: vd.fontFamily || defaultViewHud.fontFamily, }); s.hud.viewId = obj.get('id'); } @@ -3383,6 +3384,15 @@ var Gaslight = Gaslight || (() => { // ---- Initiative HUD ---- + const defaultViewHud = { + leftNorm: 0.5, + top: 100, + fontSize: 40, + fontFamily: 'Contrail One', + color: '#ffffff', + stroke: '#000000', + }; + const defaultInitHud = { tokenSize: 50, tokenPadding: 30, @@ -3794,11 +3804,18 @@ var Gaslight = Gaslight || (() => { var s = state[SCRIPT_NAME]; var id = obj.get('id'); if (id === s.hud.viewId) { - s.hud.viewPos = { - left: obj.get('left'), - top: obj.get('top'), - font_size: obj.get('font_size'), - }; + if (!s.hud.viewData) s.hud.viewData = {}; + var vPageId = getHudPageId(); + var vPage = vPageId ? getObj('page', vPageId) : null; + var vPageWidth = vPage ? vPage.get('width') * 70 : 1400; + s.hud.viewData.leftNorm = obj.get('left') / vPageWidth; + s.hud.viewData.top = obj.get('top'); + s.hud.viewData.fontSize = obj.get('font_size'); + s.hud.viewData.fontFamily = obj.get('font_family'); + var vColor = obj.get('color'); + if (vColor) s.hud.viewData.color = vColor; + var vStroke = obj.get('stroke'); + if (vStroke) s.hud.viewData.stroke = vStroke; } // Initiative HUD text moved or styled — update stored settings if (s.hud.initiative && s.hud.initData && s.hud.initData.entries) { @@ -3940,15 +3957,22 @@ var Gaslight = Gaslight || (() => { s.hud[element] = false; } else if (toggle === 'reset') { // Clear stored position, turn on, move to defaults - delete s.hud[element + 'Pos']; s.hud[element] = true; if (element === 'view') { + s.hud.viewData = {}; var existing = findHudElement('view'); if (existing) { var pageId = getHudPageId(); var page = pageId ? getObj('page', pageId) : null; var pageWidth = page ? page.get('width') * 70 : 1400; - existing.set({ left: Math.round(pageWidth / 2), top: 100, font_size: 40 }); + existing.set({ + left: Math.round(defaultViewHud.leftNorm * pageWidth), + top: defaultViewHud.top, + font_size: defaultViewHud.fontSize, + font_family: defaultViewHud.fontFamily, + color: defaultViewHud.color, + stroke: defaultViewHud.stroke, + }); } else { updateViewHud(); } From ade04d36d5a76e5c9af921f315e6062d92fa1700 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 14:32:44 -0400 Subject: [PATCH 23/41] Gaslight: HUD command improvements + drag-to-reorder initiative - Toggle word before/after element: 'hud reset init' = 'hud init reset' - No-args toggles all elements, single toggle word applies to all - Reset turns all elements on + restores defaults - Aliases: init/turn/turns -> initiative, relay -> view - Drag HUD token vertically to reorder initiative (moves linked group) - Drag only detected if token wasn't resized (corner resize safety) --- Gaslight/Gaslight.js | 117 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 62ee77b13..33377a3e9 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3934,18 +3934,63 @@ var Gaslight = Gaslight || (() => { */ const doHud = (msg, args) => { var s = state[SCRIPT_NAME]; + var hudElements = Object.keys(s.hud).filter(function(k) { return typeof s.hud[k] === 'boolean'; }); + var toggleWords = new Set(['on', 'off', 'reset']); + if (args.length === 0) { - // Show current HUD state - var status = Object.entries(s.hud).filter(function(e) { return typeof e[1] === 'boolean'; }).map(function(e) { - return '' + e[0] + ': ' + (e[1] ? 'on' : 'off'); - }).join('
'); - reply(msg, 'HUD', status); + // Toggle all elements + hudElements.forEach(function(el) { s.hud[el] = !s.hud[el]; }); + hudElements.forEach(function(el) { + if (el === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); } + if (el === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); } + }); + reply(msg, 'HUD', hudElements.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
')); + return; + } + + // Single arg that's a toggle word — apply to all elements + if (args.length === 1 && toggleWords.has(args[0].toLowerCase())) { + var toggle = args[0].toLowerCase(); + hudElements.forEach(function(el) { + if (toggle === 'on') s.hud[el] = true; + else if (toggle === 'off') s.hud[el] = false; + }); + if (toggle === 'reset') { + hudElements.forEach(function(el) { s.hud[el] = true; }); + s.hud.viewData = {}; + removeViewHud(); + updateViewHud(); + removeInitiativeHud(); + s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); + updateInitiativeHud(); + } else { + hudElements.forEach(function(el) { + if (el === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); } + if (el === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); } + }); + } + reply(msg, 'HUD', hudElements.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
')); return; } var element = args[0].toLowerCase(); var toggle = args[1] ? args[1].toLowerCase() : null; + // Aliases + var elementAliases = { init: 'initiative', turn: 'initiative', turns: 'initiative', relay: 'view' }; + if (elementAliases[element]) element = elementAliases[element]; + if (toggle && elementAliases[toggle]) toggle = elementAliases[toggle]; + + // Allow toggle before or after element: "hud reset initiative" or "hud initiative reset" + if (toggleWords.has(element) && toggle && s.hud[toggle] !== undefined) { + var tmp = element; + element = toggle; + toggle = tmp; + } + + // Aliases (apply again after swap) + if (elementAliases[element]) element = elementAliases[element]; + if (s.hud[element] === undefined) { reply(msg, 'Error', 'Unknown HUD element: ' + element + '. Available: ' + Object.keys(s.hud).filter(function(k) { return typeof s.hud[k] === 'boolean'; }).join(', ')); return; @@ -4086,6 +4131,68 @@ var Gaslight = Gaslight || (() => { data.frameSize = { width: newFrameWidth, height: frameHeight }; } reflowInitiativeHud('none'); + } else { + // Token dragged vertically — reorder initiative + var newTop = obj.get('top'); + var oldTop = prev.top; + if (newTop !== oldTop) { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var sourceId = match.sourceId; + var hudOrder = getHudTurnOrder(); + var currentIdx = hudOrder.findIndex(function(e) { return e.id === sourceId; }); + if (currentIdx === -1) { reflowInitiativeHud('none'); return; } + + // Determine target slot based on new Y position + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + var frameTop = frame.get('y'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; + var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + var targetIdx = Math.round((newTop - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + targetIdx = Math.max(0, Math.min(hudOrder.length - 1, targetIdx)); + + if (targetIdx !== currentIdx) { + var sourceEntry = order.find(function(e) { return e.id === sourceId; }); + if (!sourceEntry) { reflowInitiativeHud('none'); return; } + + var targetHudEntry = hudOrder[targetIdx]; + var targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); + if (targetFullIdx === -1) { reflowInitiativeHud('none'); return; } + + // Remove source (and its linked children) from order + var info = getLinkedInfo(sourceId); + var groupIds = new Set([sourceId].concat(info.linkedIds)); + var removed = []; + order = order.filter(function(e) { + if (groupIds.has(e.id)) { removed.push(e); return false; } + return true; + }); + + // Recalculate target index after removal + targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); + if (targetFullIdx === -1) targetFullIdx = order.length; + + // Insert: if moving down, insert after target group; if up, insert before + if (targetIdx > currentIdx) { + var tInfo = getLinkedInfo(targetHudEntry.id); + var tGroupSize = 1 + tInfo.linkedIds.filter(function(lid) { + return order.some(function(e) { return e.id === lid; }); + }).length; + var insertIdx = targetFullIdx + tGroupSize; + removed.forEach(function(e, i) { order.splice(insertIdx + i, 0, e); }); + } else { + removed.forEach(function(e, i) { order.splice(targetFullIdx + i, 0, e); }); + } + + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + } + reflowInitiativeHud('none'); + } } }); on('destroy:pin', onHudGraphicDestroyed); From 4d8098257790a4cc8f52070d67d3a910de5f9fd5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 14:36:58 -0400 Subject: [PATCH 24/41] Gaslight: drag-to-reorder for custom turn pins - change:pin handler detects vertical drag on HUD pins - Reorders custom turn entry in full turn order - Uses text position as source of truth for current slot index - Pin Y offset accounted for in target slot calculation --- Gaslight/Gaslight.js | 91 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 33377a3e9..fc5bb997f 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4196,6 +4196,97 @@ var Gaslight = Gaslight || (() => { } }); on('destroy:pin', onHudGraphicDestroyed); + on('change:pin', function(obj, prev) { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + var match = data.entries.find(function(e) { return e.tokenId === obj.get('id'); }); + if (!match) return; + + var newY = obj.get('y'); + var oldY = prev.y; + if (newY === oldY) return; + + // Pin dragged vertically — reorder custom turn in initiative + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var hudOrder = getHudTurnOrder(); + + // Find current index of this custom in the deduped order by matching text position + var txt = getObj('text', match.textId); + var currentIdx = -1; + if (txt) { + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + var frameTop = frame.get('y'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; + var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + currentIdx = Math.round((txt.get('top') - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + } + if (currentIdx < 0 || currentIdx >= hudOrder.length) { reflowInitiativeHud('none'); return; } + + // Determine target slot based on new pin Y (subtract pin offset) + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + var frameTop = frame.get('y'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; + var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + var slotY = newY - tknSize / 2; // undo pin offset + var targetIdx = Math.round((slotY - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + targetIdx = Math.max(0, Math.min(hudOrder.length - 1, targetIdx)); + + if (targetIdx !== currentIdx) { + // Find the custom entry in the full order + var customEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + var customIdx = 0; + var sourceFullIdx = -1; + for (var fi = 0; fi < order.length; fi++) { + if (!order[fi].id || order[fi].id === '-1') { + if (customIdx === (currentIdx - hudOrder.slice(0, currentIdx).filter(function(e) { return e.id && e.id !== '-1'; }).length)) { + sourceFullIdx = fi; + break; + } + customIdx++; + } + } + if (sourceFullIdx === -1) { reflowInitiativeHud('none'); return; } + + // Remove source from order + var removed = order.splice(sourceFullIdx, 1); + + // Find target position in full order + var targetHudEntry = hudOrder[targetIdx]; + var targetFullIdx; + if (!targetHudEntry.id || targetHudEntry.id === '-1') { + // Targeting another custom — find it in the full order + targetFullIdx = targetIdx > currentIdx ? sourceFullIdx : sourceFullIdx; + } else { + targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); + } + if (targetFullIdx === -1) targetFullIdx = order.length; + + // Insert + if (targetIdx > currentIdx) { + var tInfo = getLinkedInfo(targetHudEntry.id || ''); + var tGroupSize = (targetHudEntry.id && targetHudEntry.id !== '-1') ? 1 + tInfo.linkedIds.filter(function(lid) { + return order.some(function(e) { return e.id === lid; }); + }).length : 1; + order.splice(targetFullIdx + tGroupSize, 0, removed[0]); + } else { + order.splice(targetFullIdx, 0, removed[0]); + } + + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + } + reflowInitiativeHud('none'); + }); }; return { checkInstall, registerEventHandlers }; From aa86e88dd8ce8d2fe78779df0dfe1eaf3f0359b4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 14:40:35 -0400 Subject: [PATCH 25/41] Gaslight: delete HUD token/pin removes from initiative (frame delete = HUD off) - Delete HUD token: removes entry + linked children from turn order, reflows - Delete HUD pin: removes custom turn from turn order, reflows - Delete HUD text: same behavior (removes associated token/pin + turn order entry) - Delete frame: still turns initiative HUD off entirely - Associated text/token cleaned up on either deletion --- Gaslight/Gaslight.js | 64 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index fc5bb997f..ecb8599d6 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3891,11 +3891,32 @@ var Gaslight = Gaslight || (() => { } // Initiative HUD text if (s.hud.initData && s.hud.initData.entries) { - var match = s.hud.initData.entries.find(function(e) { return e.textId === id; }); - if (match) { - s.hud.initiative = false; - removeInitiativeHud(); - sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + var data = s.hud.initData; + var matchIdx = data.entries.findIndex(function(e) { return e.textId === id; }); + if (matchIdx !== -1) { + var match = data.entries[matchIdx]; + // Remove associated token/pin + var tok = getObj('graphic', match.tokenId) || getObj('pin', match.tokenId); + if (tok) tok.remove(); + // Remove from entries + data.entries.splice(matchIdx, 1); + + // Remove from turn order + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (match.sourceId && !match.sourceId.startsWith('custom:')) { + var info = getLinkedInfo(match.sourceId); + var groupIds = new Set([match.sourceId].concat(info.linkedIds)); + order = order.filter(function(e) { return !groupIds.has(e.id); }); + } else { + var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); + if (customIdx !== -1) order.splice(customIdx, 1); + } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + + reflowInitiativeHud('none'); + sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); } } }; @@ -3907,12 +3928,35 @@ var Gaslight = Gaslight || (() => { var s = state[SCRIPT_NAME]; if (!s.hud.initData || !s.hud.initData.entries) return; var id = obj.get('id'); - var match = s.hud.initData.entries.find(function(e) { return e.tokenId === id; }); - if (match) { - s.hud.initiative = false; - removeInitiativeHud(); - sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + var data = s.hud.initData; + var matchIdx = data.entries.findIndex(function(e) { return e.tokenId === id; }); + if (matchIdx === -1) return; + + var match = data.entries[matchIdx]; + // Remove associated text + var txt = getObj('text', match.textId); + if (txt) txt.remove(); + // Remove from entries + data.entries.splice(matchIdx, 1); + + // Remove from turn order + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (match.sourceId && !match.sourceId.startsWith('custom:')) { + // Token — remove it and linked children from turn order + var info = getLinkedInfo(match.sourceId); + var groupIds = new Set([match.sourceId].concat(info.linkedIds)); + order = order.filter(function(e) { return !groupIds.has(e.id); }); + } else { + // Custom turn — remove first matching custom entry + var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); + if (customIdx !== -1) order.splice(customIdx, 1); } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + + reflowInitiativeHud('none'); + sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); }; /** From 42e93bfeb1fe71e25a6659be09ea20976210a722 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 14:54:55 -0400 Subject: [PATCH 26/41] Gaslight: horizontal swipe on HUD = make it that token's turn - Drag token/pin past frame edge horizontally = rotate turn order to that entry - Vertical drift guard: horizontal swipe only if vertical movement < half token size - Horizontal escape guard: vertical reorder only if token stays within frame - Works for both graphic tokens and custom pins --- Gaslight/Gaslight.js | 61 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index ecb8599d6..31551f27b 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4179,7 +4179,17 @@ var Gaslight = Gaslight || (() => { // Token dragged vertically — reorder initiative var newTop = obj.get('top'); var oldTop = prev.top; - if (newTop !== oldTop) { + var newLeft = obj.get('left'); + var oldLeft = prev.left; + var frame = getObj('pathv2', data.frameId); + var frameLeft = frame ? frame.get('x') : 0; + var pts = frame ? JSON.parse(frame.get('points') || '[]') : []; + var fw = pts.length >= 2 ? pts[1][0] - pts[0][0] : 70; + var frameRightEdge = frameLeft + fw / 2; + var frameLeftEdge = frameLeft - fw / 2; + var horizontalEscape = newLeft > frameRightEdge || newLeft < frameLeftEdge; + + if (newTop !== oldTop && !horizontalEscape) { var order = JSON.parse(Campaign().get('turnorder') || '[]'); var sourceId = match.sourceId; var hudOrder = getHudTurnOrder(); @@ -4237,6 +4247,25 @@ var Gaslight = Gaslight || (() => { } reflowInitiativeHud('none'); } + + // Token dragged horizontally — make it this token's turn + var verticalDrift = Math.abs(newTop - oldTop); + var tknSizeH = data.tokenSize || defaultInitHud.tokenSize; + if (newLeft !== oldLeft && verticalDrift < tknSizeH / 2 && horizontalEscape) { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var sourceId = match.sourceId; + // Rotate until this token's master is at position 0 + var safety = order.length; + while (safety-- > 0 && order.length > 0 && order[0].id !== sourceId) { + order.push(order.shift()); + } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + reflowInitiativeHud('none'); + } else if (!horizontalEscape) { + reflowInitiativeHud('none'); + } } }); on('destroy:pin', onHudGraphicDestroyed); @@ -4329,6 +4358,36 @@ var Gaslight = Gaslight || (() => { Campaign().set('turnorder', JSON.stringify(order)); _suppressTurnSync = false; } + + // Pin dragged horizontally — make it this turn's turn + var newX = obj.get('x'); + var oldX = prev.x; + var verticalDrift = Math.abs(obj.get('y') - prev.y); + var tknSizeP = data.tokenSize || defaultInitHud.tokenSize; + if (newX !== oldX && verticalDrift < tknSizeP / 2 && frame) { + var frameLeft = frame.get('x'); + var pts2 = JSON.parse(frame.get('points') || '[]'); + var fw2 = pts2.length >= 2 ? pts2[1][0] - pts2[0][0] : 70; + var frameRightEdge = frameLeft + fw2 / 2; + var frameLeftEdge = frameLeft - fw2 / 2; + + if (newX > frameRightEdge || newX < frameLeftEdge) { + // Find which custom turn this is in the full order + var fullOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + var customsInOrder = []; + fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); + var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (customRank !== -1 && customsInOrder[customRank] !== undefined) { + var targetFullIdx = customsInOrder[customRank]; + // Rotate until this entry is at position 0 + var rotated = fullOrder.slice(targetFullIdx).concat(fullOrder.slice(0, targetFullIdx)); + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(rotated)); + _suppressTurnSync = false; + } + } + } + reflowInitiativeHud('none'); }); }; From 5b96469e04edea2b2acda4317602cbf484276508 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 15:27:43 -0400 Subject: [PATCH 27/41] Gaslight: initiative HUD redesign - centered current turn + fixes - Current turn positioned at frame center (not top) - Past turns above center, future turns below - Offset-based positioning (hudSlotY uses offset from center) - Yellow highlight rectangle on current turn slot - Fix: deduplicate linkedIds before adding to turn order - Fix: don't add '-1' to placed set in reorderInitiative (allows multiple custom turns) - Fix: alreadyHasLinked guard prevents cascade additions --- Gaslight/Gaslight.js | 63 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 31551f27b..e27cb00a9 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3127,9 +3127,13 @@ var Gaslight = Gaslight || (() => { added.forEach(function(entry) { var info = getLinkedInfo(entry.id); if (info.linkedIds.length === 0) return; - // Add linked tokens that aren't already in the order + // Deduplicate linked IDs (can accumulate duplicates from repeated splits) + var uniqueLinkedIds = info.linkedIds.filter(function(id, i) { return info.linkedIds.indexOf(id) === i; }); + // Only process if none of this entry's linked tokens are already in the order var existingIds = new Set(newOrder.map(function(e) { return e.id; })); - info.linkedIds.forEach(function(linkedId) { + var alreadyHasLinked = uniqueLinkedIds.some(function(lid) { return existingIds.has(lid); }); + if (alreadyHasLinked) return; + uniqueLinkedIds.forEach(function(linkedId) { if (existingIds.has(linkedId)) return; var linkedObj = getObj('graphic', linkedId); var pushEntry = { id: linkedId, pr: entry.pr }; @@ -3255,7 +3259,7 @@ var Gaslight = Gaslight || (() => { if (placed.has(entry.id)) return; if (!entry.id || entry.id === '-1') { result.push(entry); - placed.add(entry.id); + // Don't add '-1' to placed — multiple custom entries share this ID return; } @@ -3408,15 +3412,16 @@ var Gaslight = Gaslight || (() => { frameStrokeWidth: 3, entries: [], frameId: null, + highlightId: null, pos: null, frameSize: null, }; - const hudSlotY = (frameTopEdge, index, tokenSize, tokenPadding) => { - return frameTopEdge + tokenSize / 2 + (tokenSize + tokenPadding) * index; + const hudSlotY = (frameCenter, offset, tokenSize, tokenPadding) => { + return frameCenter + offset * (tokenSize + tokenPadding); }; - const hudPinY = (frameTopEdge, index, tokenSize, tokenPadding) => { - return hudSlotY(frameTopEdge, index, tokenSize, tokenPadding) + tokenSize / 2; + const hudPinY = (frameCenter, offset, tokenSize, tokenPadding) => { + return hudSlotY(frameCenter, offset, tokenSize, tokenPadding) + tokenSize / 2; }; /** @@ -3493,6 +3498,24 @@ var Gaslight = Gaslight || (() => { var frame = getObj('pathv2', data.frameId); if (!frame) return; + // Create highlight indicator if missing + if (!data.highlightId || !getObj('pathv2', data.highlightId)) { + var hlTokenSize = data.tokenSize || defaultInitHud.tokenSize; + var hlSize = hlTokenSize + 6; + var highlight = createObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: 'rec', + x: frame.get('x'), + y: frame.get('y'), + points: JSON.stringify([[0, 0], [hlSize, hlSize]]), + stroke: '#ffcc00', + stroke_width: 3, + fill: 'transparent', + }); + data.highlightId = highlight.get('id'); + } + var tokenSize = getHudTokenSize(frame); var frameLeft = frame.get('x'); var frameTop = frame.get('y'); @@ -3634,6 +3657,18 @@ var Gaslight = Gaslight || (() => { var vPadding = data.vPadding || defaultInitHud.vPadding; var frameTopEdge = frameTop - frameHeight / 2 + vPadding; var frameBotEdge = frameTop + frameHeight / 2 - vPadding; + var frameCenter = frameTop; // current turn positioned at frame center + + // Update highlight position and size + var highlight = data.highlightId ? getObj('pathv2', data.highlightId) : null; + if (highlight) { + var hlSize = tokenSize + 6; + highlight.set({ + x: frameLeft, + y: frameCenter, + points: JSON.stringify([[0, 0], [hlSize, hlSize]]), + }); + } var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : tokenSize + 2 * (data.hPadding || defaultInitHud.hPadding); // Match order entries to HUD entries @@ -3693,7 +3728,8 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = hudSlotY(frameTopEdge, i, tokenSize, tokenPadding); + var offset = i <= order.length / 2 ? i : i - order.length; + var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); var visible = (yPos - tokenSize / 2 >= frameTopEdge) && (yPos + tokenSize / 2 <= frameBotEdge); @@ -3749,14 +3785,15 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var yPos = hudSlotY(frameTopEdge, i, tokenSize, tokenPadding); + var offset = i <= order.length / 2 ? i : i - order.length; + var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); var visible = (yPos - tokenSize / 2 >= frameTopEdge) && (yPos + tokenSize / 2 <= frameBotEdge); if (tok.get('type') === 'graphic') { tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); } else { - tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameTopEdge, i, tokenSize, tokenPadding) : -5000, title: entry.custom || 'Custom' }); + tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000, title: entry.custom || 'Custom' }); } if (txt) { @@ -3780,15 +3817,21 @@ var Gaslight = Gaslight || (() => { if (!data) return; var frameId = data.frameId; + var highlightId = data.highlightId; var entries = data.entries.slice(); // Clear IDs first so destroy handler ignores data.frameId = null; + data.highlightId = null; data.entries = []; if (frameId) { var frame = getObj('pathv2', frameId); if (frame) frame.remove(); } + if (highlightId) { + var hl = getObj('pathv2', highlightId); + if (hl) hl.remove(); + } entries.forEach(function(entry) { var tok = getObj('graphic', entry.tokenId) || getObj('pin', entry.tokenId); var txt = getObj('text', entry.textId); From 4c042c4ae8ecad8740618725e14e98de9cc6d651 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 16:05:16 -0400 Subject: [PATCH 28/41] Gaslight: WIP - round calculation formula support + swipe direction fixes - Apply custom turn formula when reaching top via API rotation - Inverse formula on backward direction - Only apply if our code modified the order (no double-apply with Roll20) - Update custom text pr values in direction shift path - Horizontal swipe: right=forward, left=backward with formula application - Known issue: current-turn swipe and pin backward rotation need fixing --- Gaslight/Gaslight.js | 79 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index e27cb00a9..234ecdde0 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3234,6 +3234,23 @@ var Gaslight = Gaslight || (() => { modified = true; } + // Apply round calculation for custom turns that reached the top via our rotation + // Only if we modified the order (if not modified, Roll20 handled it naturally and already applied the formula) + if (modified && newOrder.length > 0 && newOrder[0].id === '-1' && newOrder[0].formula) { + var topEntry = newOrder[0]; + var oldTopId = oldOrder.length > 0 ? oldOrder[0].id : null; + // Only apply if the top changed (this custom turn just became active) + if (oldTopId !== '-1' || (oldOrder[0] && oldOrder[0].custom !== topEntry.custom)) { + var formula = topEntry.formula.trim(); + var currentPr = parseFloat(topEntry.pr) || 0; + var val = parseFloat(formula.replace(/^[+-]/, '')) || 0; + var isAdd = formula.startsWith('+'); + // Forward = apply normally, backward = apply inverse + if (hudDirection === 'backward') isAdd = !isAdd; + topEntry.pr = isAdd ? currentPr + val : currentPr - val; + } + } + if (modified) { var finalJson = JSON.stringify(newOrder); _suppressTurnSync = true; @@ -3718,6 +3735,27 @@ var Gaslight = Gaslight || (() => { txt.set({ top: newSlotY, color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent' }); }); + // Update custom text values from current order based on position + var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + customEntries.forEach(function(e) { + var txt = getObj('text', e.textId); + if (!txt) return; + var txtY = txt.get('top'); + // Find which order slot this text is closest to + var bestOffset = null; + var bestDist = Infinity; + for (var ci = 0; ci < order.length; ci++) { + if (order[ci].id && order[ci].id !== '-1') continue; + var off = ci <= order.length / 2 ? ci : ci - order.length; + var slotY = hudSlotY(frameCenter, off, tokenSize, tokenPadding); + var dist = Math.abs(txtY - slotY); + if (dist < bestDist) { bestDist = dist; bestOffset = ci; } + } + if (bestOffset !== null) { + txt.set('text', String(order[bestOffset].pr || '')); + } + }); + // Reflow only token entries by ID order.forEach(function(entry, i) { if (!entry.id || entry.id === '-1') return; @@ -4297,10 +4335,23 @@ var Gaslight = Gaslight || (() => { if (newLeft !== oldLeft && verticalDrift < tknSizeH / 2 && horizontalEscape) { var order = JSON.parse(Campaign().get('turnorder') || '[]'); var sourceId = match.sourceId; - // Rotate until this token's master is at position 0 + var swipeDirection = newLeft > frameRightEdge ? 'forward' : 'backward'; + // Rotate until this token's master is at position 0, applying formulas along the way var safety = order.length; while (safety-- > 0 && order.length > 0 && order[0].id !== sourceId) { - order.push(order.shift()); + // Apply formula to custom turns as we pass them + if (order[0].id === '-1' && order[0].formula) { + var f = order[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDirection === 'backward') add = !add; + order[0].pr = (parseFloat(order[0].pr) || 0) + (add ? v : -v); + } + if (swipeDirection === 'forward') { + order.push(order.shift()); + } else { + order.unshift(order.pop()); + } } _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(order)); @@ -4415,17 +4466,33 @@ var Gaslight = Gaslight || (() => { var frameLeftEdge = frameLeft - fw2 / 2; if (newX > frameRightEdge || newX < frameLeftEdge) { - // Find which custom turn this is in the full order + var swipeDir = newX > frameRightEdge ? 'forward' : 'backward'; var fullOrder = JSON.parse(Campaign().get('turnorder') || '[]'); var customsInOrder = []; fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); if (customRank !== -1 && customsInOrder[customRank] !== undefined) { var targetFullIdx = customsInOrder[customRank]; - // Rotate until this entry is at position 0 - var rotated = fullOrder.slice(targetFullIdx).concat(fullOrder.slice(0, targetFullIdx)); + // Calculate how many rotations needed + var rotCount = swipeDir === 'forward' + ? targetFullIdx + : fullOrder.length - targetFullIdx; + for (var ri = 0; ri < rotCount; ri++) { + if (fullOrder[0].id === '-1' && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } + if (swipeDir === 'forward') { + fullOrder.push(fullOrder.shift()); + } else { + fullOrder.unshift(fullOrder.pop()); + } + } _suppressTurnSync = true; - Campaign().set('turnorder', JSON.stringify(rotated)); + Campaign().set('turnorder', JSON.stringify(fullOrder)); _suppressTurnSync = false; } } From 594b6232bf57f6734db63630cabf73bcf02f1999 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 16:41:52 -0400 Subject: [PATCH 29/41] Gaslight: fix swipe gestures + formula logic + pin handling - Current turn swipe: advance/retreat past linked children (not no-op) - Formula applies when custom ENTERS top (not when leaving) - Pin horizontal swipe: removed isCurrentTurn gate, added targetFullIdx=0 handling - Pin vertical vs horizontal: use horizontalEscapePin flag instead of early return - Fix duplicate var declarations in pin handler - Fix missing 's' variable in doSplit - Fix stray brace in pin handler - Tighten formula check in onTurnOrderChanged (compare custom+pr to old top) --- Gaslight/Gaslight.js | 158 +++++++++++++++++++++++++++++++------------ 1 file changed, 114 insertions(+), 44 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 234ecdde0..d3ec7dcd8 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1003,6 +1003,7 @@ var Gaslight = Gaslight || (() => { establishLinks(groupName, groupInfo, allLinks); // Enable relay by default on split + var s = state[SCRIPT_NAME]; s.view = 'master'; // Recreate HUD elements if enabled @@ -3238,9 +3239,9 @@ var Gaslight = Gaslight || (() => { // Only if we modified the order (if not modified, Roll20 handled it naturally and already applied the formula) if (modified && newOrder.length > 0 && newOrder[0].id === '-1' && newOrder[0].formula) { var topEntry = newOrder[0]; - var oldTopId = oldOrder.length > 0 ? oldOrder[0].id : null; - // Only apply if the top changed (this custom turn just became active) - if (oldTopId !== '-1' || (oldOrder[0] && oldOrder[0].custom !== topEntry.custom)) { + // Only apply if this custom turn was NOT already at the top + var wasAlreadyTop = oldOrder.length > 0 && oldOrder[0].id === '-1' && oldOrder[0].custom === topEntry.custom && oldOrder[0].pr === topEntry.pr; + if (!wasAlreadyTop) { var formula = topEntry.formula.trim(); var currentPr = parseFloat(topEntry.pr) || 0; var val = parseFloat(formula.replace(/^[+-]/, '')) || 0; @@ -4336,21 +4337,54 @@ var Gaslight = Gaslight || (() => { var order = JSON.parse(Campaign().get('turnorder') || '[]'); var sourceId = match.sourceId; var swipeDirection = newLeft > frameRightEdge ? 'forward' : 'backward'; - // Rotate until this token's master is at position 0, applying formulas along the way - var safety = order.length; - while (safety-- > 0 && order.length > 0 && order[0].id !== sourceId) { - // Apply formula to custom turns as we pass them - if (order[0].id === '-1' && order[0].formula) { + var isCurrentTurn = order.length > 0 && order[0].id === sourceId; + + if (isCurrentTurn) { + // Current turn swiped — advance/retreat to next/prev master or custom + if (swipeDirection === 'forward') { + order.push(order.shift()); + // Skip past linked children + var skip = order.length; + while (skip-- > 0 && order[0] && order[0].id && order[0].id !== '-1') { + var cInfo = getLinkedInfo(order[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + order.push(order.shift()); + } + } else { + order.unshift(order.pop()); + // Skip backward past linked children + var skip = order.length; + while (skip-- > 0 && order[0] && order[0].id && order[0].id !== '-1') { + var cInfo = getLinkedInfo(order[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + order.unshift(order.pop()); + } + } + // Apply formula if new top is a custom + if (order[0] && order[0].id === '-1' && order[0].formula) { var f = order[0].formula.trim(); var v = parseFloat(f.replace(/^[+-]/, '')) || 0; var add = f.startsWith('+'); if (swipeDirection === 'backward') add = !add; order[0].pr = (parseFloat(order[0].pr) || 0) + (add ? v : -v); } - if (swipeDirection === 'forward') { - order.push(order.shift()); - } else { - order.unshift(order.pop()); + } else { + // Non-current turn — rotate until target is at position 0 + var safety = order.length; + while (safety-- > 0 && order.length > 0 && order[0].id !== sourceId) { + if (swipeDirection === 'forward') { + order.push(order.shift()); + } else { + order.unshift(order.pop()); + } + } + // Apply formula if the new top is a custom turn + if (order[0] && order[0].id === '-1' && order[0].formula) { + var f = order[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDirection === 'backward') add = !add; + order[0].pr = (parseFloat(order[0].pr) || 0) + (add ? v : -v); } } _suppressTurnSync = true; @@ -4372,9 +4406,20 @@ var Gaslight = Gaslight || (() => { var newY = obj.get('y'); var oldY = prev.y; - if (newY === oldY) return; + + // Check if pin escaped horizontally (swipe, not reorder) + var newX = obj.get('x'); + var frame = getObj('pathv2', data.frameId); + var horizontalEscapePin = false; + if (frame) { + var frameLeftPin = frame.get('x'); + var ptsPin = JSON.parse(frame.get('points') || '[]'); + var fwPin = ptsPin.length >= 2 ? ptsPin[1][0] - ptsPin[0][0] : 70; + horizontalEscapePin = newX > frameLeftPin + fwPin / 2 || newX < frameLeftPin - fwPin / 2; + } // Pin dragged vertically — reorder custom turn in initiative + if (!horizontalEscapePin && newY !== oldY) { var order = JSON.parse(Campaign().get('turnorder') || '[]'); var hudOrder = getHudTurnOrder(); @@ -4452,50 +4497,75 @@ var Gaslight = Gaslight || (() => { Campaign().set('turnorder', JSON.stringify(order)); _suppressTurnSync = false; } + } // Pin dragged horizontally — make it this turn's turn - var newX = obj.get('x'); var oldX = prev.x; - var verticalDrift = Math.abs(obj.get('y') - prev.y); + var verticalDrift = Math.abs(newY - oldY); var tknSizeP = data.tokenSize || defaultInitHud.tokenSize; - if (newX !== oldX && verticalDrift < tknSizeP / 2 && frame) { - var frameLeft = frame.get('x'); - var pts2 = JSON.parse(frame.get('points') || '[]'); - var fw2 = pts2.length >= 2 ? pts2[1][0] - pts2[0][0] : 70; - var frameRightEdge = frameLeft + fw2 / 2; - var frameLeftEdge = frameLeft - fw2 / 2; - - if (newX > frameRightEdge || newX < frameLeftEdge) { - var swipeDir = newX > frameRightEdge ? 'forward' : 'backward'; - var fullOrder = JSON.parse(Campaign().get('turnorder') || '[]'); - var customsInOrder = []; - fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); - var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); - if (customRank !== -1 && customsInOrder[customRank] !== undefined) { - var targetFullIdx = customsInOrder[customRank]; - // Calculate how many rotations needed - var rotCount = swipeDir === 'forward' - ? targetFullIdx - : fullOrder.length - targetFullIdx; - for (var ri = 0; ri < rotCount; ri++) { - if (fullOrder[0].id === '-1' && fullOrder[0].formula) { - var f = fullOrder[0].formula.trim(); - var v = parseFloat(f.replace(/^[+-]/, '')) || 0; - var add = f.startsWith('+'); - if (swipeDir === 'backward') add = !add; - fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + if (newX !== oldX && verticalDrift < tknSizeP / 2 && horizontalEscapePin) { + var frameLeftH = frame.get('x'); + var ptsH = JSON.parse(frame.get('points') || '[]'); + var fwH = ptsH.length >= 2 ? ptsH[1][0] - ptsH[0][0] : 70; + var frameRightEdge = frameLeftH + fwH / 2; + var swipeDir = newX > frameRightEdge ? 'forward' : 'backward'; + var fullOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + + var customsInOrder = []; + fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); + var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (customRank !== -1 && customsInOrder[customRank] !== undefined) { + var targetFullIdx = customsInOrder[customRank]; + if (targetFullIdx === 0) { + // This pin is the current turn — advance/retreat to next/prev master or custom + if (swipeDir === 'forward') { + fullOrder.push(fullOrder.shift()); + var skip = fullOrder.length; + while (skip-- > 0 && fullOrder[0] && fullOrder[0].id && fullOrder[0].id !== '-1') { + var cInfo = getLinkedInfo(fullOrder[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + fullOrder.push(fullOrder.shift()); + } + } else { + fullOrder.unshift(fullOrder.pop()); + var skip = fullOrder.length; + while (skip-- > 0 && fullOrder[0] && fullOrder[0].id && fullOrder[0].id !== '-1') { + var cInfo = getLinkedInfo(fullOrder[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + fullOrder.unshift(fullOrder.pop()); } + } + // Apply formula if new top is a custom + if (fullOrder[0] && fullOrder[0].id === '-1' && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } + } else { + // Non-current pin — rotate to it + var rotCount = swipeDir === 'forward' ? targetFullIdx : fullOrder.length - targetFullIdx; + for (var ri = 0; ri < rotCount; ri++) { if (swipeDir === 'forward') { fullOrder.push(fullOrder.shift()); } else { fullOrder.unshift(fullOrder.pop()); } } - _suppressTurnSync = true; - Campaign().set('turnorder', JSON.stringify(fullOrder)); - _suppressTurnSync = false; + // Apply formula if the new top is a custom turn + if (fullOrder[0] && fullOrder[0].id === '-1' && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } } } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(fullOrder)); + _suppressTurnSync = false; } reflowInitiativeHud('none'); From 6e613a7e0aa5815fe63a7060fc4d36753a2d53fc Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 16:45:27 -0400 Subject: [PATCH 30/41] Gaslight: initiative HUD removed on merge, recreated on split --- Gaslight/Gaslight.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d3ec7dcd8..6eadaf634 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1008,6 +1008,7 @@ var Gaslight = Gaslight || (() => { // Recreate HUD elements if enabled if (s.hud.view) updateViewHud(); + if (s.hud.initiative) updateInitiativeHud(); var summary = 'Group "' + groupName + '" activated. ' + Object.keys(groupInfo.players).length + ' player(s), ' + @@ -1083,6 +1084,7 @@ var Gaslight = Gaslight || (() => { // Destroy HUD elements (preference preserved, will recreate on next split) removeViewHud(); + removeInitiativeHud(); }; const doTest = (msg, args) => { From 323223d769e66313615728bde2cda62fede67f9e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 17:38:56 -0400 Subject: [PATCH 31/41] Gaslight: initiative HUD - customizable highlight, slot-based visibility, tweaks - Highlight: 45deg rotation, stroke width 5, tokenSize+15, customizable styling - Moving highlight vertically sets current turn Y position (normalized 0-1) - Slot-based visibility: count actual slots that fit above/below indicator - Token padding default: 20px (token+padding = 70px = 1 grid cell) - Frame default: 5.5 tokens tall for easier indicator movement - Pin scale: 1.75 - Text offset: 25px - Highlight delete = just clear ID (recreated on next update, doesn't turn off HUD) - slotsAbove/slotsBelow computed from actual positions, not Math.floor --- Gaslight/Gaslight.js | 77 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 6eadaf634..225151f04 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3419,10 +3419,10 @@ var Gaslight = Gaslight || (() => { const defaultInitHud = { tokenSize: 50, - tokenPadding: 30, + tokenPadding: 20, vPadding: 15, hPadding: 10, - textOffset: 15, + textOffset: 25, textFontSize: 16, textFontFamily: 'Contrail One', textColor: '#ffffff', @@ -3430,6 +3430,10 @@ var Gaslight = Gaslight || (() => { frameStroke: '#ffffff', frameFill: 'transparent', frameStrokeWidth: 3, + highlightStroke: '#ffcc00', + highlightFill: 'transparent', + highlightStrokeWidth: 5, + currentTurnOffset: 0.5, entries: [], frameId: null, highlightId: null, @@ -3507,7 +3511,7 @@ var Gaslight = Gaslight || (() => { // Create frame if missing if (!data.frameId || !getObj('pathv2', data.frameId)) { var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; - var frameHeight = 5 * defaultInitHud.tokenSize + 4 * defaultInitHud.tokenPadding + 2 * defaultInitHud.vPadding; + var frameHeight = 5.5 * (defaultInitHud.tokenSize + defaultInitHud.tokenPadding) + 2 * defaultInitHud.vPadding; var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height, data); data.frameId = frame.get('id'); @@ -3521,17 +3525,25 @@ var Gaslight = Gaslight || (() => { // Create highlight indicator if missing if (!data.highlightId || !getObj('pathv2', data.highlightId)) { var hlTokenSize = data.tokenSize || defaultInitHud.tokenSize; - var hlSize = hlTokenSize + 6; + var hlSize = hlTokenSize + 15; + var hlOffset = data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset; + var frameH = JSON.parse(frame.get('points') || '[]'); + var fH = frameH.length >= 2 ? frameH[1][1] - frameH[0][1] : 510; + var vPadHL = data.vPadding || defaultInitHud.vPadding; + var usableTopHL = frame.get('y') - fH / 2 + hlTokenSize / 2 + vPadHL; + var usableBotHL = frame.get('y') + fH / 2 - hlTokenSize / 2 - vPadHL; + var hlY = usableTopHL + (usableBotHL - usableTopHL) * hlOffset; var highlight = createObj('pathv2', { _pageid: pageId, layer: 'foreground', shape: 'rec', x: frame.get('x'), - y: frame.get('y'), + y: hlY, points: JSON.stringify([[0, 0], [hlSize, hlSize]]), - stroke: '#ffcc00', - stroke_width: 3, - fill: 'transparent', + stroke: data.highlightStroke || defaultInitHud.highlightStroke, + stroke_width: data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth, + fill: data.highlightFill || defaultInitHud.highlightFill, + rotation: 45, }); data.highlightId = highlight.get('id'); } @@ -3588,7 +3600,7 @@ var Gaslight = Gaslight || (() => { bgColor: 'transparent', useTextIcon: true, textIcon: '', - scale: 2.0, + scale: 1.75, tooltipVisibleTo: '', }); @@ -3677,12 +3689,18 @@ var Gaslight = Gaslight || (() => { var vPadding = data.vPadding || defaultInitHud.vPadding; var frameTopEdge = frameTop - frameHeight / 2 + vPadding; var frameBotEdge = frameTop + frameHeight / 2 - vPadding; - var frameCenter = frameTop; // current turn positioned at frame center + var frameCenter = (frameTopEdge + tokenSize / 2) + (frameBotEdge - frameTopEdge - tokenSize) * (data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset); + // Calculate how many slots fit below and above the indicator + var slotsBelow = 0; + var slotsAbove = 0; + var step = tokenSize + tokenPadding; + while (frameCenter + (slotsBelow + 1) * step + tokenSize / 2 <= frameBotEdge) slotsBelow++; + while (frameCenter - (slotsAbove + 1) * step - tokenSize / 2 >= frameTopEdge) slotsAbove++; // Update highlight position and size var highlight = data.highlightId ? getObj('pathv2', data.highlightId) : null; if (highlight) { - var hlSize = tokenSize + 6; + var hlSize = tokenSize + 15; highlight.set({ x: frameLeft, y: frameCenter, @@ -3769,10 +3787,9 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var offset = i <= order.length / 2 ? i : i - order.length; + var offset = i <= slotsBelow ? i : i - order.length; + var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); - var visible = (yPos - tokenSize / 2 >= frameTopEdge) && - (yPos + tokenSize / 2 <= frameBotEdge); tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); if (txt) { @@ -3826,10 +3843,9 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!tok) return; - var offset = i <= order.length / 2 ? i : i - order.length; + var offset = i <= slotsBelow ? i : i - order.length; + var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); - var visible = (yPos - tokenSize / 2 >= frameTopEdge) && - (yPos + tokenSize / 2 <= frameBotEdge); if (tok.get('type') === 'graphic') { tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); @@ -4054,6 +4070,9 @@ var Gaslight = Gaslight || (() => { s.hud.initData.frameId = null; removeInitiativeHud(); sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + } else if (obj.get('id') === s.hud.initData.highlightId) { + // Highlight deleted — just clear ID, will be recreated on next update + s.hud.initData.highlightId = null; } }; @@ -4234,6 +4253,30 @@ var Gaslight = Gaslight || (() => { if (fill !== undefined) data.frameFill = fill; if (strokeWidth !== undefined) data.frameStrokeWidth = strokeWidth; reflowInitiativeHud('none'); + } else if (obj.get('id') === s.hud.initData.highlightId) { + var data = s.hud.initData; + // Track highlight styling + var hlStroke = obj.get('stroke'); + var hlFill = obj.get('fill'); + var hlStrokeWidth = obj.get('stroke_width'); + if (hlStroke !== undefined) data.highlightStroke = hlStroke; + if (hlFill !== undefined) data.highlightFill = hlFill; + if (hlStrokeWidth !== undefined) data.highlightStrokeWidth = hlStrokeWidth; + // Compute normalized Y offset (0=top+tokenSize/2+vPadding, 1=bottom-tokenSize/2-vPadding) + var frame = getObj('pathv2', data.frameId); + if (frame) { + var fTop = frame.get('y'); + var fPts = JSON.parse(frame.get('points') || '[]'); + var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; + var tknSz = data.tokenSize || defaultInitHud.tokenSize; + var vPad = data.vPadding || defaultInitHud.vPadding; + var usableTop = fTop - fHeight / 2 + tknSz / 2 + vPad; + var usableBot = fTop + fHeight / 2 - tknSz / 2 - vPad; + var hlY = obj.get('y'); + var norm = (hlY - usableTop) / (usableBot - usableTop); + data.currentTurnOffset = Math.max(0, Math.min(1, norm)); + } + reflowInitiativeHud('none'); } }); on('change:graphic', function(obj, prev) { From c1236b2ddc45bb7455ad5a8eb2e19dc21d8c2034 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 17:44:54 -0400 Subject: [PATCH 32/41] Gaslight: HUD on by default for new installs --- Gaslight/Gaslight.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 225151f04..33011d6fc 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -81,11 +81,11 @@ var Gaslight = Gaslight || (() => { activeGroups: {}, config: { autoCommit: false, relayCommands: [] }, view: 'master', - hud: { view: false, initiative: false } + hud: { view: true, initiative: true } }; } if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; - if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: false, initiative: false }; + if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: true, initiative: true }; if (state[SCRIPT_NAME].hud.initiative === undefined) state[SCRIPT_NAME].hud.initiative = false; // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { From f7cb5b029d7f2b24b0dfeebc3e2f8fb8d462f6fd Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 17:46:34 -0400 Subject: [PATCH 33/41] Updated modified timestamp --- Gaslight/Gaslight.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 33011d6fc..8ce837f75 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,6 +1,6 @@ // ============================================================================= // Gaslight v2.1.0 -// Last Updated: 2026-06-30 +// Last Updated: 2026-07-03 // Author: Kenan Millet // // Description: From 4dff040eddb70b9387d65935da681fb816b3a7ea Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 17:53:03 -0400 Subject: [PATCH 34/41] Gaslight: update README, HELP_TEXT, TODO for v2.1.0 HUD release --- Gaslight/Gaslight.js | 9 ++++++++- Gaslight/README.md | 33 +++++++++++++++++++++++++++++++++ Gaslight/TODO.md | 20 +++++++++++++++----- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 8ce837f75..f3a9f56f0 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1712,6 +1712,8 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + '' + CMD + ' view [master|off|<player>] -- Control relay targeting
' + '' + CMD + ' relay <views> <!cmd> -- Relay command to views
' + '' + CMD + ' group <group> <player|GM> -- Assign page
' @@ -1724,7 +1726,12 @@ var Gaslight = Gaslight || (() => { + 'master -- relay to all (default on split)
' + 'off -- relay disabled (GM-only changes)
' + '<player> -- relay to one player only
' - + '
Initiative: Linked tokens auto-sync in turn order. Non-master children are auto-skipped on turn advance.
'; + + '
Initiative: Linked tokens auto-sync in turn order. Non-master children are auto-skipped on turn advance.
' + + '
HUD:
' + + '' + CMD + ' hud -- toggle all elements
' + + '' + CMD + ' hud [on|off|reset] -- all elements
' + + '' + CMD + ' hud [init|view] [on|off|reset] -- specific element
' + + 'Aliases: init/turn/turns = initiative, relay = view
'; // ========================================================================= // Scripting Engine — Fetch Integration diff --git a/Gaslight/README.md b/Gaslight/README.md index ef4702cd5..8879a463a 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -112,6 +112,39 @@ Gaslight automatically syncs the turn order across linked tokens: The GM only interacts with master-page tokens in the turn tracker. Players see their own copies on their page. Linked children are skipped automatically when advancing turns. +## HUD + +On-canvas indicators on the master page foreground layer. Toggle with `!gaslight hud [element] [on|off|reset]`. + +### Elements + +| Element | Description | +|---------|-------------| +| `view` (alias: `relay`) | Shows current relay state: ALL / OFF / player name | +| `initiative` (aliases: `init`, `turn`, `turns`) | Visual initiative tracker with frame, tokens, and current turn indicator | + +### Initiative HUD Features + +- **Frame**: resizable rectangle containing initiative entries +- **Current turn indicator**: rotated diamond showing whose turn it is (movable to set position) +- **Token entries**: mirrored copies of master tokens (syncs name, status, tint) +- **Custom turn entries**: pins with titles for custom initiative entries +- **Overflow**: entries outside the frame are hidden automatically +- **Drag to reorder**: drag a token/pin vertically between others to change initiative order +- **Swipe to change turn**: drag a token/pin horizontally past the frame edge to make it the current turn +- **Round calculation**: custom turn formulas applied when entering the top via rotation +- **Delete entry**: deleting a HUD token/pin removes it from initiative (deleting the frame turns HUD off) +- **Fully customizable**: frame stroke/fill, text color/stroke/font, token size derived from frame width, all positions persist + +### Commands + +- `!gaslight hud` — toggle all elements +- `!gaslight hud on` — turn all on +- `!gaslight hud off` — turn all off +- `!gaslight hud reset` — reset all to defaults (turns on) +- `!gaslight hud initiative` — toggle initiative +- `!gaslight hud reset view` — reset just view element + ## Staging - `!gaslight stage` — propagate selected tokens to all player pages diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 8226f4805..e6ca43d80 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -37,12 +37,22 @@ - [ ] View + relay with both paths - [ ] Focus-ping on split +## Done (v2.1.0) +- [x] Initiative tracking (add/remove/sync/reorder/direction-aware skip) +- [x] Token-level gaslight_sync (gmnotes, same pattern as gaslight_link) +- [x] !gaslight sync / !gaslight desync commands +- [x] !gaslight view off (relay disabled mode) +- [x] GM HUD system — persistent on-canvas indicators: + - [x] `view` — relay state indicator (fully customizable styling, normalized position) + - [x] `initiative` — on-canvas turn order (frame + mirrored tokens + custom pins + highlight) + - Drag to reorder, swipe to change turn, round calculation formulas + - Customizable: frame/highlight styling, token size, padding, text offset/font/color + - Current turn indicator movable (sets Y offset for current turn position) + - Slot-based visibility, overflow hidden +- [x] State migration v2.0.0 → v2.1.0 + ## v2 Ideas -- [ ] GM HUD system — persistent on-canvas indicators, each toggled via `!gaslight hud [on|off]`: - - `initiative` — on-canvas turn order display (tokens + text on foreground layer, one per entry). Deduplicates linked copies so GM sees a clean list. - - `view` — big text showing current relay state (OFF / ALL / player name) - - `links` — ascii-art boxes around linked tokens with sync/desync annotations - - `group` — active group indicator +- [ ] HUD `links` element — visual overlay showing linked tokens with sync/desync annotations - [ ] Diff command (`!gaslight diff `) — show what's different between master and a player's page - [ ] Batch operations — `--all` flag on sync/desync to affect all linked tokens on the page - [ ] `!gaslight relay all --except ` flag From 33fa67229b2e36f49b61a46cab4f1bf5c982c102 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 3 Jul 2026 17:53:03 -0400 Subject: [PATCH 35/41] Gaslight: update versioned copy (2.1.0) --- Gaslight/2.1.0/Gaslight.js | 1453 ++++++++++++++++++++++++++++++++++-- 1 file changed, 1411 insertions(+), 42 deletions(-) diff --git a/Gaslight/2.1.0/Gaslight.js b/Gaslight/2.1.0/Gaslight.js index 47ebdd60d..f3a9f56f0 100644 --- a/Gaslight/2.1.0/Gaslight.js +++ b/Gaslight/2.1.0/Gaslight.js @@ -1,6 +1,6 @@ // ============================================================================= // Gaslight v2.1.0 -// Last Updated: 2026-06-30 +// Last Updated: 2026-07-03 // Author: Kenan Millet // // Description: @@ -80,10 +80,13 @@ var Gaslight = Gaslight || (() => { state[SCRIPT_NAME] = { activeGroups: {}, config: { autoCommit: false, relayCommands: [] }, - view: 'master' + view: 'master', + hud: { view: true, initiative: true } }; } if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: true, initiative: true }; + if (state[SCRIPT_NAME].hud.initiative === undefined) state[SCRIPT_NAME].hud.initiative = false; // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; @@ -1000,8 +1003,13 @@ var Gaslight = Gaslight || (() => { establishLinks(groupName, groupInfo, allLinks); // Enable relay by default on split + var s = state[SCRIPT_NAME]; s.view = 'master'; + // Recreate HUD elements if enabled + if (s.hud.view) updateViewHud(); + if (s.hud.initiative) updateInitiativeHud(); + var summary = 'Group "' + groupName + '" activated. ' + Object.keys(groupInfo.players).length + ' player(s), ' + allLinks.length + ' link(s) established.'; @@ -1073,6 +1081,10 @@ var Gaslight = Gaslight || (() => { }); reply(msg, 'Merge', 'Merged ' + groupsToMerge.length + ' group(s). Players returned to shared page.'); + + // Destroy HUD elements (preference preserved, will recreate on next split) + removeViewHud(); + removeInitiativeHud(); }; const doTest = (msg, args) => { @@ -1317,6 +1329,7 @@ var Gaslight = Gaslight || (() => { s.view = resolved.id; reply(msg, 'View', 'Switched to ' + resolved.name + ' view. Commands will auto-target their linked tokens.'); } + updateViewHud(); }; /** @@ -1699,6 +1712,8 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + '' + CMD + ' view [master|off|<player>] -- Control relay targeting
' + '' + CMD + ' relay <views> <!cmd> -- Relay command to views
' + '' + CMD + ' group <group> <player|GM> -- Assign page
' @@ -1711,7 +1726,12 @@ var Gaslight = Gaslight || (() => { + 'master -- relay to all (default on split)
' + 'off -- relay disabled (GM-only changes)
' + '<player> -- relay to one player only
' - + '
Initiative: Linked tokens auto-sync in turn order. Non-master children are auto-skipped on turn advance.
'; + + '
Initiative: Linked tokens auto-sync in turn order. Non-master children are auto-skipped on turn advance.
' + + '
HUD:
' + + '' + CMD + ' hud -- toggle all elements
' + + '' + CMD + ' hud [on|off|reset] -- all elements
' + + '' + CMD + ' hud [init|view] [on|off|reset] -- specific element
' + + 'Aliases: init/turn/turns = initiative, relay = view
'; // ========================================================================= // Scripting Engine — Fetch Integration @@ -2555,6 +2575,7 @@ var Gaslight = Gaslight || (() => { case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; + case 'hud': doHud(msg, args); break; case 'eval': doEval(msg, args); break; case 'status': doStatus(msg); break; case '--script-lock': @@ -2857,6 +2878,17 @@ var Gaslight = Gaslight || (() => { log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) buildTriggerMap(); + + // HUD recovery: if enabled but object is missing, recreate; if disabled but object exists, clean up + var s = state[SCRIPT_NAME]; + if (s.hud.view) { + var existing = findHudElement('view'); + if (!existing) updateViewHud(); + } else if (s.hud.viewId) { + var orphan = getObj('text', s.hud.viewId); + if (orphan) orphan.remove(); + s.hud.viewId = null; + } }; /** @@ -3066,6 +3098,31 @@ var Gaslight = Gaslight || (() => { var newOrder = JSON.parse(obj.get('turnorder') || '[]'); var oldOrder = JSON.parse(prev.turnorder || '[]'); + // Detect direction early (before skip/reorder modifies newOrder) + var hudDirection = 'none'; + if (oldOrder.length === newOrder.length && newOrder.length > 0) { + var targetEntry = newOrder.find(function(e) { return e.id && e.id !== '-1'; }); + if (targetEntry) { + var newIdx = newOrder.indexOf(targetEntry); + var oldIdx = -1; + for (var oi = 0; oi < oldOrder.length; oi++) { + if (oldOrder[oi].id === targetEntry.id) { oldIdx = oi; break; } + } + if (oldIdx !== -1 && oldIdx !== newIdx) { + var rotAmount = oldIdx - newIdx; + var len = newOrder.length; + // Normalize rotation + rotAmount = ((rotAmount % len) + len) % len; + var rotated = newOrder.slice(len - rotAmount).concat(newOrder.slice(0, len - rotAmount)); + var oldIdStr = oldOrder.map(function(e) { return e.id; }).join(','); + var rotIdStr = rotated.map(function(e) { return e.id; }).join(','); + if (rotIdStr === oldIdStr) { + hudDirection = rotAmount <= len / 2 ? 'forward' : 'backward'; + } + } + } + } + // Detect additions: entries in newOrder not in oldOrder var oldIds = new Set(oldOrder.map(function(e) { return e.id; })); var newIds = new Set(newOrder.map(function(e) { return e.id; })); @@ -3080,9 +3137,13 @@ var Gaslight = Gaslight || (() => { added.forEach(function(entry) { var info = getLinkedInfo(entry.id); if (info.linkedIds.length === 0) return; - // Add linked tokens that aren't already in the order + // Deduplicate linked IDs (can accumulate duplicates from repeated splits) + var uniqueLinkedIds = info.linkedIds.filter(function(id, i) { return info.linkedIds.indexOf(id) === i; }); + // Only process if none of this entry's linked tokens are already in the order var existingIds = new Set(newOrder.map(function(e) { return e.id; })); - info.linkedIds.forEach(function(linkedId) { + var alreadyHasLinked = uniqueLinkedIds.some(function(lid) { return existingIds.has(lid); }); + if (alreadyHasLinked) return; + uniqueLinkedIds.forEach(function(linkedId) { if (existingIds.has(linkedId)) return; var linkedObj = getObj('graphic', linkedId); var pushEntry = { id: linkedId, pr: entry.pr }; @@ -3139,33 +3200,34 @@ var Gaslight = Gaslight || (() => { var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; - var startId = topId; - var safety = newOrder.length; - - if (isForward) { - // Rotate forward: shift to end until master/unlinked is on top - while (safety-- > 0 && newOrder.length > 1) { - newOrder.push(newOrder.shift()); - var cId = newOrder[0].id; - if (!cId || cId === '-1') break; - if (cId === startId) break; - var cInfo = getLinkedInfo(cId); - if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; - } - modified = true; - } else if (isBackward) { - // Rotate backward: pop from end to front until master/unlinked is on top - while (safety-- > 0 && newOrder.length > 1) { - newOrder.unshift(newOrder.pop()); - var cId = newOrder[0].id; - if (!cId || cId === '-1') break; - if (cId === startId) break; - var cInfo = getLinkedInfo(cId); - if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + if (isForward || isBackward) { + var startId = topId; + var safety = newOrder.length; + + if (isForward) { + // Rotate forward: shift to end until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.push(newOrder.shift()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + } else { + // Rotate backward: pop from end to front until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.unshift(newOrder.pop()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } } modified = true; - } else if (added.length === 0 && removed.length === 0) { - // Neither forward nor backward and no add/remove — likely a sort; reorder groups + } else { + // Not forward/backward — manual drag. Reorder to group children after master. var reordered = reorderInitiative(newOrder); if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { newOrder = reordered; @@ -3173,16 +3235,29 @@ var Gaslight = Gaslight || (() => { } } } - } else if (added.length === 0 && removed.length === 0 && newOrder.length > 1) { - // No child on top, but check if sort happened (order changed without add/remove) - var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; - var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; - if (!isForward && !isBackward && JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) { - var reordered = reorderInitiative(newOrder); - if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { - newOrder = reordered; - modified = true; - } + } + + // Always ensure linked tokens are grouped below their master + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + + // Apply round calculation for custom turns that reached the top via our rotation + // Only if we modified the order (if not modified, Roll20 handled it naturally and already applied the formula) + if (modified && newOrder.length > 0 && newOrder[0].id === '-1' && newOrder[0].formula) { + var topEntry = newOrder[0]; + // Only apply if this custom turn was NOT already at the top + var wasAlreadyTop = oldOrder.length > 0 && oldOrder[0].id === '-1' && oldOrder[0].custom === topEntry.custom && oldOrder[0].pr === topEntry.pr; + if (!wasAlreadyTop) { + var formula = topEntry.formula.trim(); + var currentPr = parseFloat(topEntry.pr) || 0; + var val = parseFloat(formula.replace(/^[+-]/, '')) || 0; + var isAdd = formula.startsWith('+'); + // Forward = apply normally, backward = apply inverse + if (hudDirection === 'backward') isAdd = !isAdd; + topEntry.pr = isAdd ? currentPr + val : currentPr - val; } } @@ -3192,6 +3267,11 @@ var Gaslight = Gaslight || (() => { Campaign().set('turnorder', finalJson); _suppressTurnSync = false; } + + // Update initiative HUD if enabled + if (state[SCRIPT_NAME].hud.initiative) { + updateInitiativeHud(hudDirection); + } }; /** @@ -3206,7 +3286,7 @@ var Gaslight = Gaslight || (() => { if (placed.has(entry.id)) return; if (!entry.id || entry.id === '-1') { result.push(entry); - placed.add(entry.id); + // Don't add '-1' to placed — multiple custom entries share this ID return; } @@ -3218,6 +3298,9 @@ var Gaslight = Gaslight || (() => { return; } + // Skip children — they'll be pulled in when we reach their master + if (!isMasterToken(entry.id)) return; + // Find all entries in this link group var groupIds = [entry.id].concat(info.linkedIds); var groupEntries = order.filter(function(e) { @@ -3240,6 +3323,882 @@ var Gaslight = Gaslight || (() => { return result; }; + // ========================================================================= + // HUD System — on-canvas indicators + // ========================================================================= + + const HUD_PREFIX = 'gaslight_hud_'; + + /** + * Get the master page ID from the first active group. + */ + const getHudPageId = () => { + var s = state[SCRIPT_NAME]; + var group = Object.values(s.activeGroups)[0]; + return group ? group.masterPageId : null; + }; + + /** + * Find an existing HUD text object by stored ID. + */ + const findHudElement = (name) => { + var s = state[SCRIPT_NAME]; + var id = s.hud[name + 'Id']; + if (!id) return null; + return getObj('text', id) || null; + }; + + /** + * Create or update the view HUD element. + */ + const updateViewHud = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.view) return; + + var pageId = getHudPageId(); + if (!pageId) return; + + var page = getObj('page', pageId); + if (!page) return; + + // Determine display text + var label; + if (s.view === null) { + label = '🔴 RELAY OFF'; + } else if (s.view === 'master') { + label = '🟢 RELAY: ALL'; + } else { + // Resolve player name + var playerName = s.view; + Object.values(s.activeGroups).forEach(function(g) { + var entry = g.playerPages[s.view]; + if (entry && entry.name) playerName = entry.name; + }); + label = '🔵 VIEW: ' + playerName; + } + + // Find existing or create new + var existing = findHudElement('view'); + if (existing) { + existing.set('text', label); + } else { + // Use stored position/size or defaults + var vd = s.hud.viewData || {}; + var pageWidth = page.get('width') * 70; + var obj = createObj('text', { + _pageid: pageId, + layer: 'foreground', + text: label, + left: Math.round((vd.leftNorm || defaultViewHud.leftNorm) * pageWidth), + top: vd.top || defaultViewHud.top, + font_size: vd.fontSize || defaultViewHud.fontSize, + color: vd.color || defaultViewHud.color, + stroke: vd.stroke || defaultViewHud.stroke, + font_family: vd.fontFamily || defaultViewHud.fontFamily, + }); + s.hud.viewId = obj.get('id'); + } + }; + + /** + * Remove the view HUD element (programmatic). Clears ID first so destroy handler ignores it. + */ + const removeViewHud = () => { + var s = state[SCRIPT_NAME]; + var id = s.hud.viewId; + s.hud.viewId = null; + if (id) { + var obj = getObj('text', id); + if (obj) obj.remove(); + } + }; + + // ---- Initiative HUD ---- + + const defaultViewHud = { + leftNorm: 0.5, + top: 100, + fontSize: 40, + fontFamily: 'Contrail One', + color: '#ffffff', + stroke: '#000000', + }; + + const defaultInitHud = { + tokenSize: 50, + tokenPadding: 20, + vPadding: 15, + hPadding: 10, + textOffset: 25, + textFontSize: 16, + textFontFamily: 'Contrail One', + textColor: '#ffffff', + textStroke: '#000000', + frameStroke: '#ffffff', + frameFill: 'transparent', + frameStrokeWidth: 3, + highlightStroke: '#ffcc00', + highlightFill: 'transparent', + highlightStrokeWidth: 5, + currentTurnOffset: 0.5, + entries: [], + frameId: null, + highlightId: null, + pos: null, + frameSize: null, + }; + + const hudSlotY = (frameCenter, offset, tokenSize, tokenPadding) => { + return frameCenter + offset * (tokenSize + tokenPadding); + }; + const hudPinY = (frameCenter, offset, tokenSize, tokenPadding) => { + return hudSlotY(frameCenter, offset, tokenSize, tokenPadding) + tokenSize / 2; + }; + + /** + * Compute token size from frame width or stored override. + */ + const getHudTokenSize = (frame) => { + var s = state[SCRIPT_NAME]; + if (s.hud.initData && s.hud.initData.tokenSize) return s.hud.initData.tokenSize; + var points = JSON.parse(frame.get('points') || '[]'); + var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : defaultInitHud.tokenSize + 2 * defaultInitHud.hPadding; + var hPad = (s.hud.initData && s.hud.initData.hPadding) || defaultInitHud.hPadding; + return Math.max(10, frameWidth - 2 * hPad); + }; + + /** + * Get the deduped turn order (master tokens only, skip children). + */ + const getHudTurnOrder = () => { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + return order.filter(function(entry) { + if (!entry.id || entry.id === '-1') return true; + return isMasterToken(entry.id); + }); + }; + + /** + * Draw a rectangle path on the foreground layer. + */ + const createFramePath = (pageId, left, top, width, height, data) => { + return createObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: 'rec', + x: left, + y: top, + points: JSON.stringify([[0, 0], [width, height]]), + stroke: (data && data.frameStroke) || defaultInitHud.frameStroke, + stroke_width: (data && data.frameStrokeWidth) || defaultInitHud.frameStrokeWidth, + fill: (data && data.frameFill) || defaultInitHud.frameFill, + }); + }; + + /** + * Build/rebuild the initiative HUD. + */ + const updateInitiativeHud = (direction) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative) return; + + var pageId = getHudPageId(); + if (!pageId) return; + + var page = getObj('page', pageId); + if (!page) return; + + var order = getHudTurnOrder(); + if (!s.hud.initData) s.hud.initData = {}; + if (!s.hud.initData.entries) s.hud.initData.entries = []; + var data = s.hud.initData; + + var frameWidth = defaultInitHud.tokenSize + 2 * defaultInitHud.hPadding; + + // Create frame if missing + if (!data.frameId || !getObj('pathv2', data.frameId)) { + var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; + var frameHeight = 5.5 * (defaultInitHud.tokenSize + defaultInitHud.tokenPadding) + 2 * defaultInitHud.vPadding; + var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; + var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height, data); + data.frameId = frame.get('id'); + data.pos = pos; + data.frameSize = frameSize; + } + + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + + // Create highlight indicator if missing + if (!data.highlightId || !getObj('pathv2', data.highlightId)) { + var hlTokenSize = data.tokenSize || defaultInitHud.tokenSize; + var hlSize = hlTokenSize + 15; + var hlOffset = data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset; + var frameH = JSON.parse(frame.get('points') || '[]'); + var fH = frameH.length >= 2 ? frameH[1][1] - frameH[0][1] : 510; + var vPadHL = data.vPadding || defaultInitHud.vPadding; + var usableTopHL = frame.get('y') - fH / 2 + hlTokenSize / 2 + vPadHL; + var usableBotHL = frame.get('y') + fH / 2 - hlTokenSize / 2 - vPadHL; + var hlY = usableTopHL + (usableBotHL - usableTopHL) * hlOffset; + var highlight = createObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: 'rec', + x: frame.get('x'), + y: hlY, + points: JSON.stringify([[0, 0], [hlSize, hlSize]]), + stroke: data.highlightStroke || defaultInitHud.highlightStroke, + stroke_width: data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth, + fill: data.highlightFill || defaultInitHud.highlightFill, + rotation: 45, + }); + data.highlightId = highlight.get('id'); + } + + var tokenSize = getHudTokenSize(frame); + var frameLeft = frame.get('x'); + var frameTop = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; + var frameTopEdge = frameTop - frameHeight / 2; + + // Remove old entries no longer in the order + // For tokens: match by ID. For customs: if order has fewer customs than we have, remove extras. + var orderTokenIds = order.filter(function(e) { return e.id && e.id !== '-1'; }).map(function(e) { return e.id; }); + var orderCustomCount = order.filter(function(e) { return !e.id || e.id === '-1'; }).length; + var currentCustomCount = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).length; + + var toRemove = []; + var customsToRemove = currentCustomCount - orderCustomCount; + data.entries.forEach(function(entry, i) { + if (entry.sourceId && entry.sourceId.startsWith('custom:')) { + if (customsToRemove > 0) { toRemove.push(i); customsToRemove--; } + } else if (orderTokenIds.indexOf(entry.sourceId) === -1) { + toRemove.push(i); + } + }); + toRemove.reverse().forEach(function(i) { + var entry = data.entries[i]; + var tok = getObj('graphic', entry.tokenId) || getObj('pin', entry.tokenId); + var txt = getObj('text', entry.textId); + // Splice first so destroy handler ignores + data.entries.splice(i, 1); + if (tok) { if (typeof Mirror !== 'undefined' && entry.sourceId && !entry.sourceId.startsWith('custom:')) Mirror.unlink([tok.get('id')]); tok.remove(); } + if (txt) txt.remove(); + }); + + // Add new entries not yet in the HUD + var existingTokenIds = data.entries.filter(function(e) { return !e.sourceId.startsWith('custom:'); }).map(function(e) { return e.sourceId; }); + var customsToAdd = orderCustomCount - data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).length; + + order.forEach(function(entry) { + var isCustom = !entry.id || entry.id === '-1'; + + if (isCustom) { + if (customsToAdd <= 0) return; + customsToAdd--; + + var hudPin = createObj('pin', { + _pageid: pageId, + x: frameLeft, + y: -5000, + title: entry.custom || 'Custom', + shape: 'circle', + bgColor: 'transparent', + useTextIcon: true, + textIcon: '', + scale: 1.75, + tooltipVisibleTo: '', + }); + + var pinText = createObj('text', { + _pageid: pageId, + layer: 'foreground', + text: String(entry.pr || ''), + left: -5000, + top: -5000, + font_size: data.textFontSize || defaultInitHud.textFontSize, + color: data.textColor || defaultInitHud.textColor, + stroke: data.textStroke || defaultInitHud.textStroke, + font_family: data.textFontFamily || defaultInitHud.textFontFamily, + }); + + data.entries.push({ + sourceId: 'custom:' + Date.now() + ':' + Math.random().toString(36).slice(2, 6), + tokenId: hudPin ? hudPin.get('id') : null, + textId: pinText.get('id'), + }); + } else { + if (existingTokenIds.indexOf(entry.id) !== -1) return; + + var sourceToken = getObj('graphic', entry.id); + if (!sourceToken) return; + + var hudToken = createObj('graphic', { + _pageid: pageId, + layer: 'foreground', + imgsrc: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), + left: frameLeft, + top: frameTopEdge + (data.tokenPadding || defaultInitHud.tokenPadding) + tokenSize / 2, + width: tokenSize, + height: tokenSize, + showname: true, + name: sourceToken.get('name'), + baseOpacity: 1, + isdrawing: true, + }); + + var hudText = createObj('text', { + _pageid: pageId, + layer: 'foreground', + text: String(entry.pr || ''), + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), + top: frameTopEdge + (data.tokenPadding || defaultInitHud.tokenPadding) + tokenSize / 2, + font_size: data.textFontSize || defaultInitHud.textFontSize, + color: data.textColor || defaultInitHud.textColor, + stroke: data.textStroke || defaultInitHud.textStroke, + font_family: data.textFontFamily || defaultInitHud.textFontFamily, + }); + + if (typeof Mirror !== 'undefined') { + Mirror.link([sourceToken.get('id'), hudToken.get('id')], ['name', 'statusmarkers', 'tint_color'], { soft: true }); + } + + data.entries.push({ + sourceId: entry.id, + tokenId: hudToken.get('id'), + textId: hudText.get('id'), + }); + } + }); + + reflowInitiativeHud(direction); + }; + + /** + * Reflow initiative HUD: current turn at top, overflow hidden. + */ + const reflowInitiativeHud = (direction) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + + var order = getHudTurnOrder(); + var frameLeft = frame.get('x'); + var frameTop = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; + var tokenSize = getHudTokenSize(frame); + var tokenPadding = data.tokenPadding || defaultInitHud.tokenPadding; + var vPadding = data.vPadding || defaultInitHud.vPadding; + var frameTopEdge = frameTop - frameHeight / 2 + vPadding; + var frameBotEdge = frameTop + frameHeight / 2 - vPadding; + var frameCenter = (frameTopEdge + tokenSize / 2) + (frameBotEdge - frameTopEdge - tokenSize) * (data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset); + // Calculate how many slots fit below and above the indicator + var slotsBelow = 0; + var slotsAbove = 0; + var step = tokenSize + tokenPadding; + while (frameCenter + (slotsBelow + 1) * step + tokenSize / 2 <= frameBotEdge) slotsBelow++; + while (frameCenter - (slotsAbove + 1) * step - tokenSize / 2 >= frameTopEdge) slotsAbove++; + + // Update highlight position and size + var highlight = data.highlightId ? getObj('pathv2', data.highlightId) : null; + if (highlight) { + var hlSize = tokenSize + 15; + highlight.set({ + x: frameLeft, + y: frameCenter, + points: JSON.stringify([[0, 0], [hlSize, hlSize]]), + }); + } + var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : tokenSize + 2 * (data.hPadding || defaultInitHud.hPadding); + + // Match order entries to HUD entries + var tokenMap = {}; + data.entries.forEach(function(e) { + if (e.sourceId && !e.sourceId.startsWith('custom:')) tokenMap[e.sourceId] = e; + }); + var customEntries = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + + if (direction === 'forward' || direction === 'backward') { + // Simple shift: move all custom pins/text by one slot, wrap at edges + var shift = direction === 'forward' + ? -(tokenSize + tokenPadding) + : (tokenSize + tokenPadding); + + // Gather all HUD text Y positions (tokens + customs) + var allTextYs = data.entries.map(function(e) { + var t = getObj('text', e.textId); + return t ? t.get('top') : null; + }).filter(function(y) { return y !== null; }); + var minTextY = Math.min.apply(null, allTextYs); + var maxTextY = Math.max.apply(null, allTextYs); + + customEntries.forEach(function(e) { + var pin = getObj('pin', e.tokenId); + var txt = getObj('text', e.textId); + if (!txt) return; + var currentY = txt.get('top'); + var newSlotY; + + if (direction === 'forward' && currentY === minTextY) { + // Wrapping: place at where the current max will be after shift + newSlotY = maxTextY; + } else if (direction === 'backward' && currentY === maxTextY) { + // Wrapping: place at where the current min will be after shift + newSlotY = minTextY; + } else { + newSlotY = currentY + shift; + } + + var visible = (newSlotY - tokenSize / 2 >= frameTopEdge) && + (newSlotY + tokenSize / 2 <= frameBotEdge); + var newPinY = newSlotY + tokenSize / 2; + if (pin) { + pin.set({ x: visible ? frameLeft : -5000, y: visible ? newPinY : -5000 }); + } + txt.set({ top: newSlotY, color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent' }); + }); + + // Update custom text values from current order based on position + var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + customEntries.forEach(function(e) { + var txt = getObj('text', e.textId); + if (!txt) return; + var txtY = txt.get('top'); + // Find which order slot this text is closest to + var bestOffset = null; + var bestDist = Infinity; + for (var ci = 0; ci < order.length; ci++) { + if (order[ci].id && order[ci].id !== '-1') continue; + var off = ci <= order.length / 2 ? ci : ci - order.length; + var slotY = hudSlotY(frameCenter, off, tokenSize, tokenPadding); + var dist = Math.abs(txtY - slotY); + if (dist < bestDist) { bestDist = dist; bestOffset = ci; } + } + if (bestOffset !== null) { + txt.set('text', String(order[bestOffset].pr || '')); + } + }); + + // Reflow only token entries by ID + order.forEach(function(entry, i) { + if (!entry.id || entry.id === '-1') return; + var hudEntry = tokenMap[entry.id]; + if (!hudEntry) return; + + var tok = getObj('graphic', hudEntry.tokenId); + var txt = getObj('text', hudEntry.textId); + if (!tok) return; + + var offset = i <= slotsBelow ? i : i - order.length; + var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); + var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); + + tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); + if (txt) { + txt.set({ + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), + top: yPos, + color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', + text: String(entry.pr || ''), + }); + } + }); + } else { + // Full reflow (sort/add/remove): match customs by pr value + var visibleCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return obj && obj.get('y') > -1000; + }); + var hiddenCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return !obj || obj.get('y') <= -1000; + }); + var sortedCustoms = []; + var usedVisible = new Set(); + var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + customOrderEntries.forEach(function(orderEntry) { + var prVal = String(orderEntry.pr || ''); + var match = visibleCustoms.findIndex(function(e, i) { + if (usedVisible.has(i)) return false; + var txt = getObj('text', e.textId); + return txt && txt.get('text') === prVal; + }); + if (match !== -1) { + sortedCustoms.push(visibleCustoms[match]); + usedVisible.add(match); + } else if (hiddenCustoms.length > 0) { + sortedCustoms.push(hiddenCustoms.shift()); + } + }); + visibleCustoms.forEach(function(e, i) { + if (!usedVisible.has(i)) sortedCustoms.push(e); + }); + sortedCustoms = sortedCustoms.concat(hiddenCustoms); + var customIdx = 0; + + order.forEach(function(entry, i) { + var isCustom = !entry.id || entry.id === '-1'; + var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; + if (!hudEntry) return; + + var tok = getObj('graphic', hudEntry.tokenId) || getObj('pin', hudEntry.tokenId); + var txt = getObj('text', hudEntry.textId); + if (!tok) return; + + var offset = i <= slotsBelow ? i : i - order.length; + var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); + var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); + + if (tok.get('type') === 'graphic') { + tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); + } else { + tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000, title: entry.custom || 'Custom' }); + } + + if (txt) { + txt.set({ + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), + top: yPos, + color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', + text: String(entry.pr || ''), + }); + } + }); + } + }; + + /** + * Remove the initiative HUD (programmatic). + */ + const removeInitiativeHud = () => { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData; + if (!data) return; + + var frameId = data.frameId; + var highlightId = data.highlightId; + var entries = data.entries.slice(); + // Clear IDs first so destroy handler ignores + data.frameId = null; + data.highlightId = null; + data.entries = []; + + if (frameId) { + var frame = getObj('pathv2', frameId); + if (frame) frame.remove(); + } + if (highlightId) { + var hl = getObj('pathv2', highlightId); + if (hl) hl.remove(); + } + entries.forEach(function(entry) { + var tok = getObj('graphic', entry.tokenId) || getObj('pin', entry.tokenId); + var txt = getObj('text', entry.textId); + if (tok) { if (typeof Mirror !== 'undefined' && entry.sourceId && !entry.sourceId.startsWith('custom:')) Mirror.unlink([tok.get('id')]); tok.remove(); } + if (txt) txt.remove(); + }); + }; + + /** + * Handle change:text — persist HUD element position/size if moved by GM. + */ + const onHudTextChanged = (obj) => { + var s = state[SCRIPT_NAME]; + var id = obj.get('id'); + if (id === s.hud.viewId) { + if (!s.hud.viewData) s.hud.viewData = {}; + var vPageId = getHudPageId(); + var vPage = vPageId ? getObj('page', vPageId) : null; + var vPageWidth = vPage ? vPage.get('width') * 70 : 1400; + s.hud.viewData.leftNorm = obj.get('left') / vPageWidth; + s.hud.viewData.top = obj.get('top'); + s.hud.viewData.fontSize = obj.get('font_size'); + s.hud.viewData.fontFamily = obj.get('font_family'); + var vColor = obj.get('color'); + if (vColor) s.hud.viewData.color = vColor; + var vStroke = obj.get('stroke'); + if (vStroke) s.hud.viewData.stroke = vStroke; + } + // Initiative HUD text moved or styled — update stored settings + if (s.hud.initiative && s.hud.initData && s.hud.initData.entries) { + var match = s.hud.initData.entries.find(function(e) { return e.textId === id; }); + if (match) { + var data = s.hud.initData; + var frame = getObj('pathv2', data.frameId); + if (frame) { + var frameLeft = frame.get('x'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fw = pts.length >= 2 ? pts[1][0] - pts[0][0] : 70; + var rightEdge = frameLeft + fw / 2; + var newOffset = obj.get('left') - rightEdge; + data.textOffset = newOffset; + // Move all other texts to match offset + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('left', rightEdge + newOffset); + }); + } + // Font size/family changes + var newFontSize = obj.get('font_size'); + var newFontFamily = obj.get('font_family'); + if (newFontSize && newFontSize !== data.textFontSize) { + data.textFontSize = newFontSize; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('font_size', newFontSize); + }); + } + if (newFontFamily && newFontFamily !== data.textFontFamily) { + data.textFontFamily = newFontFamily; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('font_family', newFontFamily); + }); + } + var newColor = obj.get('color'); + if (newColor && newColor !== 'transparent' && newColor !== data.textColor) { + data.textColor = newColor; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt && otherTxt.get('color') !== 'transparent') otherTxt.set('color', newColor); + }); + } + var newStroke = obj.get('stroke'); + if (newStroke && newStroke !== data.textStroke) { + data.textStroke = newStroke; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('stroke', newStroke); + }); + } + } + } + }; + + /** + * Handle destroy:text — if a HUD element is deleted, treat as turning it off. + */ + const onHudTextDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + var id = obj.get('id'); + if (id === s.hud.viewId) { + s.hud.view = false; + s.hud.viewId = null; + sendChat(SCRIPT_NAME, '/w gm HUD: view is now off'); + } + // Initiative HUD text + if (s.hud.initData && s.hud.initData.entries) { + var data = s.hud.initData; + var matchIdx = data.entries.findIndex(function(e) { return e.textId === id; }); + if (matchIdx !== -1) { + var match = data.entries[matchIdx]; + // Remove associated token/pin + var tok = getObj('graphic', match.tokenId) || getObj('pin', match.tokenId); + if (tok) tok.remove(); + // Remove from entries + data.entries.splice(matchIdx, 1); + + // Remove from turn order + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (match.sourceId && !match.sourceId.startsWith('custom:')) { + var info = getLinkedInfo(match.sourceId); + var groupIds = new Set([match.sourceId].concat(info.linkedIds)); + order = order.filter(function(e) { return !groupIds.has(e.id); }); + } else { + var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); + if (customIdx !== -1) order.splice(customIdx, 1); + } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + + reflowInitiativeHud('none'); + sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); + } + } + }; + + /** + * Handle destroy:graphic — if an initiative HUD token is deleted, turn off. + */ + const onHudGraphicDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initData || !s.hud.initData.entries) return; + var id = obj.get('id'); + var data = s.hud.initData; + var matchIdx = data.entries.findIndex(function(e) { return e.tokenId === id; }); + if (matchIdx === -1) return; + + var match = data.entries[matchIdx]; + // Remove associated text + var txt = getObj('text', match.textId); + if (txt) txt.remove(); + // Remove from entries + data.entries.splice(matchIdx, 1); + + // Remove from turn order + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (match.sourceId && !match.sourceId.startsWith('custom:')) { + // Token — remove it and linked children from turn order + var info = getLinkedInfo(match.sourceId); + var groupIds = new Set([match.sourceId].concat(info.linkedIds)); + order = order.filter(function(e) { return !groupIds.has(e.id); }); + } else { + // Custom turn — remove first matching custom entry + var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); + if (customIdx !== -1) order.splice(customIdx, 1); + } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + + reflowInitiativeHud('none'); + sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); + }; + + /** + * Handle destroy:path — if the initiative frame is deleted, turn off. + */ + const onHudPathDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initData) return; + if (obj.get('id') === s.hud.initData.frameId) { + s.hud.initiative = false; + s.hud.initData.frameId = null; + removeInitiativeHud(); + sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + } else if (obj.get('id') === s.hud.initData.highlightId) { + // Highlight deleted — just clear ID, will be recreated on next update + s.hud.initData.highlightId = null; + } + }; + + /** + * Handle !gaslight hud command. + */ + const doHud = (msg, args) => { + var s = state[SCRIPT_NAME]; + var hudElements = Object.keys(s.hud).filter(function(k) { return typeof s.hud[k] === 'boolean'; }); + var toggleWords = new Set(['on', 'off', 'reset']); + + if (args.length === 0) { + // Toggle all elements + hudElements.forEach(function(el) { s.hud[el] = !s.hud[el]; }); + hudElements.forEach(function(el) { + if (el === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); } + if (el === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); } + }); + reply(msg, 'HUD', hudElements.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
')); + return; + } + + // Single arg that's a toggle word — apply to all elements + if (args.length === 1 && toggleWords.has(args[0].toLowerCase())) { + var toggle = args[0].toLowerCase(); + hudElements.forEach(function(el) { + if (toggle === 'on') s.hud[el] = true; + else if (toggle === 'off') s.hud[el] = false; + }); + if (toggle === 'reset') { + hudElements.forEach(function(el) { s.hud[el] = true; }); + s.hud.viewData = {}; + removeViewHud(); + updateViewHud(); + removeInitiativeHud(); + s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); + updateInitiativeHud(); + } else { + hudElements.forEach(function(el) { + if (el === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); } + if (el === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); } + }); + } + reply(msg, 'HUD', hudElements.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
')); + return; + } + + var element = args[0].toLowerCase(); + var toggle = args[1] ? args[1].toLowerCase() : null; + + // Aliases + var elementAliases = { init: 'initiative', turn: 'initiative', turns: 'initiative', relay: 'view' }; + if (elementAliases[element]) element = elementAliases[element]; + if (toggle && elementAliases[toggle]) toggle = elementAliases[toggle]; + + // Allow toggle before or after element: "hud reset initiative" or "hud initiative reset" + if (toggleWords.has(element) && toggle && s.hud[toggle] !== undefined) { + var tmp = element; + element = toggle; + toggle = tmp; + } + + // Aliases (apply again after swap) + if (elementAliases[element]) element = elementAliases[element]; + + if (s.hud[element] === undefined) { + reply(msg, 'Error', 'Unknown HUD element: ' + element + '. Available: ' + Object.keys(s.hud).filter(function(k) { return typeof s.hud[k] === 'boolean'; }).join(', ')); + return; + } + + if (toggle === 'on') { + s.hud[element] = true; + } else if (toggle === 'off') { + s.hud[element] = false; + } else if (toggle === 'reset') { + // Clear stored position, turn on, move to defaults + s.hud[element] = true; + if (element === 'view') { + s.hud.viewData = {}; + var existing = findHudElement('view'); + if (existing) { + var pageId = getHudPageId(); + var page = pageId ? getObj('page', pageId) : null; + var pageWidth = page ? page.get('width') * 70 : 1400; + existing.set({ + left: Math.round(defaultViewHud.leftNorm * pageWidth), + top: defaultViewHud.top, + font_size: defaultViewHud.fontSize, + font_family: defaultViewHud.fontFamily, + color: defaultViewHud.color, + stroke: defaultViewHud.stroke, + }); + } else { + updateViewHud(); + } + } else if (element === 'initiative') { + removeInitiativeHud(); + s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); + updateInitiativeHud(); + } + reply(msg, 'HUD', '' + element + ' reset to default position.'); + return; + } else { + // Toggle + s.hud[element] = !s.hud[element]; + } + + // Apply + if (element === 'view') { + if (s.hud.view) updateViewHud(); + else removeViewHud(); + } else if (element === 'initiative') { + if (s.hud.initiative) updateInitiativeHud(); + else removeInitiativeHud(); + } + + reply(msg, 'HUD', '' + element + ' is now ' + (s.hud[element] ? 'on' : 'off')); + }; + const registerEventHandlers = () => { on('chat:message', handleInput); on('chat:message', viewInterceptor); @@ -3248,11 +4207,421 @@ var Gaslight = Gaslight || (() => { }); registerWithRollCapture(); on('add:graphic', onTokenAdded); - on('destroy:graphic', onTokenDestroyed); on('change:attribute', onAttributeChanged); on('change:graphic', onGraphicPropChanged); on('change:graphic:gmnotes', onGmNotesChanged); on('change:campaign:turnorder', onTurnOrderChanged); + on('change:text', onHudTextChanged); + on('destroy:text', onHudTextDestroyed); + on('destroy:graphic', function(obj) { onTokenDestroyed(obj); onHudGraphicDestroyed(obj); }); + on('destroy:pathv2', onHudPathDestroyed); + on('change:pathv2', function(obj) { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + if (obj.get('id') === s.hud.initData.frameId) { + var data = s.hud.initData; + var points = JSON.parse(obj.get('points') || '[]'); + var newWidth = points.length >= 2 ? points[1][0] - points[0][0] : 0; + var newHeight = points.length >= 2 ? points[1][1] - points[0][1] : 0; + var oldWidth = data.frameSize ? data.frameSize.width : newWidth; + + // Width change logic: shrink → reduce padding first, then tokens; grow → grow padding + if (newWidth !== oldWidth) { + var delta = newWidth - oldWidth; + var currentHPad = data.hPadding || defaultInitHud.hPadding; + var currentTokenSize = data.tokenSize || defaultInitHud.tokenSize; + var minHPad = 5; + + if (delta < 0) { + // Frame got narrower — reduce padding first, then shrink tokens + var padReduction = Math.min(Math.abs(delta) / 2, currentHPad - minHPad); + if (padReduction > 0) { + data.hPadding = currentHPad - padReduction; + delta += padReduction * 2; // remaining delta + } + if (delta < 0) { + // Still need to shrink — reduce token size + data.tokenSize = Math.max(10, currentTokenSize + delta); + } + } else { + // Frame got wider — increase h_padding + data.hPadding = currentHPad + delta / 2; + } + } + + // Save position and size to state + data.pos = { left: obj.get('x'), top: obj.get('y') }; + data.frameSize = { width: newWidth, height: newHeight }; + // Track frame styling + var stroke = obj.get('stroke'); + var fill = obj.get('fill'); + var strokeWidth = obj.get('stroke_width'); + if (stroke !== undefined) data.frameStroke = stroke; + if (fill !== undefined) data.frameFill = fill; + if (strokeWidth !== undefined) data.frameStrokeWidth = strokeWidth; + reflowInitiativeHud('none'); + } else if (obj.get('id') === s.hud.initData.highlightId) { + var data = s.hud.initData; + // Track highlight styling + var hlStroke = obj.get('stroke'); + var hlFill = obj.get('fill'); + var hlStrokeWidth = obj.get('stroke_width'); + if (hlStroke !== undefined) data.highlightStroke = hlStroke; + if (hlFill !== undefined) data.highlightFill = hlFill; + if (hlStrokeWidth !== undefined) data.highlightStrokeWidth = hlStrokeWidth; + // Compute normalized Y offset (0=top+tokenSize/2+vPadding, 1=bottom-tokenSize/2-vPadding) + var frame = getObj('pathv2', data.frameId); + if (frame) { + var fTop = frame.get('y'); + var fPts = JSON.parse(frame.get('points') || '[]'); + var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; + var tknSz = data.tokenSize || defaultInitHud.tokenSize; + var vPad = data.vPadding || defaultInitHud.vPadding; + var usableTop = fTop - fHeight / 2 + tknSz / 2 + vPad; + var usableBot = fTop + fHeight / 2 - tknSz / 2 - vPad; + var hlY = obj.get('y'); + var norm = (hlY - usableTop) / (usableBot - usableTop); + data.currentTurnOffset = Math.max(0, Math.min(1, norm)); + } + reflowInitiativeHud('none'); + } + }); + on('change:graphic', function(obj, prev) { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + // Check if this is a HUD token that was resized + var match = data.entries.find(function(e) { return e.tokenId === obj.get('id'); }); + if (!match || match.sourceId.startsWith('custom:')) return; + var newSize = obj.get('width'); + var oldSize = prev.width; + if (newSize !== oldSize && newSize > 0) { + // Token resized — update token size, resize frame to fit + data.tokenSize = newSize; + var hPad = data.hPadding || defaultInitHud.hPadding; + var newFrameWidth = newSize + 2 * hPad; + // Update frame + var frame = getObj('pathv2', data.frameId); + if (frame) { + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; + frame.set('points', JSON.stringify([[0, 0], [newFrameWidth, frameHeight]])); + data.frameSize = { width: newFrameWidth, height: frameHeight }; + } + reflowInitiativeHud('none'); + } else { + // Token dragged vertically — reorder initiative + var newTop = obj.get('top'); + var oldTop = prev.top; + var newLeft = obj.get('left'); + var oldLeft = prev.left; + var frame = getObj('pathv2', data.frameId); + var frameLeft = frame ? frame.get('x') : 0; + var pts = frame ? JSON.parse(frame.get('points') || '[]') : []; + var fw = pts.length >= 2 ? pts[1][0] - pts[0][0] : 70; + var frameRightEdge = frameLeft + fw / 2; + var frameLeftEdge = frameLeft - fw / 2; + var horizontalEscape = newLeft > frameRightEdge || newLeft < frameLeftEdge; + + if (newTop !== oldTop && !horizontalEscape) { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var sourceId = match.sourceId; + var hudOrder = getHudTurnOrder(); + var currentIdx = hudOrder.findIndex(function(e) { return e.id === sourceId; }); + if (currentIdx === -1) { reflowInitiativeHud('none'); return; } + + // Determine target slot based on new Y position + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + var frameTop = frame.get('y'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; + var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + var targetIdx = Math.round((newTop - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + targetIdx = Math.max(0, Math.min(hudOrder.length - 1, targetIdx)); + + if (targetIdx !== currentIdx) { + var sourceEntry = order.find(function(e) { return e.id === sourceId; }); + if (!sourceEntry) { reflowInitiativeHud('none'); return; } + + var targetHudEntry = hudOrder[targetIdx]; + var targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); + if (targetFullIdx === -1) { reflowInitiativeHud('none'); return; } + + // Remove source (and its linked children) from order + var info = getLinkedInfo(sourceId); + var groupIds = new Set([sourceId].concat(info.linkedIds)); + var removed = []; + order = order.filter(function(e) { + if (groupIds.has(e.id)) { removed.push(e); return false; } + return true; + }); + + // Recalculate target index after removal + targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); + if (targetFullIdx === -1) targetFullIdx = order.length; + + // Insert: if moving down, insert after target group; if up, insert before + if (targetIdx > currentIdx) { + var tInfo = getLinkedInfo(targetHudEntry.id); + var tGroupSize = 1 + tInfo.linkedIds.filter(function(lid) { + return order.some(function(e) { return e.id === lid; }); + }).length; + var insertIdx = targetFullIdx + tGroupSize; + removed.forEach(function(e, i) { order.splice(insertIdx + i, 0, e); }); + } else { + removed.forEach(function(e, i) { order.splice(targetFullIdx + i, 0, e); }); + } + + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + } + reflowInitiativeHud('none'); + } + + // Token dragged horizontally — make it this token's turn + var verticalDrift = Math.abs(newTop - oldTop); + var tknSizeH = data.tokenSize || defaultInitHud.tokenSize; + if (newLeft !== oldLeft && verticalDrift < tknSizeH / 2 && horizontalEscape) { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var sourceId = match.sourceId; + var swipeDirection = newLeft > frameRightEdge ? 'forward' : 'backward'; + var isCurrentTurn = order.length > 0 && order[0].id === sourceId; + + if (isCurrentTurn) { + // Current turn swiped — advance/retreat to next/prev master or custom + if (swipeDirection === 'forward') { + order.push(order.shift()); + // Skip past linked children + var skip = order.length; + while (skip-- > 0 && order[0] && order[0].id && order[0].id !== '-1') { + var cInfo = getLinkedInfo(order[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + order.push(order.shift()); + } + } else { + order.unshift(order.pop()); + // Skip backward past linked children + var skip = order.length; + while (skip-- > 0 && order[0] && order[0].id && order[0].id !== '-1') { + var cInfo = getLinkedInfo(order[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + order.unshift(order.pop()); + } + } + // Apply formula if new top is a custom + if (order[0] && order[0].id === '-1' && order[0].formula) { + var f = order[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDirection === 'backward') add = !add; + order[0].pr = (parseFloat(order[0].pr) || 0) + (add ? v : -v); + } + } else { + // Non-current turn — rotate until target is at position 0 + var safety = order.length; + while (safety-- > 0 && order.length > 0 && order[0].id !== sourceId) { + if (swipeDirection === 'forward') { + order.push(order.shift()); + } else { + order.unshift(order.pop()); + } + } + // Apply formula if the new top is a custom turn + if (order[0] && order[0].id === '-1' && order[0].formula) { + var f = order[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDirection === 'backward') add = !add; + order[0].pr = (parseFloat(order[0].pr) || 0) + (add ? v : -v); + } + } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + reflowInitiativeHud('none'); + } else if (!horizontalEscape) { + reflowInitiativeHud('none'); + } + } + }); + on('destroy:pin', onHudGraphicDestroyed); + on('change:pin', function(obj, prev) { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + var match = data.entries.find(function(e) { return e.tokenId === obj.get('id'); }); + if (!match) return; + + var newY = obj.get('y'); + var oldY = prev.y; + + // Check if pin escaped horizontally (swipe, not reorder) + var newX = obj.get('x'); + var frame = getObj('pathv2', data.frameId); + var horizontalEscapePin = false; + if (frame) { + var frameLeftPin = frame.get('x'); + var ptsPin = JSON.parse(frame.get('points') || '[]'); + var fwPin = ptsPin.length >= 2 ? ptsPin[1][0] - ptsPin[0][0] : 70; + horizontalEscapePin = newX > frameLeftPin + fwPin / 2 || newX < frameLeftPin - fwPin / 2; + } + + // Pin dragged vertically — reorder custom turn in initiative + if (!horizontalEscapePin && newY !== oldY) { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var hudOrder = getHudTurnOrder(); + + // Find current index of this custom in the deduped order by matching text position + var txt = getObj('text', match.textId); + var currentIdx = -1; + if (txt) { + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + var frameTop = frame.get('y'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; + var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + currentIdx = Math.round((txt.get('top') - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + } + if (currentIdx < 0 || currentIdx >= hudOrder.length) { reflowInitiativeHud('none'); return; } + + // Determine target slot based on new pin Y (subtract pin offset) + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + var frameTop = frame.get('y'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; + var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + var slotY = newY - tknSize / 2; // undo pin offset + var targetIdx = Math.round((slotY - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + targetIdx = Math.max(0, Math.min(hudOrder.length - 1, targetIdx)); + + if (targetIdx !== currentIdx) { + // Find the custom entry in the full order + var customEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + var customIdx = 0; + var sourceFullIdx = -1; + for (var fi = 0; fi < order.length; fi++) { + if (!order[fi].id || order[fi].id === '-1') { + if (customIdx === (currentIdx - hudOrder.slice(0, currentIdx).filter(function(e) { return e.id && e.id !== '-1'; }).length)) { + sourceFullIdx = fi; + break; + } + customIdx++; + } + } + if (sourceFullIdx === -1) { reflowInitiativeHud('none'); return; } + + // Remove source from order + var removed = order.splice(sourceFullIdx, 1); + + // Find target position in full order + var targetHudEntry = hudOrder[targetIdx]; + var targetFullIdx; + if (!targetHudEntry.id || targetHudEntry.id === '-1') { + // Targeting another custom — find it in the full order + targetFullIdx = targetIdx > currentIdx ? sourceFullIdx : sourceFullIdx; + } else { + targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); + } + if (targetFullIdx === -1) targetFullIdx = order.length; + + // Insert + if (targetIdx > currentIdx) { + var tInfo = getLinkedInfo(targetHudEntry.id || ''); + var tGroupSize = (targetHudEntry.id && targetHudEntry.id !== '-1') ? 1 + tInfo.linkedIds.filter(function(lid) { + return order.some(function(e) { return e.id === lid; }); + }).length : 1; + order.splice(targetFullIdx + tGroupSize, 0, removed[0]); + } else { + order.splice(targetFullIdx, 0, removed[0]); + } + + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + } + } + + // Pin dragged horizontally — make it this turn's turn + var oldX = prev.x; + var verticalDrift = Math.abs(newY - oldY); + var tknSizeP = data.tokenSize || defaultInitHud.tokenSize; + if (newX !== oldX && verticalDrift < tknSizeP / 2 && horizontalEscapePin) { + var frameLeftH = frame.get('x'); + var ptsH = JSON.parse(frame.get('points') || '[]'); + var fwH = ptsH.length >= 2 ? ptsH[1][0] - ptsH[0][0] : 70; + var frameRightEdge = frameLeftH + fwH / 2; + var swipeDir = newX > frameRightEdge ? 'forward' : 'backward'; + var fullOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + + var customsInOrder = []; + fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); + var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (customRank !== -1 && customsInOrder[customRank] !== undefined) { + var targetFullIdx = customsInOrder[customRank]; + if (targetFullIdx === 0) { + // This pin is the current turn — advance/retreat to next/prev master or custom + if (swipeDir === 'forward') { + fullOrder.push(fullOrder.shift()); + var skip = fullOrder.length; + while (skip-- > 0 && fullOrder[0] && fullOrder[0].id && fullOrder[0].id !== '-1') { + var cInfo = getLinkedInfo(fullOrder[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + fullOrder.push(fullOrder.shift()); + } + } else { + fullOrder.unshift(fullOrder.pop()); + var skip = fullOrder.length; + while (skip-- > 0 && fullOrder[0] && fullOrder[0].id && fullOrder[0].id !== '-1') { + var cInfo = getLinkedInfo(fullOrder[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + fullOrder.unshift(fullOrder.pop()); + } + } + // Apply formula if new top is a custom + if (fullOrder[0] && fullOrder[0].id === '-1' && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } + } else { + // Non-current pin — rotate to it + var rotCount = swipeDir === 'forward' ? targetFullIdx : fullOrder.length - targetFullIdx; + for (var ri = 0; ri < rotCount; ri++) { + if (swipeDir === 'forward') { + fullOrder.push(fullOrder.shift()); + } else { + fullOrder.unshift(fullOrder.pop()); + } + } + // Apply formula if the new top is a custom turn + if (fullOrder[0] && fullOrder[0].id === '-1' && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } + } + } + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(fullOrder)); + _suppressTurnSync = false; + } + + reflowInitiativeHud('none'); + }); }; return { checkInstall, registerEventHandlers }; From b6fc7d19d6a3f670111594a572865239bc53a12c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 4 Jul 2026 07:27:25 -0400 Subject: [PATCH 36/41] Added ZeroFrame as a dependency --- Gaslight/script.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gaslight/script.json b/Gaslight/script.json index 1138b344b..0f02327b9 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -6,7 +6,7 @@ "description": "[Wiki Page](https://wiki.roll20.net/Script:Gaslight)\n\nPer-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\n**v2.1.0 — New features:**\n- Initiative tracking: linked tokens auto-added to turn order, auto-skip children on turn advance, direction-aware (forward/backward), sort-aware\n- Token-level sync control: `!gaslight sync` / `!gaslight desync` commands to manage per-token sync config\n- View off mode: `!gaslight view off` disables command relay (GM-only changes)\n- State migration from v2.0.0\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight sync [props|all|reset]` -- Manage sync whitelist per token\n- `!gaslight desync [props|all]` -- Exclude props from sync per token\n- `!gaslight view [master|off|]` -- Control command relay targeting\n- `!gaslight relay ` -- Relay command to specific views\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", - "dependencies": ["Anchor", "Mirror", "SelectManager", "RollCapture"], + "dependencies": ["Anchor", "Mirror", "SelectManager", "RollCapture", "ZeroFrame"], "modifies": { "graphic": "read, write", "text": "read, write", From cb43baa54544cccb4382720f84003a36bf0ead96 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 4 Jul 2026 07:30:12 -0400 Subject: [PATCH 37/41] Updated Readme --- Gaslight/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Gaslight/README.md b/Gaslight/README.md index 8879a463a..764087590 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -9,6 +9,7 @@ Per-player map perception for Roll20. Split players onto individual copies of a - [Mirror](https://github.com/Roll20/roll20-api-scripts/tree/master/Mirror) (property sync) - [SelectManager](https://github.com/Roll20/roll20-api-scripts/tree/master/SelectManager) (command relay) - [RollCapture](https://github.com/Roll20/roll20-api-scripts/tree/master/RollCapture) (optional, roll value extraction for scripting) +- [ZeroFrame] (https://github.com/Roll20/roll20-api-scripts/tree/master/ZeroFrame) (optional, but highly recommended for use with SelectManager) ## Use Cases From adaebbd4b1dedda86b0e55934446136fd1bad8a4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 4 Jul 2026 07:33:31 -0400 Subject: [PATCH 38/41] Added APILogic and Fetch too for a consistent experience --- Gaslight/README.md | 6 ++++-- Gaslight/script.json | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Gaslight/README.md b/Gaslight/README.md index 764087590..4b38ebe4a 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -7,9 +7,11 @@ Per-player map perception for Roll20. Split players onto individual copies of a - Roll20 Pro subscription (API access required) - [Anchor](https://github.com/Roll20/roll20-api-scripts/tree/master/Anchor) (spatial sync) - [Mirror](https://github.com/Roll20/roll20-api-scripts/tree/master/Mirror) (property sync) -- [SelectManager](https://github.com/Roll20/roll20-api-scripts/tree/master/SelectManager) (command relay) - [RollCapture](https://github.com/Roll20/roll20-api-scripts/tree/master/RollCapture) (optional, roll value extraction for scripting) -- [ZeroFrame] (https://github.com/Roll20/roll20-api-scripts/tree/master/ZeroFrame) (optional, but highly recommended for use with SelectManager) +- [ZeroFrame] (https://github.com/Roll20/roll20-api-scripts/tree/master/ZeroFrame) (needed for the following other required scripts) +- [SelectManager](https://github.com/Roll20/roll20-api-scripts/tree/master/SelectManager) (command relay) +- [APILogic](https://github.com/Roll20/roll20-api-scripts/tree/master/APILogic) (conditional branching in scripts) +- [Fetch](https://github.com/Roll20/roll20-api-scripts/tree/master/Fetch) (access attributes in scripts) ## Use Cases diff --git a/Gaslight/script.json b/Gaslight/script.json index 0f02327b9..1161447a4 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -6,7 +6,7 @@ "description": "[Wiki Page](https://wiki.roll20.net/Script:Gaslight)\n\nPer-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\n**v2.1.0 — New features:**\n- Initiative tracking: linked tokens auto-added to turn order, auto-skip children on turn advance, direction-aware (forward/backward), sort-aware\n- Token-level sync control: `!gaslight sync` / `!gaslight desync` commands to manage per-token sync config\n- View off mode: `!gaslight view off` disables command relay (GM-only changes)\n- State migration from v2.0.0\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight sync [props|all|reset]` -- Manage sync whitelist per token\n- `!gaslight desync [props|all]` -- Exclude props from sync per token\n- `!gaslight view [master|off|]` -- Control command relay targeting\n- `!gaslight relay ` -- Relay command to specific views\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", - "dependencies": ["Anchor", "Mirror", "SelectManager", "RollCapture", "ZeroFrame"], + "dependencies": ["Anchor", "Mirror", "RollCapture", "ZeroFrame","SelectManager", "APILogic", "Fetch"], "modifies": { "graphic": "read, write", "text": "read, write", From 0dcdc23eea701cc642d546232c7e734d2d32a89d Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 5 Jul 2026 15:36:18 -0400 Subject: [PATCH 39/41] Gaslight: !gaslight var command, stage improvements, script eval fixes - New: !gaslight var [--silent] [--get/--set/--del/--setch/--delch] - chainable gl_* variable management - View-aware: reads/writes scoped to current view target - Propagates gmnotes to linked tokens (respects view) - Triggers script eval unless --silent - Stage: follows current view (specific player or all) - Stage: uses Mirror.align after linking for full property sync - Stage: copies gmnotes (gaslight_link/sync config) at creation - Script eval: ZeroFrame dispatch batch fixes timing (scripting flag) - Script eval: {& select } clears selection bleed - Anchor: always pass explicit components (no layer/zorder) - fixes per-player layer scripts - Anchor: exclude sight group from Mirror chain by default - Fix: duplicate sync/desync in HELP_TEXT removed --- Gaslight/2.1.0/Gaslight.js | 313 +++++++++++++++++++++++++++++++++++-- Gaslight/Gaslight.js | 313 +++++++++++++++++++++++++++++++++++-- Gaslight/README.md | 41 ++++- 3 files changed, 640 insertions(+), 27 deletions(-) diff --git a/Gaslight/2.1.0/Gaslight.js b/Gaslight/2.1.0/Gaslight.js index f3a9f56f0..a1fd9e04d 100644 --- a/Gaslight/2.1.0/Gaslight.js +++ b/Gaslight/2.1.0/Gaslight.js @@ -1,6 +1,6 @@ // ============================================================================= // Gaslight v2.1.0 -// Last Updated: 2026-07-03 +// Last Updated: 2026-07-05 // Author: Kenan Millet // // Description: @@ -21,9 +21,13 @@ // !gaslight group Assign page to group // !gaslight ungroup Remove page from group // !gaslight stage [players...] Propagate tokens to player pages -// !gaslight view [player|master] Switch relay view target +// !gaslight sync [props|all|reset] Manage sync per token +// !gaslight desync [props|all] Exclude props from sync +// !gaslight var [--silent] [actions...] Read/set/unset gl_ variables +// !gaslight view [player|master|off] Switch relay view target // !gaslight relay Manually relay command to views // !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight hud [on|off|reset] [element] Toggle HUD elements // !gaslight eval [--dry-run] [--all|] Evaluate script pins // !gaslight status Show current state // !gaslight --help Command reference @@ -365,6 +369,9 @@ var Gaslight = Gaslight || (() => { pagesToCloneTo.forEach(function(targetPageId) { var imgsrc = token.get('imgsrc'); if (!imgsrc) return; + + // Create with minimal props (spatial + identity + gmnotes) + // Mirror.align handles synced properties after linking is established var newToken = createObj('graphic', { _subtype: 'token', pageid: targetPageId, @@ -374,10 +381,13 @@ var Gaslight = Gaslight || (() => { width: token.get('width'), height: token.get('height'), rotation: token.get('rotation'), + flipv: token.get('flipv'), + fliph: token.get('fliph'), layer: token.get('layer'), name: token.get('name'), represents: token.get('represents') || '', - controlledby: token.get('controlledby') || '' + controlledby: token.get('controlledby') || '', + gmnotes: token.get('gmnotes') || '' }); if (newToken) setLinkId(newToken, getLinkId(token)); cloned++; @@ -737,9 +747,9 @@ var Gaslight = Gaslight || (() => { if (syncProps === '') return; // Determine which props go to Anchor vs Mirror - var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; var needsAnchor = true; - var anchorComponents = null; // null = use Anchor defaults + var anchorComponents = null; // null = use allAnchorProps as default var mirrorProps = null; // null = all non-anchor if (Array.isArray(syncProps)) { var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); @@ -752,6 +762,11 @@ var Gaslight = Gaslight || (() => { } mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; } + // Always pass explicit components — never let Anchor default to ALL_COMPONENTS + if (!anchorComponents) { + anchorComponents = {}; + allAnchorProps.forEach(function(p) { anchorComponents[p] = true; }); + } // Set up Anchor links (spatial sync) if (needsAnchor) { @@ -805,11 +820,15 @@ var Gaslight = Gaslight || (() => { // Set up Mirror chain for non-spatial property sync if (typeof Mirror !== 'undefined' && mirrorProps !== false) { if (mirrorProps === null) { - // Default: sync all minus whatever Anchor is handling + // Default: sync all minus anchor and sight (sight stripped from children) var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + // Also exclude sight unless explicitly included in gaslight_sync + if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS && Mirror.PROP_GROUPS.sight) { + mirrorExcludes = mirrorExcludes.concat(Mirror.PROP_GROUPS.sight); + } Mirror.chainLink(ids, null, mirrorExcludes); } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { - // Specific non-spatial props + // Specific non-spatial props (user explicitly chose what to sync) Mirror.chainLink(ids, mirrorProps); } } @@ -1273,6 +1292,254 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' token(s).'); }; + /** + * Read/set/unset gl_* variables on token gmnotes or character sheet. + * Supports chained actions: --get, --set, --del, --setch, --delch + * After mutations, propagates to linked tokens and triggers eval (unless --silent). + * + * Usage: !gaslight var [--silent] [actions...] + * --get Read gl_ (token gmnotes priority, fallback to char) + * --set Set gl_ on token gmnotes + * --del Remove gl_ from token gmnotes + * --setch Set gl_ on character sheet attribute + * --delch Remove gl_ from character sheet attribute + */ + const doVar = (msg, args) => { + var silent = args.indexOf('--silent') !== -1; + args = args.filter(function(a) { return a !== '--silent'; }); + + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight var [--silent] --get|--set|--del|--setch|--delch <name> [value]'); return; } + + // Parse chained actions + var actions = []; + var i = 0; + while (i < args.length) { + var action = args[i].toLowerCase(); + if (action === '--get') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--get requires a name.'); return; } + actions.push({ type: 'get', name: args[i + 1] }); + i += 2; + } else if (action === '--set') { + if (i + 2 >= args.length) { reply(msg, 'Error', '--set requires a name and value.'); return; } + actions.push({ type: 'set', name: args[i + 1], value: args[i + 2] }); + i += 3; + } else if (action === '--del') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--del requires a name.'); return; } + actions.push({ type: 'del', name: args[i + 1] }); + i += 2; + } else if (action === '--setch') { + if (i + 2 >= args.length) { reply(msg, 'Error', '--setch requires a name and value.'); return; } + actions.push({ type: 'setch', name: args[i + 1], value: args[i + 2] }); + i += 3; + } else if (action === '--delch') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--delch requires a name.'); return; } + actions.push({ type: 'delch', name: args[i + 1] }); + i += 2; + } else { + reply(msg, 'Error', 'Unknown action: ' + args[i] + '. Expected --get, --set, --del, --setch, or --delch.'); + return; + } + } + + // Normalize names — ensure gl_ prefix + actions.forEach(function(a) { + if (!a.name.startsWith('gl_')) a.name = 'gl_' + a.name; + }); + + var s = state[SCRIPT_NAME]; + var output = []; + var mutatedTokenGmnotes = false; + var mutatedCharAttrs = false; + + tokens.forEach(function(t) { + var tokenName = t.get('name') || t.get('id'); + var tokenId = t.get('id'); + var onMaster = Object.values(s.activeGroups).some(function(active) { return t.get('_pageid') === active.masterPageId; }); + + // Determine which tokens to read from / write to based on view + var resolveViewTargets = function() { + if (!onMaster) return [t]; // non-master: operate on itself only + if (s.view === null) return [t]; // off: master only + if (s.view === 'master') { + // All linked tokens including master + var targets = [t]; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = (active.linkedTokens[tokenId] || []).slice(); + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, idx, arr) { return arr.indexOf(id) === idx && id !== tokenId; }).forEach(function(id) { + var linked = getObj('graphic', id); + if (linked) targets.push(linked); + }); + }); + return targets; + } + // Specific player: find their linked copy + var playerTargets = []; + Object.values(s.activeGroups).forEach(function(active) { + var targetPageId = active.playerPages[s.view] ? active.playerPages[s.view].pageId : null; + if (!targetPageId) return; + var allLinked = (active.linkedTokens[tokenId] || []).slice(); + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, idx, arr) { return arr.indexOf(id) === idx && id !== tokenId; }).forEach(function(id) { + var linked = getObj('graphic', id); + if (linked && linked.get('_pageid') === targetPageId) playerTargets.push(linked); + }); + }); + return playerTargets.length > 0 ? playerTargets : [t]; + }; + + // For --get: read from view targets + var getTargets = resolveViewTargets(); + // For --set/--del: write to view targets (master included only if view is master or off) + var writeTargets; + if (!onMaster || s.view === null || s.view === 'master') { + writeTargets = resolveViewTargets(); + } else { + // view is a specific player: only write to that player's copy, not master + writeTargets = resolveViewTargets(); + } + + // Process --get actions against getTargets + actions.forEach(function(a) { + if (a.type !== 'get') return; + if (s.view === 'master' && onMaster && getTargets.length > 1) { + // Compact format: show all values + var vals = getTargets.map(function(target) { + var tNotes = target.get('gmnotes') || ''; + try { tNotes = decodeURIComponent(tNotes); } catch(e) {} + var val = readGlField(tNotes, a.name); + if (!val) { + var charId = target.get('represents'); + if (charId) val = getAttrByName(charId, a.name) || ''; + } + var pageName = 'master'; + Object.values(s.activeGroups).forEach(function(active) { + if (target.get('_pageid') === active.masterPageId) pageName = 'master'; + Object.entries(active.playerPages).forEach(function(e) { + if (target.get('_pageid') === e[1].pageId) pageName = e[1].name || e[0]; + }); + }); + return pageName + ': ' + (val || ''); + }); + output.push('' + tokenName + ' ' + a.name + ' — ' + vals.join(', ')); + } else { + var target = getTargets[0]; + var tNotes = target.get('gmnotes') || ''; + try { tNotes = decodeURIComponent(tNotes); } catch(e) {} + var val = readGlField(tNotes, a.name); + if (!val) { + var charId = target.get('represents'); + if (charId) val = getAttrByName(charId, a.name) || ''; + } + output.push('' + tokenName + ' ' + a.name + ' = ' + (val || '(not set)')); + } + }); + + // Process --set/--del/--setch/--delch against writeTargets + var prevGmnotes_original = t.get('gmnotes') || ''; + writeTargets.forEach(function(target) { + var prevGmnotes = target.get('gmnotes') || ''; + var notes = prevGmnotes; + try { notes = decodeURIComponent(notes); } catch(e) {} + var gmnotesChanged = false; + + actions.forEach(function(a) { + if (a.type === 'set') { + var rx = new RegExp(a.name + '\\s*[=:]\\s*[^\\n]*'); + if (notes.match(rx)) { + notes = notes.replace(rx, a.name + ': ' + a.value); + } else { + notes = (notes ? notes + '\n' : '') + a.name + ': ' + a.value; + } + gmnotesChanged = true; + } else if (a.type === 'del') { + notes = notes.replace(new RegExp('\\n?' + a.name + '\\s*[=:]\\s*[^\\n]*'), ''); + gmnotesChanged = true; + } else if (a.type === 'setch') { + var charId = target.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: a.name })[0]; + if (attr) { attr.set('current', a.value); } + else { createObj('attribute', { characterid: charId, name: a.name, current: a.value }); } + mutatedCharAttrs = true; + } else if (a.type === 'delch') { + var charId = target.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: a.name })[0]; + if (attr) attr.remove(); + mutatedCharAttrs = true; + } + }); + + if (gmnotesChanged) { + target.set('gmnotes', notes); + mutatedTokenGmnotes = true; + } + }); + + // Trigger script evaluation once, scoped to view (unless --silent) + if (mutatedTokenGmnotes && !silent) { + // Pick the right token to trigger from based on view + var triggerTarget; + if (!onMaster || s.view === null || s.view === 'master') { + triggerTarget = t; // master token: evaluates for all viewers + } else { + // Specific player view: trigger from their copy so eval scopes to that viewer + triggerTarget = writeTargets[0] || t; + } + onGmNotesChanged(triggerTarget, { gmnotes: prevGmnotes_original }); + } + + // Trigger eval for char attr mutations (scripts may resolve from char) + if (mutatedCharAttrs && !silent) { + var changedFields = actions.filter(function(a) { return a.type === 'setch' || a.type === 'delch'; }).map(function(a) { return a.name; }); + changedFields.forEach(function(field) { + var entries = triggerMap[field]; + if (!entries || entries.length === 0) return; + var masterTokenId = null; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = (active.linkedTokens[tokenId] || []).slice(); + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, idx) { return allLinked.indexOf(id) === idx; }).forEach(function(id) { + var tok = getObj('graphic', id); + if (tok && tok.get('_pageid') === active.masterPageId) masterTokenId = id; + }); + if (t.get('_pageid') === active.masterPageId) masterTokenId = tokenId; + }); + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false, masterTokenId, t.get('_pageid')); + }); + }); + } + }); + + // Build summary + var hasMutations = actions.some(function(a) { return a.type !== 'get'; }); + if (output.length > 0) { + reply(msg, 'Var', output.join('
')); + } + if (hasMutations) { + var summary = actions.filter(function(a) { return a.type !== 'get'; }).map(function(a) { + if (a.type === 'set') return 'set ' + a.name + ' = ' + a.value; + if (a.type === 'del') return 'del ' + a.name; + if (a.type === 'setch') return 'setch ' + a.name + ' = ' + a.value; + if (a.type === 'delch') return 'delch ' + a.name; + }).join(', '); + reply(msg, 'Var', summary + ' on ' + tokens.length + ' token(s).' + (silent ? ' (silent)' : '')); + } + }; + const doGroup = (msg, args) => { if (args.length < 2) { reply(msg, 'Error', 'Usage: !gaslight group <group> <player|GM>'); return; } const groupName = args.shift(); @@ -1468,7 +1735,12 @@ var Gaslight = Gaslight || (() => { else reply(msg, 'Warning', 'Player "' + name + '" not found in group.'); }); } else { - targetPlayerIds = Object.keys(groupInfo.players); + // Default: follow current view target + if (s.view && s.view !== 'master' && groupInfo.players[s.view]) { + targetPlayerIds = [s.view]; + } else { + targetPlayerIds = Object.keys(groupInfo.players); + } } if (targetPlayerIds.length === 0) { reply(msg, 'Error', 'No valid target players.'); return; } @@ -1497,6 +1769,13 @@ var Gaslight = Gaslight || (() => { links.forEach(function(l) { if (l.target) allLinks.push(l); }); }); establishLinks(groupName, groupDiscovered, allLinks); + + // Align synced properties from source tokens to newly linked children + if (typeof Mirror !== 'undefined' && Mirror.align) { + tokens.forEach(function(token) { + Mirror.align(token.get('id'), { ifLinked: true }); + }); + } } reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); @@ -1546,6 +1825,11 @@ var Gaslight = Gaslight || (() => { links.forEach(function(l) { if (l.target) allLinks.push(l); }); }); establishLinks(groupName, groupDiscovered, allLinks); + + // Align synced properties from source to newly linked children + if (typeof Mirror !== 'undefined' && Mirror.align) { + Mirror.align(obj.get('id'), { ifLinked: true }); + } }, 500); }; @@ -1712,8 +1996,7 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + '' + CMD + ' desync [props|all] -- Exclude props from sync
' - + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' - + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + + '' + CMD + ' var [--silent] [actions...] -- Read/set/unset gl_ vars
' + '' + CMD + ' view [master|off|<player>] -- Control relay targeting
' + '' + CMD + ' relay <views> <!cmd> -- Relay command to views
' + '' + CMD + ' group <group> <player|GM> -- Assign page
' @@ -2431,9 +2714,12 @@ var Gaslight = Gaslight || (() => { var gmPlayer = findObjs({ _type: 'player' }).find(function(p) { return playerIsGM(p.get('_id')); }); if (gmPlayer) senderId = gmPlayer.get('_id'); } - sendChat('', CMD + ' --script-lock', null, { noarchive: true }); - sendChat(getPlayerName(senderId), fullCmd); - sendChat('', CMD + ' --script-unlock', null, { noarchive: true }); + var batch = '!{{\n' + + CMD + ' --script-lock\n' + + fullCmd + ' {& select ' + viewerTarget.get('id') + '}\n' + + '(^)!^gaslight --script-unlock\n' + + '}}'; + sendChat(getPlayerName(senderId), batch); } } }; @@ -2569,6 +2855,7 @@ var Gaslight = Gaslight || (() => { case 'unlink': doUnlink(msg, args); break; case 'sync': doSync(msg, args); break; case 'desync': doDesync(msg, args); break; + case 'var': doVar(msg, args); break; case 'group': doGroup(msg, args); break; case 'ungroup': doUngroup(msg, args); break; case 'relay': doRelay(msg, args); break; diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index f3a9f56f0..a1fd9e04d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,6 +1,6 @@ // ============================================================================= // Gaslight v2.1.0 -// Last Updated: 2026-07-03 +// Last Updated: 2026-07-05 // Author: Kenan Millet // // Description: @@ -21,9 +21,13 @@ // !gaslight group Assign page to group // !gaslight ungroup Remove page from group // !gaslight stage [players...] Propagate tokens to player pages -// !gaslight view [player|master] Switch relay view target +// !gaslight sync [props|all|reset] Manage sync per token +// !gaslight desync [props|all] Exclude props from sync +// !gaslight var [--silent] [actions...] Read/set/unset gl_ variables +// !gaslight view [player|master|off] Switch relay view target // !gaslight relay Manually relay command to views // !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight hud [on|off|reset] [element] Toggle HUD elements // !gaslight eval [--dry-run] [--all|] Evaluate script pins // !gaslight status Show current state // !gaslight --help Command reference @@ -365,6 +369,9 @@ var Gaslight = Gaslight || (() => { pagesToCloneTo.forEach(function(targetPageId) { var imgsrc = token.get('imgsrc'); if (!imgsrc) return; + + // Create with minimal props (spatial + identity + gmnotes) + // Mirror.align handles synced properties after linking is established var newToken = createObj('graphic', { _subtype: 'token', pageid: targetPageId, @@ -374,10 +381,13 @@ var Gaslight = Gaslight || (() => { width: token.get('width'), height: token.get('height'), rotation: token.get('rotation'), + flipv: token.get('flipv'), + fliph: token.get('fliph'), layer: token.get('layer'), name: token.get('name'), represents: token.get('represents') || '', - controlledby: token.get('controlledby') || '' + controlledby: token.get('controlledby') || '', + gmnotes: token.get('gmnotes') || '' }); if (newToken) setLinkId(newToken, getLinkId(token)); cloned++; @@ -737,9 +747,9 @@ var Gaslight = Gaslight || (() => { if (syncProps === '') return; // Determine which props go to Anchor vs Mirror - var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; var needsAnchor = true; - var anchorComponents = null; // null = use Anchor defaults + var anchorComponents = null; // null = use allAnchorProps as default var mirrorProps = null; // null = all non-anchor if (Array.isArray(syncProps)) { var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); @@ -752,6 +762,11 @@ var Gaslight = Gaslight || (() => { } mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; } + // Always pass explicit components — never let Anchor default to ALL_COMPONENTS + if (!anchorComponents) { + anchorComponents = {}; + allAnchorProps.forEach(function(p) { anchorComponents[p] = true; }); + } // Set up Anchor links (spatial sync) if (needsAnchor) { @@ -805,11 +820,15 @@ var Gaslight = Gaslight || (() => { // Set up Mirror chain for non-spatial property sync if (typeof Mirror !== 'undefined' && mirrorProps !== false) { if (mirrorProps === null) { - // Default: sync all minus whatever Anchor is handling + // Default: sync all minus anchor and sight (sight stripped from children) var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + // Also exclude sight unless explicitly included in gaslight_sync + if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS && Mirror.PROP_GROUPS.sight) { + mirrorExcludes = mirrorExcludes.concat(Mirror.PROP_GROUPS.sight); + } Mirror.chainLink(ids, null, mirrorExcludes); } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { - // Specific non-spatial props + // Specific non-spatial props (user explicitly chose what to sync) Mirror.chainLink(ids, mirrorProps); } } @@ -1273,6 +1292,254 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' token(s).'); }; + /** + * Read/set/unset gl_* variables on token gmnotes or character sheet. + * Supports chained actions: --get, --set, --del, --setch, --delch + * After mutations, propagates to linked tokens and triggers eval (unless --silent). + * + * Usage: !gaslight var [--silent] [actions...] + * --get Read gl_ (token gmnotes priority, fallback to char) + * --set Set gl_ on token gmnotes + * --del Remove gl_ from token gmnotes + * --setch Set gl_ on character sheet attribute + * --delch Remove gl_ from character sheet attribute + */ + const doVar = (msg, args) => { + var silent = args.indexOf('--silent') !== -1; + args = args.filter(function(a) { return a !== '--silent'; }); + + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight var [--silent] --get|--set|--del|--setch|--delch <name> [value]'); return; } + + // Parse chained actions + var actions = []; + var i = 0; + while (i < args.length) { + var action = args[i].toLowerCase(); + if (action === '--get') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--get requires a name.'); return; } + actions.push({ type: 'get', name: args[i + 1] }); + i += 2; + } else if (action === '--set') { + if (i + 2 >= args.length) { reply(msg, 'Error', '--set requires a name and value.'); return; } + actions.push({ type: 'set', name: args[i + 1], value: args[i + 2] }); + i += 3; + } else if (action === '--del') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--del requires a name.'); return; } + actions.push({ type: 'del', name: args[i + 1] }); + i += 2; + } else if (action === '--setch') { + if (i + 2 >= args.length) { reply(msg, 'Error', '--setch requires a name and value.'); return; } + actions.push({ type: 'setch', name: args[i + 1], value: args[i + 2] }); + i += 3; + } else if (action === '--delch') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--delch requires a name.'); return; } + actions.push({ type: 'delch', name: args[i + 1] }); + i += 2; + } else { + reply(msg, 'Error', 'Unknown action: ' + args[i] + '. Expected --get, --set, --del, --setch, or --delch.'); + return; + } + } + + // Normalize names — ensure gl_ prefix + actions.forEach(function(a) { + if (!a.name.startsWith('gl_')) a.name = 'gl_' + a.name; + }); + + var s = state[SCRIPT_NAME]; + var output = []; + var mutatedTokenGmnotes = false; + var mutatedCharAttrs = false; + + tokens.forEach(function(t) { + var tokenName = t.get('name') || t.get('id'); + var tokenId = t.get('id'); + var onMaster = Object.values(s.activeGroups).some(function(active) { return t.get('_pageid') === active.masterPageId; }); + + // Determine which tokens to read from / write to based on view + var resolveViewTargets = function() { + if (!onMaster) return [t]; // non-master: operate on itself only + if (s.view === null) return [t]; // off: master only + if (s.view === 'master') { + // All linked tokens including master + var targets = [t]; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = (active.linkedTokens[tokenId] || []).slice(); + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, idx, arr) { return arr.indexOf(id) === idx && id !== tokenId; }).forEach(function(id) { + var linked = getObj('graphic', id); + if (linked) targets.push(linked); + }); + }); + return targets; + } + // Specific player: find their linked copy + var playerTargets = []; + Object.values(s.activeGroups).forEach(function(active) { + var targetPageId = active.playerPages[s.view] ? active.playerPages[s.view].pageId : null; + if (!targetPageId) return; + var allLinked = (active.linkedTokens[tokenId] || []).slice(); + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, idx, arr) { return arr.indexOf(id) === idx && id !== tokenId; }).forEach(function(id) { + var linked = getObj('graphic', id); + if (linked && linked.get('_pageid') === targetPageId) playerTargets.push(linked); + }); + }); + return playerTargets.length > 0 ? playerTargets : [t]; + }; + + // For --get: read from view targets + var getTargets = resolveViewTargets(); + // For --set/--del: write to view targets (master included only if view is master or off) + var writeTargets; + if (!onMaster || s.view === null || s.view === 'master') { + writeTargets = resolveViewTargets(); + } else { + // view is a specific player: only write to that player's copy, not master + writeTargets = resolveViewTargets(); + } + + // Process --get actions against getTargets + actions.forEach(function(a) { + if (a.type !== 'get') return; + if (s.view === 'master' && onMaster && getTargets.length > 1) { + // Compact format: show all values + var vals = getTargets.map(function(target) { + var tNotes = target.get('gmnotes') || ''; + try { tNotes = decodeURIComponent(tNotes); } catch(e) {} + var val = readGlField(tNotes, a.name); + if (!val) { + var charId = target.get('represents'); + if (charId) val = getAttrByName(charId, a.name) || ''; + } + var pageName = 'master'; + Object.values(s.activeGroups).forEach(function(active) { + if (target.get('_pageid') === active.masterPageId) pageName = 'master'; + Object.entries(active.playerPages).forEach(function(e) { + if (target.get('_pageid') === e[1].pageId) pageName = e[1].name || e[0]; + }); + }); + return pageName + ': ' + (val || ''); + }); + output.push('' + tokenName + ' ' + a.name + ' — ' + vals.join(', ')); + } else { + var target = getTargets[0]; + var tNotes = target.get('gmnotes') || ''; + try { tNotes = decodeURIComponent(tNotes); } catch(e) {} + var val = readGlField(tNotes, a.name); + if (!val) { + var charId = target.get('represents'); + if (charId) val = getAttrByName(charId, a.name) || ''; + } + output.push('' + tokenName + ' ' + a.name + ' = ' + (val || '(not set)')); + } + }); + + // Process --set/--del/--setch/--delch against writeTargets + var prevGmnotes_original = t.get('gmnotes') || ''; + writeTargets.forEach(function(target) { + var prevGmnotes = target.get('gmnotes') || ''; + var notes = prevGmnotes; + try { notes = decodeURIComponent(notes); } catch(e) {} + var gmnotesChanged = false; + + actions.forEach(function(a) { + if (a.type === 'set') { + var rx = new RegExp(a.name + '\\s*[=:]\\s*[^\\n]*'); + if (notes.match(rx)) { + notes = notes.replace(rx, a.name + ': ' + a.value); + } else { + notes = (notes ? notes + '\n' : '') + a.name + ': ' + a.value; + } + gmnotesChanged = true; + } else if (a.type === 'del') { + notes = notes.replace(new RegExp('\\n?' + a.name + '\\s*[=:]\\s*[^\\n]*'), ''); + gmnotesChanged = true; + } else if (a.type === 'setch') { + var charId = target.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: a.name })[0]; + if (attr) { attr.set('current', a.value); } + else { createObj('attribute', { characterid: charId, name: a.name, current: a.value }); } + mutatedCharAttrs = true; + } else if (a.type === 'delch') { + var charId = target.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: a.name })[0]; + if (attr) attr.remove(); + mutatedCharAttrs = true; + } + }); + + if (gmnotesChanged) { + target.set('gmnotes', notes); + mutatedTokenGmnotes = true; + } + }); + + // Trigger script evaluation once, scoped to view (unless --silent) + if (mutatedTokenGmnotes && !silent) { + // Pick the right token to trigger from based on view + var triggerTarget; + if (!onMaster || s.view === null || s.view === 'master') { + triggerTarget = t; // master token: evaluates for all viewers + } else { + // Specific player view: trigger from their copy so eval scopes to that viewer + triggerTarget = writeTargets[0] || t; + } + onGmNotesChanged(triggerTarget, { gmnotes: prevGmnotes_original }); + } + + // Trigger eval for char attr mutations (scripts may resolve from char) + if (mutatedCharAttrs && !silent) { + var changedFields = actions.filter(function(a) { return a.type === 'setch' || a.type === 'delch'; }).map(function(a) { return a.name; }); + changedFields.forEach(function(field) { + var entries = triggerMap[field]; + if (!entries || entries.length === 0) return; + var masterTokenId = null; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = (active.linkedTokens[tokenId] || []).slice(); + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, idx) { return allLinked.indexOf(id) === idx; }).forEach(function(id) { + var tok = getObj('graphic', id); + if (tok && tok.get('_pageid') === active.masterPageId) masterTokenId = id; + }); + if (t.get('_pageid') === active.masterPageId) masterTokenId = tokenId; + }); + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false, masterTokenId, t.get('_pageid')); + }); + }); + } + }); + + // Build summary + var hasMutations = actions.some(function(a) { return a.type !== 'get'; }); + if (output.length > 0) { + reply(msg, 'Var', output.join('
')); + } + if (hasMutations) { + var summary = actions.filter(function(a) { return a.type !== 'get'; }).map(function(a) { + if (a.type === 'set') return 'set ' + a.name + ' = ' + a.value; + if (a.type === 'del') return 'del ' + a.name; + if (a.type === 'setch') return 'setch ' + a.name + ' = ' + a.value; + if (a.type === 'delch') return 'delch ' + a.name; + }).join(', '); + reply(msg, 'Var', summary + ' on ' + tokens.length + ' token(s).' + (silent ? ' (silent)' : '')); + } + }; + const doGroup = (msg, args) => { if (args.length < 2) { reply(msg, 'Error', 'Usage: !gaslight group <group> <player|GM>'); return; } const groupName = args.shift(); @@ -1468,7 +1735,12 @@ var Gaslight = Gaslight || (() => { else reply(msg, 'Warning', 'Player "' + name + '" not found in group.'); }); } else { - targetPlayerIds = Object.keys(groupInfo.players); + // Default: follow current view target + if (s.view && s.view !== 'master' && groupInfo.players[s.view]) { + targetPlayerIds = [s.view]; + } else { + targetPlayerIds = Object.keys(groupInfo.players); + } } if (targetPlayerIds.length === 0) { reply(msg, 'Error', 'No valid target players.'); return; } @@ -1497,6 +1769,13 @@ var Gaslight = Gaslight || (() => { links.forEach(function(l) { if (l.target) allLinks.push(l); }); }); establishLinks(groupName, groupDiscovered, allLinks); + + // Align synced properties from source tokens to newly linked children + if (typeof Mirror !== 'undefined' && Mirror.align) { + tokens.forEach(function(token) { + Mirror.align(token.get('id'), { ifLinked: true }); + }); + } } reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); @@ -1546,6 +1825,11 @@ var Gaslight = Gaslight || (() => { links.forEach(function(l) { if (l.target) allLinks.push(l); }); }); establishLinks(groupName, groupDiscovered, allLinks); + + // Align synced properties from source to newly linked children + if (typeof Mirror !== 'undefined' && Mirror.align) { + Mirror.align(obj.get('id'), { ifLinked: true }); + } }, 500); }; @@ -1712,8 +1996,7 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' + '' + CMD + ' desync [props|all] -- Exclude props from sync
' - + '' + CMD + ' sync [props|all|reset] -- Manage sync per token
' - + '' + CMD + ' desync [props|all] -- Exclude props from sync
' + + '' + CMD + ' var [--silent] [actions...] -- Read/set/unset gl_ vars
' + '' + CMD + ' view [master|off|<player>] -- Control relay targeting
' + '' + CMD + ' relay <views> <!cmd> -- Relay command to views
' + '' + CMD + ' group <group> <player|GM> -- Assign page
' @@ -2431,9 +2714,12 @@ var Gaslight = Gaslight || (() => { var gmPlayer = findObjs({ _type: 'player' }).find(function(p) { return playerIsGM(p.get('_id')); }); if (gmPlayer) senderId = gmPlayer.get('_id'); } - sendChat('', CMD + ' --script-lock', null, { noarchive: true }); - sendChat(getPlayerName(senderId), fullCmd); - sendChat('', CMD + ' --script-unlock', null, { noarchive: true }); + var batch = '!{{\n' + + CMD + ' --script-lock\n' + + fullCmd + ' {& select ' + viewerTarget.get('id') + '}\n' + + '(^)!^gaslight --script-unlock\n' + + '}}'; + sendChat(getPlayerName(senderId), batch); } } }; @@ -2569,6 +2855,7 @@ var Gaslight = Gaslight || (() => { case 'unlink': doUnlink(msg, args); break; case 'sync': doSync(msg, args); break; case 'desync': doDesync(msg, args); break; + case 'var': doVar(msg, args); break; case 'group': doGroup(msg, args); break; case 'ungroup': doUngroup(msg, args); break; case 'relay': doRelay(msg, args); break; diff --git a/Gaslight/README.md b/Gaslight/README.md index 4b38ebe4a..875fd96cf 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -42,6 +42,7 @@ Per-player map perception for Roll20. Split players onto individual copies of a | `!gaslight unlink [ids...\|--group ]` | Remove links | | `!gaslight sync [props\|all\|reset]` | Manage sync whitelist per token | | `!gaslight desync [props\|all]` | Exclude props from sync per token | +| `!gaslight var [--silent] [actions...]` | Read/set/unset gl_* variables | | `!gaslight view [master\|off\|]` | Control command relay targeting | | `!gaslight relay ` | Relay command to views | | `!gaslight group ` | Assign page to group | @@ -80,6 +81,42 @@ Controlled by `gaslight_sync` in token GM notes (auto-populated from character a | `!gaslight desync ` | Exclude specific props from sync | | `!gaslight desync all` | Disable all syncing (link preserved) | +### gl_* Variables (`!gaslight var`) + +Read, set, or unset `gl_*` variables on tokens (gmnotes) or character sheets. Actions are chainable in a single command. Respects the current view for reads and writes. + +**Actions:** + +| Action | Description | +|--------|-------------| +| `--get ` | Read value (token gmnotes priority, fallback to character attribute) | +| `--set ` | Set on token gmnotes | +| `--del ` | Remove from token gmnotes | +| `--setch ` | Set on character sheet attribute | +| `--delch ` | Remove from character sheet attribute | + +**Flags:** + +| Flag | Description | +|------|-------------| +| `--silent` | Don't trigger script evaluation after setting | + +**View behavior (master token selected):** +- `view master` → read/write all linked copies (compact display for `--get`) +- `view ` → read/write only that player's copy +- `view off` → read/write master token only + +**Examples:** +``` +!gaslight var --set stealth_result 15 +!gaslight var --get stealth_result +!gaslight var --set stealth_result 20 --setch stealth_dc 12 +!gaslight var --del stealth_result --delch stealth_dc +!gaslight var --silent --set stealth_result 25 +``` + +The `gl_` prefix is implicit — `stealth_result` and `gl_stealth_result` both map to `gl_stealth_result`. + ## Command Relay Any API command that references master-page linked tokens (via selection or token IDs in the command) is automatically relayed to all player pages with token IDs replaced by their linked counterparts. This happens transparently — no configuration needed. @@ -150,8 +187,10 @@ On-canvas indicators on the master page foreground layer. Toggle with `!gaslight ## Staging -- `!gaslight stage` — propagate selected tokens to all player pages +- `!gaslight stage` — propagate selected tokens to player pages (follows current view; all if view is master/off) +- `!gaslight stage ` — propagate to a specific player's page - `gaslight_stage = 1` character attribute — auto-propagate on placement +- Staged tokens inherit all synced properties via Mirror after linking - Linked tokens cascade-delete when removed ## Configuration Storage From 789c1c3fa035a6da7a39cbec0b9b289ed5f78a64 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 5 Jul 2026 16:34:39 -0400 Subject: [PATCH 40/41] Gaslight: quote-aware arg parser, remove dead flag from setup usage - Args now support double quotes, single quotes, and backticks for multi-word values - Fixes: !gaslight setup mygroup 'Kenan Millet' now works correctly - Remove unused flag from setup usage text --- Gaslight/2.1.0/Gaslight.js | 10 ++++++++-- Gaslight/Gaslight.js | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Gaslight/2.1.0/Gaslight.js b/Gaslight/2.1.0/Gaslight.js index a1fd9e04d..7a38345ed 100644 --- a/Gaslight/2.1.0/Gaslight.js +++ b/Gaslight/2.1.0/Gaslight.js @@ -855,7 +855,7 @@ var Gaslight = Gaslight || (() => { * Expects N+1 pages with the same name (or name prefix). Assigns master + players. */ const doSetup = (msg, args) => { - if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [players...]'); return; } var groupName = args.shift(); // Determine players: selected tokens + named args, fallback to party tags @@ -2843,7 +2843,13 @@ var Gaslight = Gaslight || (() => { if (msg.content.split(' ')[0] !== CMD) return; if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; - const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const rawArgs = msg.content.slice(CMD.length).trim(); + const args = []; + var argRx = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g; + var argMatch; + while ((argMatch = argRx.exec(rawArgs)) !== null) { + args.push(argMatch[1] !== undefined ? argMatch[1] : argMatch[2] !== undefined ? argMatch[2] : argMatch[3] !== undefined ? argMatch[3] : argMatch[4]); + } const sub = (args.shift() || '').toLowerCase(); switch (sub) { diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index a1fd9e04d..7a38345ed 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -855,7 +855,7 @@ var Gaslight = Gaslight || (() => { * Expects N+1 pages with the same name (or name prefix). Assigns master + players. */ const doSetup = (msg, args) => { - if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [players...]'); return; } var groupName = args.shift(); // Determine players: selected tokens + named args, fallback to party tags @@ -2843,7 +2843,13 @@ var Gaslight = Gaslight || (() => { if (msg.content.split(' ')[0] !== CMD) return; if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; - const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const rawArgs = msg.content.slice(CMD.length).trim(); + const args = []; + var argRx = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g; + var argMatch; + while ((argMatch = argRx.exec(rawArgs)) !== null) { + args.push(argMatch[1] !== undefined ? argMatch[1] : argMatch[2] !== undefined ? argMatch[2] : argMatch[3] !== undefined ? argMatch[3] : argMatch[4]); + } const sub = (args.shift() || '').toLowerCase(); switch (sub) { From 34b8dfd732849b43a6a0de985d15295691f9aa79 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 5 Jul 2026 18:30:08 -0400 Subject: [PATCH 41/41] Gaslight: auto-rebuild triggerMap on script/pin changes - Cache pin IDs during buildTriggerMap (workaround: findObjs for pins fails in non-chat contexts) - rebuildTriggerMapFromCache uses getObj which works in all contexts - Handout notes change: rebuild if handout is linked by a cached pin - Pin gmnotes change: rebuild if pin is in cache - Pin link/linkType change: update cache and rebuild - New pin added to active page: add to cache and rebuild --- Gaslight/2.1.0/Gaslight.js | 101 +++++++++++++++++++++++++++++++++++++ Gaslight/Gaslight.js | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/Gaslight/2.1.0/Gaslight.js b/Gaslight/2.1.0/Gaslight.js index 7a38345ed..de9a88036 100644 --- a/Gaslight/2.1.0/Gaslight.js +++ b/Gaslight/2.1.0/Gaslight.js @@ -2098,6 +2098,7 @@ var Gaslight = Gaslight || (() => { // triggerMap: attributeName → [{ pinId, pageId }] var triggerMap = {}; + var knownScriptPins = []; // cached pin IDs + handout links from last buildTriggerMap /** * Parse a script's conditional blocks to find referenced attributes for auto-triggering. @@ -2130,6 +2131,7 @@ var Gaslight = Gaslight || (() => { */ const buildTriggerMap = () => { triggerMap = {}; + knownScriptPins = []; var s = state[SCRIPT_NAME]; Object.values(s.activeGroups).forEach(function(group) { @@ -2137,6 +2139,7 @@ var Gaslight = Gaslight || (() => { allPageIds.forEach(function(pageId) { var pins = findScriptPins(pageId); pins.forEach(function(pin) { + knownScriptPins.push({ pinId: pin.get('_id'), pageId: pageId, handoutId: pin.get('link') || null }); parsePinConfig(pin, function(config) { if (!config) return; var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); @@ -2168,6 +2171,43 @@ var Gaslight = Gaslight || (() => { }); }; + /** + * Rebuild triggerMap using cached pin IDs (works in non-chat contexts where findObjs fails for pins). + * Uses getObj('pin', id) which works in all contexts. + */ + const rebuildTriggerMapFromCache = () => { + if (knownScriptPins.length === 0) return; + triggerMap = {}; + knownScriptPins.forEach(function(cached) { + var pin = getObj('pin', cached.pinId); + if (!pin) return; + parsePinConfig(pin, function(config) { + if (!config) return; + var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); + var manualOnly = config.triggers.some(function(t) { return t === 'manual only'; }); + if (manualOnly) return; + + if (explicitTriggers.length > 0) { + explicitTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: cached.pinId, pageId: cached.pageId }); + }); + } else { + getPinScript(pin, function(content) { + if (!content) return; + var autoTriggers = parseTriggersFromScript(content); + var ignored = config.triggers.filter(function(t) { return t.startsWith('ignore '); }).map(function(t) { return t.slice(7).trim(); }); + autoTriggers = autoTriggers.filter(function(t) { return ignored.indexOf(t) === -1; }); + autoTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: cached.pinId, pageId: cached.pageId }); + }); + }); + } + }); + }); + }; + /** * Handle attribute changes — check trigger map and re-evaluate affected pins. */ @@ -4915,6 +4955,67 @@ var Gaslight = Gaslight || (() => { reflowInitiativeHud('none'); }); + + // --- TriggerMap auto-rebuild --- + + // Helper: check if a page belongs to any active group + var isActivePage = function(pageId) { + var s = state[SCRIPT_NAME]; + return Object.values(s.activeGroups).some(function(g) { + if (g.masterPageId === pageId) return true; + return Object.values(g.playerPages).some(function(p) { return p.pageId === pageId; }); + }); + }; + + // 1. Handout notes change → rebuild triggerMap from cache + on('change:handout:notes', function(obj) { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var handoutId = obj.get('_id'); + if (knownScriptPins.some(function(p) { return p.handoutId === handoutId; })) { + rebuildTriggerMapFromCache(); + } + }); + + // 2. Pin gmnotes change → rebuild from cache if pin is known + on('change:pin:gmNotes', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { + rebuildTriggerMapFromCache(); + } + }); + + // 3. Pin link/linkType change → rebuild from cache + on('change:pin:link', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + // Update cache entry with new handout link + var pinId = obj.get('_id'); + var cached = knownScriptPins.find(function(p) { return p.pinId === pinId; }); + if (cached) { + cached.handoutId = obj.get('link') || null; + rebuildTriggerMapFromCache(); + } else if (isActivePage(obj.get('_pageid')) && obj.get('link') && obj.get('linkType') === 'handout') { + // New script pin — add to cache and rebuild + knownScriptPins.push({ pinId: pinId, pageId: obj.get('_pageid'), handoutId: obj.get('link') }); + rebuildTriggerMapFromCache(); + } + }); + on('change:pin:linkType', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { + rebuildTriggerMapFromCache(); + } + }); + + // 4. New pin added to active page → add to cache and rebuild + on('add:pin', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (!isActivePage(obj.get('_pageid'))) return; + if (obj.get('link') && obj.get('linkType') === 'handout') { + knownScriptPins.push({ pinId: obj.get('_id'), pageId: obj.get('_pageid'), handoutId: obj.get('link') }); + rebuildTriggerMapFromCache(); + } + }); }; return { checkInstall, registerEventHandlers }; diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7a38345ed..de9a88036 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2098,6 +2098,7 @@ var Gaslight = Gaslight || (() => { // triggerMap: attributeName → [{ pinId, pageId }] var triggerMap = {}; + var knownScriptPins = []; // cached pin IDs + handout links from last buildTriggerMap /** * Parse a script's conditional blocks to find referenced attributes for auto-triggering. @@ -2130,6 +2131,7 @@ var Gaslight = Gaslight || (() => { */ const buildTriggerMap = () => { triggerMap = {}; + knownScriptPins = []; var s = state[SCRIPT_NAME]; Object.values(s.activeGroups).forEach(function(group) { @@ -2137,6 +2139,7 @@ var Gaslight = Gaslight || (() => { allPageIds.forEach(function(pageId) { var pins = findScriptPins(pageId); pins.forEach(function(pin) { + knownScriptPins.push({ pinId: pin.get('_id'), pageId: pageId, handoutId: pin.get('link') || null }); parsePinConfig(pin, function(config) { if (!config) return; var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); @@ -2168,6 +2171,43 @@ var Gaslight = Gaslight || (() => { }); }; + /** + * Rebuild triggerMap using cached pin IDs (works in non-chat contexts where findObjs fails for pins). + * Uses getObj('pin', id) which works in all contexts. + */ + const rebuildTriggerMapFromCache = () => { + if (knownScriptPins.length === 0) return; + triggerMap = {}; + knownScriptPins.forEach(function(cached) { + var pin = getObj('pin', cached.pinId); + if (!pin) return; + parsePinConfig(pin, function(config) { + if (!config) return; + var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); + var manualOnly = config.triggers.some(function(t) { return t === 'manual only'; }); + if (manualOnly) return; + + if (explicitTriggers.length > 0) { + explicitTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: cached.pinId, pageId: cached.pageId }); + }); + } else { + getPinScript(pin, function(content) { + if (!content) return; + var autoTriggers = parseTriggersFromScript(content); + var ignored = config.triggers.filter(function(t) { return t.startsWith('ignore '); }).map(function(t) { return t.slice(7).trim(); }); + autoTriggers = autoTriggers.filter(function(t) { return ignored.indexOf(t) === -1; }); + autoTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: cached.pinId, pageId: cached.pageId }); + }); + }); + } + }); + }); + }; + /** * Handle attribute changes — check trigger map and re-evaluate affected pins. */ @@ -4915,6 +4955,67 @@ var Gaslight = Gaslight || (() => { reflowInitiativeHud('none'); }); + + // --- TriggerMap auto-rebuild --- + + // Helper: check if a page belongs to any active group + var isActivePage = function(pageId) { + var s = state[SCRIPT_NAME]; + return Object.values(s.activeGroups).some(function(g) { + if (g.masterPageId === pageId) return true; + return Object.values(g.playerPages).some(function(p) { return p.pageId === pageId; }); + }); + }; + + // 1. Handout notes change → rebuild triggerMap from cache + on('change:handout:notes', function(obj) { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var handoutId = obj.get('_id'); + if (knownScriptPins.some(function(p) { return p.handoutId === handoutId; })) { + rebuildTriggerMapFromCache(); + } + }); + + // 2. Pin gmnotes change → rebuild from cache if pin is known + on('change:pin:gmNotes', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { + rebuildTriggerMapFromCache(); + } + }); + + // 3. Pin link/linkType change → rebuild from cache + on('change:pin:link', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + // Update cache entry with new handout link + var pinId = obj.get('_id'); + var cached = knownScriptPins.find(function(p) { return p.pinId === pinId; }); + if (cached) { + cached.handoutId = obj.get('link') || null; + rebuildTriggerMapFromCache(); + } else if (isActivePage(obj.get('_pageid')) && obj.get('link') && obj.get('linkType') === 'handout') { + // New script pin — add to cache and rebuild + knownScriptPins.push({ pinId: pinId, pageId: obj.get('_pageid'), handoutId: obj.get('link') }); + rebuildTriggerMapFromCache(); + } + }); + on('change:pin:linkType', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { + rebuildTriggerMapFromCache(); + } + }); + + // 4. New pin added to active page → add to cache and rebuild + on('add:pin', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (!isActivePage(obj.get('_pageid'))) return; + if (obj.get('link') && obj.get('linkType') === 'handout') { + knownScriptPins.push({ pinId: obj.get('_id'), pageId: obj.get('_pageid'), handoutId: obj.get('link') }); + rebuildTriggerMapFromCache(); + } + }); }; return { checkInstall, registerEventHandlers };