diff --git a/Gaslight/1.0.0/Gaslight.js b/Gaslight/1.0.0/Gaslight.js new file mode 100644 index 000000000..991ab65df --- /dev/null +++ b/Gaslight/1.0.0/Gaslight.js @@ -0,0 +1,1034 @@ +// ============================================================================= +// Gaslight v1.0.0 +// Last Updated: 2026-06-14 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor. Each player can see different things +// while token movement stays consistent across all copies. +// +// Dependencies: Anchor +// +// Commands: +// !gaslight split Activate a prepared gaslight 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...] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight master Designate page as group master +// !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 = '1.0.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + + // ========================================================================= + // 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 } + }; + } + }; + + // ========================================================================= + // 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 */ } + const match = notes.match(/gaslight_link:\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. + */ + 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')); + } + }; + + // ========================================================================= + // 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'); }); + + 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')); + }); + } else { + // Player-controlled: chain-link master + controlling players' pages + // Non-controlling player pages become children of one chain member + 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; }); + + // Chain-link the peer tokens + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds); + } + + // Non-controlling player page tokens become children of the first chain member + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id')); + }); + } + } + + // 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); + } + }); + + // 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 + // ========================================================================= + + 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(autoPopulateLinkId); + }); + + // 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); + + 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); + }; + + 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); }); + } + + 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 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 + '.'); + }; + + 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 + ' 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 + ' group <group> <player|GM> -- Assign page
' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' status -- Show state
' + + '' + CMD + ' --help -- This help
'; + + // ========================================================================= + // 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 '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 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/DESIGN.md b/Gaslight/DESIGN.md new file mode 100644 index 000000000..2464f64c7 --- /dev/null +++ b/Gaslight/DESIGN.md @@ -0,0 +1,383 @@ +# Gaslight - Design Document + +## Concept + +Gaslight makes it easy to give each player their own perception of the same map. One command splits players onto individual copies of a page, and tokens are automatically synchronized across all copies so movement stays consistent -- but each player can see different things (different token art, different names, hidden/revealed tokens, etc.). + +## Use Cases + +- **Illusions**: One player sees a bridge, another sees empty air +- **Shapechangers**: A disguised NPC looks different to a player with truesight +- **Stealth**: A rogue is visible on their own map but absent from others +- **Madness/Hallucinations**: A player sees enemies that aren't there +- **Stealth/Perception**: A stealthing creature is invisible on most player maps, semi-transparent for a player who rolled high perception, and fully visible for a player with truesight — all on the same "map" simultaneously +- **Secrets**: An NPC whispers something -- only one player sees the speech bubble token + +## Core Features + +### 1. Page Split + +Two modes: + +**On-demand mode** (`!gaslight split`): +- Clones the current page (everything: tokens, paths, DL walls, text) once per player +- Original becomes the master page (GM stays here) +- Each player is assigned to their own copy via `playerspecificpages` +- Gaslit tokens are linked via Anchor + +**Pre-setup mode** (`!gaslight split `): +- Pages are pre-configured with gaslight group metadata (stored in page GM notes) +- Group config specifies: shared group ID, player-to-page assignments, designated master page +- One page can belong to multiple gaslight groups (different player assignments per group) +- On activation: moves party tokens to their assigned pages, sets up Anchor links +- Does NOT copy or modify the map -- assumes GM has already prepared per-player differences +- If party tokens already exist on target pages, does not duplicate them +- **Test-first behavior** (default): + - Runs linking resolution before splitting + - If errors (e.g. duplicate link IDs): blocks split, shows results, no proceed option + - If warnings/info only: shows results + a clickable `[Proceed]` button in chat + - If clean (no issues): splits immediately without prompting +- `--force` flag skips the test and splits immediately regardless of warnings/errors + +**Merge** (`!gaslight merge`): +- Returns all players to the master page +- Tears down Anchor links / peer sync +- **On-demand splits**: deletes cloned pages (they were ad-hoc) +- **Pre-setup splits**: preserves pages, only unlinks sync (GM prepared these intentionally) +- A page property (in GM notes metadata) tracks whether it is ad-hoc or pre-setup + +### 2. Token Sync + +Two sync modes, auto-detected per token based on how many players *in the active gaslight group* can control it: + +**Anchor mode** (0 or 1 controlling player in group): +- **NPC tokens (0 controllers)**: Parent on master page. GM moves on master, Anchor propagates to all player pages. +- **Single-player tokens (1 controller)**: Parent on that player's page. Player moves their token, Anchor propagates to master + other player pages. + +**Peer mode** (2+ controlling players in group): +- No Anchor parent/child relationship. Gaslight's own `change:graphic` listener handles sync. +- Movement on any page where a controller lives is authoritative and propagates to all other pages. +- Use case: shared torches, vehicles, objects multiple players can interact with. + +**GM override** (both modes): If the GM moves a token copy on the master page, Gaslight detects this and propagates to the parent (Anchor mode) or all peers (peer mode). This allows GM to move any token from master (teleportation, forced movement, etc.). + +### 3. Master Page + +- Always separate -- no player is ever assigned to the master page +- GM's control surface for the gaslit encounter +- NPC parents live here (GM moves NPCs from master) +- Player token children live here (sync from player pages, GM-movable via override) +- Future possibilities: + - Toggle views: macro buttons to cycle between player perspectives without switching pages + - Diff display: show per-player differences in the GM/foreground layer + - Staging area: set up new tokens and "commit" them to player pages + +### 4. Token Linking Resolution + +All tokens with a `represents` value (character sheet) are candidates for cross-page linking. Tokens without a character sheet are page-local and never linked. + +Linking is resolved per-token from the authoritative page (master for NPCs, player's page for player tokens) to each other page, using the following cascade: + +**Step 1: Token GM notes — `gaslight_link` ID** +If a token's GM notes contain a `gaslight_link: ` entry, it links to any other token on another page with the same `gaslight_link` ID in its GM notes. This is per-token, does not require `represents` or a character sheet at all, and works for any object type. Set manually via `!gaslight link`, or auto-populated from a `gaslight_link` character attribute when the token is placed or split runs. + +**Step 2: `represents` + `name`** +For tokens with a `represents` value: if there is exactly one token with this character+name pair on each page, link them. If a page has multiple tokens with the same `represents` + `name`, those tokens fall through to step 3. + +**Step 3: `represents` + position + bars (fingerprint)** +For tokens not resolved by steps 1-2, attempt exact match by: `represents`, `left`, `top`, `width`, `height`, `rotation`, `bar1_value`, `bar1_max`, `bar2_value`, `bar2_max`, `bar3_value`, `bar3_max`. This disambiguates duplicate creatures (e.g. multiple goblins) that were placed identically across pages. + +**Step 4: No link — warn GM** +If no unique match is found, the token is not linked. Gaslight whispers warnings to the GM with varying urgency: + +1. **Info** — A token with `gaslight_link` is missing from some (but not all) group pages. Likely intentional per-player difference. +2. **Warning** — A token with `gaslight_link` exists on only one page. Likely a setup mistake. +3. **Warning** — A `represents` token on the master page failed to link to at least one player page. Master is source of truth; unlinked master tokens are likely unintentional. +4. **Error** — Duplicate `gaslight_link` ID found on the same page. Link resolution will not work correctly for these tokens. Must be fixed. + +Suggestions for near-matches are a v2 feature. + +### Order of Operations + +Linking runs in passes across ALL tokens, not per-token: + +1. First pass: attempt step 1 (gmnotes link ID) for every token on all pages. +2. Second pass: attempt step 2 (represents + name) for every still-unlinked token. +3. Third pass: attempt step 3 (fingerprint) for every still-unlinked token. +4. Final: report step 4 warnings for anything still unlinked. + +**Critical rule**: A token that has already been linked (matched as a target in a previous step/pass) is excluded from being matched again. This prevents a single token from being claimed by multiple sources and ensures each link is unique. + +### Auto-population from character attribute + +If a character has a `gaslight_link` attribute, its value is automatically written into the `gmnotes` of any token representing that character when: +- The token is first placed on a gaslit page +- `!gaslight split` runs + +This allows GMs to set linking at the character level for simple cases (unique NPCs) while retaining per-token override for duplicates. + +### 5. Manual Linking + +`!gaslight link [|new] [--ignore-selected] [...]` + +Writes a shared `gaslight_link` ID into the GM notes of all specified/selected tokens. + +- `` — Use this as the link ID. Tokens with the same link ID across pages will be linked. +- `new` — Auto-generate a unique link ID. +- No name argument — use the existing link ID from the first token (for adding tokens to an existing link group). +- `--ignore-selected` — Skip selected tokens, only use explicit IDs. + +Examples: +- `!gaslight link goblin-shaman` — selected tokens all get `gaslight_link: goblin-shaman` +- `!gaslight link new` — selected tokens get a generated unique ID +- `!gaslight link new -AbC123 -DeF456` — explicitly link two tokens by ID + +`!gaslight unlink [--ignore-selected] [...]` — Remove the `gaslight_link` entry from tokens' GM notes. + +### 6. Test Command + +`!gaslight test ` — Dry-run the linking resolution. Reports: +- Tokens that would link (and by which step) +- Tokens that are ambiguous (with suggested matches) +- Tokens that have no match + +No state changes are made. Use before `split` to verify setup. + +### 6. Sync Properties + +**Always sync** (hardcoded): left, top, rotation, width, height + +### Configurable Sync Properties + +Controlled by the `gaslight_sync` character attribute (v2): + +- **No attribute present** → sync `base` (default behavior) +- **Attribute present, empty value** → sync nothing (linked for identity tracking only, effectively excluded from sync) +- **Attribute with values** → sync only the listed properties (comma-separated) + +Available sync properties: +- `base` -- shorthand for left, top, rotation, width, height +- `left`, `top`, `rotation`, `width`, `height` -- individual position/size +- `side` -- multi-sided token current side index (`currentSide`) +- `light` -- light emission (radius, dimradius, angle, otherplayers) +- `statusmarkers` -- conditions (all-or-nothing in v1; per-marker in v2+) +- `bar1`, `bar2`, `bar3` -- HP/resource values +- `layer` -- visibility layer +- `opacity` -- token opacity (baseOpacity) + +Example: `gaslight_sync = "base, light, opacity"` syncs position + light + opacity. + +**Sight rules** (hardcoded logic): +- Child/peer tokens have sight stripped by default +- Exception: sight is preserved if the parent/source has sight AND the player assigned to that page can control the character +- This ensures each player only sees through their own token's eyes, not from copies + +### 7. Lights and Torches + +Roll20 has no dedicated "light" object type -- lights are just graphic tokens with `light_radius`/`light_dimradius` properties. Gaslight treats them the same as any other token. + +Multi-controller torches (controlled by multiple players in the gaslight group) automatically use peer sync mode, allowing any of those players to move the light on their page and have it propagate. + +### 8. Reactions (Token Triggers) + +When Gaslight propagates movement, reaction tokens on non-authoritative pages would fire duplicate reactions. Gaslight suppresses this: + +**v1 (default)**: Only the authoritative page fires reactions. +- Player moves their token → reactions fire on their page only +- GM moves from master → reactions fire on master only +- On non-authoritative pages, Gaslight watches for `change:graphic:interactionTriggered` and resets it (`interactionTriggered = false`) to suppress duplicate firing + +**v2 (configurable)**: Per-reaction-token control via attribute (e.g. `gaslight_reaction`): `source-only` (default), `master-only`, `all`, `suppress`. + +**API mechanism**: The `interactionTriggered` property on graphic objects fires a `change:graphic:interactionTriggered` event when a reaction activates. Gaslight can intercept and reset this on non-authoritative pages. + +### 9. New Tokens After Split + +- Auto-commit (configurable via script.json useroptions, toggleable at runtime): + - When ON: new gaslit tokens placed on master auto-clone to player pages with Anchor links + - When OFF: GM uses `!gaslight commit` to manually push new tokens to player pages +- Non-gaslit tokens placed on any page stay local (that's the point) + +### 10. Party Detection + +Priority order: +1. Selected tokens (default) +2. Party-tagged characters (fallback -- uses Roll20's `tags` property from Define Party) +3. No further fallback -- if neither is available, error + +## Gaslight Group Config (Text object on GM layer) + +Config is stored as text objects on the GM layer of each page -- one text object per gaslight group. This keeps config physically tied to the page, visible to the GM, and portable when pages are duplicated. + +Master page format: +``` +---GASLIGHT--- +group: haunted-mansion +player: GM +``` + +Player page format: +``` +---GASLIGHT--- +group: haunted-mansion +player: Kenan Millet +playerid: -ABC123 +``` + +Storage rules: +- Text object on `gmlayer`, content starts with `---GASLIGHT---` header +- One text object per group membership (a page in two groups has two text objects) +- `player: GM` designates the master page for that group (set when arg is `GM`, `gm`, or `master`, or if the resolved player is a GM) +- For player pages: `player` stores display name (human-readable), `playerid` stores the player object ID (used for reliable lookups, handles duplicate display names) +- `adhoc` field only on master page -- indicates the group was created by on-demand split (v2) +- Commands create/update these text objects automatically +- On page duplication (manual copy): Gaslight detects duplicated config text, clears player assignment, whispers a warning to the GM +- v2: config to toggle visibility + +### Split/Merge Edge Cases + +**Adhoc merge with multi-group child page:** +- Delete the gaslight text object for the merged group only +- If no other gaslight text objects remain on the page, delete the page +- If other groups still reference the page, leave it alive + +**Adhoc split when child pages already exist for the group:** +- Auto-assign to existing pages where a matching `player:` field exists +- Create new child pages (clone from master) only for players that don't have an existing page +- Existing child pages whose `player:` is not in the current selection/party are left dormant +- GM can add dormant players to the active split by calling split again with them selected +- Currently-assigned players remain on their page (not reassigned or disrupted) + +## Commands (Draft) + +| Command | Description | +|---------|-------------| +| `!gaslight split ` | Activate group (test-first; blocks on errors, prompts on warnings) | +| `!gaslight split --force` | Activate group (skip test, split immediately) | +| `!gaslight merge [group]` | Tear down links, return players | +| `!gaslight test ` | Dry-run linking resolution, report results | +| `!gaslight link [|new] [ids...]` | Manually link tokens across pages | +| `!gaslight unlink [ids...]` | Remove gaslight_link from tokens | +| `!gaslight group ` | Assign page to group (GM/gm/master = master page) | +| `!gaslight ungroup ` | Remove page from group | +| `!gaslight status` | Show current gaslight state | +| `!gaslight --help` | Command reference | + +Player resolution for `group`/`ungroup`: +- `GM`, `gm`, `master` → designates master page +- Player display name → resolves to player object, stores name + ID +- If two players share a display name → whispers disambiguation buttons showing each player's controlled characters, GM clicks the correct one +- Buttons embed the player ID internally (users never need to type IDs) +- If arg starts with `-` (Roll20 ID format) → treated as player ID directly (used by disambiguation button callbacks) + +## Dependencies + +- **Anchor** (cross-page position sync via parent/child) + +## Architecture Notes + +- Uses `Campaign().set('playerspecificpages', {...})` to assign per-player pages +- Page duplication (on-demand): `createObj("page", {...})`, then clone all graphics/paths/text/DL/doors/windows onto it +- Anchor handles position sync for single-controller tokens (unidirectional parent→child) +- Gaslight's own `change:graphic` listener handles peer sync (multi-controller) and GM override +- Recursion guard needed for all sync listeners (flag during propagation) +- Config stored as text object on GM layer per page (searchable by `---GASLIGHT---` prefix) +- On manual page copy: detect duplicated config text, clear player fields, warn GM +- State storage: `state.Gaslight` tracks active splits, runtime config, active sync mappings +- Party detection: selected → `tags` (Define Party) → error + +## Open Questions + +(None remaining — all feasibility confirmed) + +## Confirmed Feasibility + +- ✅ Anchor works cross-page (tested) +- ✅ Pages can be created via `createObj("page", {...})` +- ✅ Text objects on GM layer can store per-page config +- ✅ `currentSide` property exists for multi-sided token sync +- ✅ `interactionTriggered` property exists for reaction suppression + +## Known Limitations + +- `imgsrc` restriction: API can only use images already in the user's Roll20 library. On-demand cloning is fine since source tokens are already uploaded. Only matters if trying to set external URLs (Gaslight won't do this). +- Page creation via API creates a blank page -- all objects must be individually cloned onto it. + +## V1 MVP Scope + +### Included + +1. **Pre-setup split** (`!gaslight split `) — activate a prepared group, assign players to their pre-configured pages, move party tokens, set up Anchor links +2. **Merge** (`!gaslight merge`) — tear down Anchor links, unassign players from pages, preserve all pages +3. **Anchor-mode sync** — NPC tokens: parent on master, children on player pages. Player tokens: parent on their own page, children on master + other player pages. +4. **GM override** — GM moves a child token on master → Gaslight propagates upstream to the parent → Anchor propagates to all other children +5. **Token linking resolution** — 4-step cascade: `gaslight_link` attribute → `represents`+name → `represents`+position+bars fingerprint → warn GM +6. **Manual linking** (`!gaslight link `) — explicit override for ambiguous tokens +7. **Test command** (`!gaslight test `) — dry-run linking, report matches/ambiguities +8. **Sight stripping** — all Anchor children have sight stripped unconditionally +9. **Config storage** — text objects on GM layer, one per group per page, `---GASLIGHT---` header +10. **Page resolution** — selected token's page (default) or `--page "name"` argument +11. **Party detection** — selected tokens (default) → party-tagged characters (fallback) → error +12. **`!gaslight group `** — assign page to group; stores player name + ID for reliable lookup; GM/gm/master designates master page +13. **`!gaslight ungroup `** — remove page from group +14. **`!gaslight status`** — show all configured and active groups +15. **`!gaslight --help`** — command reference +16. **Startup warning** — whispers to GM about dangling groups (no master) +17. **Idempotent split** — re-running split with new players adds them without disrupting existing assignments +18. **Recursion guard** — flag during propagation to prevent echo loops + +### Not Included (v2+) + +- On-demand split (page cloning) +- Peer sync mode (multi-controller tokens) +- Configurable sync properties (`gaslight_sync`) +- Reaction suppression +- Auto-commit / `!gaslight commit` +- `!gaslight link` / `!gaslight unlink` (manual attribute commands) +- `!gaslight config` (runtime settings) + +## V2+ Roadmap + +### On-Demand Split (Page Cloning) +- `!gaslight split` (no group) clones current page N times +- `!gaslight test` (no group) dry-runs an ad-hoc split from the current page, showing what would be cloned and how tokens would link +- Adds `adhoc: true` to master config text +- Merge deletes adhoc child pages (unless they belong to another group) +- Requires: clone logic for all object types (graphics, paths, text, DL walls, doors, windows) +- **Changes to v1 systems**: Merge needs to check `adhoc` flag and conditionally delete pages. Split needs a no-arg path that creates pages instead of just assigning existing ones. + +### Peer Sync Mode +- Auto-detected: if 2+ players in the active group control a token, use peer sync instead of Anchor +- Gaslight's own `change:graphic` listener propagates movement from any controller's page to all others +- No parent/child — all copies are equal peers +- **Changes to v1 systems**: Sight stripping rule becomes conditional — children in Anchor mode still get stripped, but peer tokens preserve sight if the player on that page controls the character. Split logic needs to detect multi-controller tokens and skip Anchor setup for them. + +### Configurable Sync Properties +- `gaslight_sync` character attribute (comma-separated): `side`, `light`, `statusmarkers`, `bar1`, `bar2`, `bar3`, `layer` +- `change:graphic` listener checks which properties changed and only propagates configured ones +- **Changes to v1 systems**: The sync listener (currently only handling GM override) expands to also propagate configurable properties on any authoritative change. + +### Reaction Suppression +- On non-authoritative pages, watch `change:graphic:interactionTriggered` and reset to suppress duplicate reactions +- Default: source-only (only authoritative page fires) +- Per-token override via `gaslight_reaction` attribute: `source-only`, `master-only`, `all`, `suppress` +- **Changes to v1 systems**: Adds a new event listener. No changes to existing sync logic, but needs access to the "which page is authoritative for this move" context that the sync system already tracks. + +### Auto-Commit +- Configurable via `useroptions` and `!gaslight config auto-commit on/off` +- When ON: new gaslit tokens on master auto-clone to player pages with Anchor links +- `!gaslight commit` for manual push when auto-commit is OFF +- **Changes to v1 systems**: Adds `add:graphic` listener on master page. Commit logic reuses the same token-cloning code that split uses for initial setup. + +### Link/Unlink Commands +- `!gaslight link ` — set gaslight attribute on selected tokens' characters +- `!gaslight unlink` — remove gaslight attribute +- Convenience only — GM can always set attributes manually +- **Changes to v1 systems**: None — purely additive commands. + +### Additional V2 Ideas +- Per-status-marker sync granularity +- Master page view toggling (cycle between player perspectives via macro buttons) +- Master page diff display (show per-player differences on GM layer) +- Config visibility toggle (hide gaslight text in HTML comment) +- Choreograph/Sequence integration (lifecycle hooks for gaslight events) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js new file mode 100644 index 000000000..991ab65df --- /dev/null +++ b/Gaslight/Gaslight.js @@ -0,0 +1,1034 @@ +// ============================================================================= +// Gaslight v1.0.0 +// Last Updated: 2026-06-14 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor. Each player can see different things +// while token movement stays consistent across all copies. +// +// Dependencies: Anchor +// +// Commands: +// !gaslight split Activate a prepared gaslight 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...] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight master Designate page as group master +// !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 = '1.0.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + + // ========================================================================= + // 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 } + }; + } + }; + + // ========================================================================= + // 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 */ } + const match = notes.match(/gaslight_link:\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. + */ + 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')); + } + }; + + // ========================================================================= + // 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'); }); + + 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')); + }); + } else { + // Player-controlled: chain-link master + controlling players' pages + // Non-controlling player pages become children of one chain member + 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; }); + + // Chain-link the peer tokens + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds); + } + + // Non-controlling player page tokens become children of the first chain member + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id')); + }); + } + } + + // 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); + } + }); + + // 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 + // ========================================================================= + + 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(autoPopulateLinkId); + }); + + // 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); + + 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); + }; + + 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); }); + } + + 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 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 + '.'); + }; + + 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 + ' 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 + ' group <group> <player|GM> -- Assign page
' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' status -- Show state
' + + '' + CMD + ' --help -- This help
'; + + // ========================================================================= + // 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 '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 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/README.md b/Gaslight/README.md new file mode 100644 index 000000000..06a5aca1f --- /dev/null +++ b/Gaslight/README.md @@ -0,0 +1,74 @@ +# Gaslight + +Per-player map perception for Roll20. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things while token movement stays consistent across all copies. + +## Requirements + +- Roll20 Pro subscription (API access required) +- [Anchor](https://github.com/Roll20/roll20-api-scripts/tree/master/Anchor) (cross-page position sync) + +## Use Cases + +- **Illusions**: One player sees a bridge, another sees empty air +- **Shapechangers**: A disguised NPC looks different to a player with truesight +- **Stealth/Perception**: A stealthing creature is invisible on most maps, semi-transparent for a perceptive player +- **Madness/Hallucinations**: A player sees enemies that aren't there +- **Secrets**: Information visible to only one player + +## Quick Start + +1. Create your master page and duplicate it once per player +2. On each page, select a token and assign the page to a group: + - Master: `!gaslight group mygroup GM` + - Player pages: `!gaslight group mygroup PlayerName` +3. Dry-run to verify linking: `!gaslight test mygroup` +4. Activate: `!gaslight split mygroup` +5. When done: `!gaslight merge` + +## Commands + +| Command | Description | +|---------|-------------| +| `!gaslight split ` | Activate group (test-first; blocks on errors, prompts on warnings) | +| `!gaslight split --force` | Activate group (skip test, split immediately) | +| `!gaslight merge [group]` | Tear down links, return players to shared page | +| `!gaslight test ` | Dry-run linking resolution, report results | +| `!gaslight link [name\|new] [ids...]` | Manually link tokens across pages | +| `!gaslight unlink [ids...]` | Remove gaslight_link from tokens | +| `!gaslight unlink --group ` | Remove all links in a group | +| `!gaslight group ` | Assign page to group | +| `!gaslight ungroup ` | Remove page from group | +| `!gaslight status` | Show configured and active groups | +| `!gaslight --help` | Command reference | + +## Token Linking + +Gaslight automatically links tokens across pages using a 4-step cascade: + +1. **`gaslight_link` in token GM notes** -- Explicit link ID (set via `!gaslight link` or auto-populated from character attribute). No character sheet required. +2. **`represents` + `name`** -- Unique character+name pair per page. +3. **`represents` + fingerprint** -- Position, size, rotation, and bar values for disambiguating duplicates. +4. **No match** -- Warning whispered to GM. + +After split, all linked tokens have `gaslight_link` IDs written to their GM notes for instant re-linking on future splits. + +## Sync Behavior + +- **NPC tokens** (no player controller): Parent on master page, children on player pages. GM moves NPCs from master. +- **Player tokens** (one controller in group): Parent on player's page, children on master + other pages. Player moves their own token. +- **GM override**: GM can move any token on the master page -- propagates to the parent automatically. +- **Sight**: All child tokens have sight stripped. Only the parent (on the player's own page) retains vision. + +## Configuration Storage + +Group config is stored as text objects on the GM layer of each page (visible when viewing that layer). Format: + +``` +---GASLIGHT--- +group: mygroup +player: GM +``` + +## License + +MIT diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md new file mode 100644 index 000000000..02f2c9523 --- /dev/null +++ b/Gaslight/TODO.md @@ -0,0 +1,34 @@ +# Gaslight TODO + +## Done (v1.0.0) +- [x] Pre-setup split with test-first behavior +- [x] Merge (tear down Anchor, unassign players) +- [x] Anchor-mode sync (NPC + player tokens) +- [x] GM override (master child -> push to parent) +- [x] Token linking resolution (4-step cascade) +- [x] Manual linking (link/unlink/unlink --group) +- [x] Test command (dry-run) +- [x] Config storage (GM layer text objects with playerid) +- [x] Page resolution (selected token's page) +- [x] Party detection (selected -> tags fallback) +- [x] group/ungroup/status/--help +- [x] Startup dangling group warning +- [x] Sight stripping on children +- [x] Player disambiguation (clickable buttons) + +## v2 +- [ ] On-demand split (page cloning, adhoc flag, adhoc merge/cleanup) +- [ ] Ad-hoc test (no group arg) +- [ ] Peer sync mode (multi-controller tokens) +- [ ] Configurable sync properties (gaslight_sync attribute) +- [ ] Reaction suppression (interactionTriggered reset) +- [ ] Auto-commit / !gaslight commit +- [ ] Focus-ping players on split +- [ ] Config visibility toggle (hide text in HTML comment) +- [ ] Near-match suggestions in step 4 warnings +- [ ] Per-status-marker sync granularity +- [ ] Master page view toggling +- [ ] Choreograph/Sequence integration + +## Known Issues +- None currently diff --git a/Gaslight/script.json b/Gaslight/script.json new file mode 100644 index 000000000..5553b332a --- /dev/null +++ b/Gaslight/script.json @@ -0,0 +1,20 @@ +{ + "name": "Gaslight", + "script": "Gaslight.js", + "version": "1.0.0", + "previousversions": [], + "description": "Per-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", + "authors": "Kenan Millet", + "roll20userid": "2614613", + "dependencies": ["Anchor"], + "modifies": { + "graphic": "read, write", + "text": "read, write", + "character": "read", + "attribute": "read", + "campaign": "read, write", + "page": "read" + }, + "conflicts": [], + "useroptions": [] +}