diff --git a/Anchor/2.2.1/anchor.js b/Anchor/2.2.1/anchor.js new file mode 100644 index 0000000000..e5d4754c22 --- /dev/null +++ b/Anchor/2.2.1/anchor.js @@ -0,0 +1,2705 @@ +// ============================================================================= +// Anchor v2.2.1 +// Last Updated: 2026-06-26 +// Author: Kenan Millet +// +// Description: +// Attach child graphics to an anchor graphic so they automatically mirror +// the anchor's transform (position, rotation, scale, flip). +// Supports arbitrary chains: a child can itself be an anchor to grandchildren. +// +// Dependencies: MatrixMath +// +// Commands: +// !anchor [] [flags...] [ignore-selected] [...] +// Anchor selected token(s) (and any listed IDs) to anchor_id. +// By default anchors all transform components (position, rotation, scale, +// flipv, fliph). +// If anchor_id is omitted or not a valid token ID, an invisible anchor +// token is automatically created at the first child's position and +// auto-destroyed when its last child is removed. Add persist to keep it: +// !anchor persist [anchor_id] [flags] [child_id...] +// +// Component flags — long form (anchor-) or short alias (-): +// anchor-all / -all = every component including z-order +// anchor / (no flags) = default: pos+rot+scale+flip (no layer or z-order) +// anchor-position / -pos = x + y +// anchor-x / -x = x position only +// anchor-y / -y = y position only +// anchor-rotation / -rot = rotation only +// anchor-scale / -scale = width + height +// anchor-width / -w = width only +// anchor-height / -h = height only +// anchor-layer / -layer = layer only +// anchor-flip / -flip = flipv + fliph +// anchor-flipv / -flipv = vertical flip only +// anchor-fliph / -fliph = horizontal flip only +// anchor-z / -z = z-order (relative stacking, use Anchor.updateZOrder() after moving anchor) +// +// !anchor remove [ignore-selected] [...] +// Remove anchor from selected/listed tokens. +// +// !anchor lock [component flags] [ignore-selected] [...] +// Lock components for child(ren). Locked components are re-enforced every +// poll tick — manual moves are undone, anchor changes are ignored for those +// components. With no component flags, locks ALL components. +// Components not yet tracked are stored as "pre-locked" and will be locked +// automatically if/when tracking is added via !anchor track. +// +// !anchor unlock [component flags] [ignore-selected] [...] +// Unlock components. With no component flags, unlocks everything. +// +// !anchor track [component flags] [ignore-selected] [...] +// Add component tracking to existing anchor relationships, recording the +// current relative state as the new stored offset. Respects any pre-locks. +// +// !anchor untrack [component flags] [ignore-selected] [...] +// Remove component tracking. Does not affect locked state. +// +// !anchor retrack [component flags] [ignore-selected] [...] +// Replace the tracked component set entirely. No flags = default set. +// +// !anchor center [ignore-selected] [...] +// Snap child(ren) to anchor center (offset 0,0, rotation 0, scale 1:1). +// +// !anchor update [ignore-selected] [...] +// Force an immediate position/transform sync for the child(ren). +// +// !anchor info [ignore-selected] [...] +// Whisper current anchor state for the given token(s) to the caller. +// +// !anchor config +// Show current configuration values. +// +// !anchor config +// Set a config value at runtime (persists in state across restarts). +// Keys and their defaults: +// poll-interval — polling interval (default: 1000, min: 100) +// default-anchor-layer — gmlayer | objects | map (default: gmlayer) +// default-anchor-size — token size in pixels (default: 35) +// default-anchor-name — token name (default: Anchor) +// default-anchor-aura-color <#hex> — GM aura colour (default: #00ffff) +// default-anchor-aura-visible — show GM aura (default: true) +// allow-player-use — let players use lock/unlock/info/update/center (default: false) +// +// !anchor config reset +// Reset all runtime config overrides; reverts to globalconfig / DEFAULTS. +// +// !anchor --help +// Whisper this help text to the caller. +// +// Configuration priority (lowest → highest): +// Hardcoded DEFAULTS → useroptions (API Scripts page) → !anchor config (runtime state) +// ============================================================================= + +/* global state, on, sendChat, getObj, createObj, Campaign, playerIsGM, toFront, toBack, log, _, setInterval, setTimeout, MatrixMath */ + +var Anchor = Anchor || (() => { + 'use strict'; + + // ------------------------------------------------------------------------- + // Constants + // ------------------------------------------------------------------------- + + const SCRIPT_NAME = 'Anchor'; + const SCRIPT_VERSION = '2.2.1'; + const CMD_TOKEN = '!anchor'; + + const DEFAULTS = { + // Polling + pollIntervalMs: 1000, + // Auto-created anchor token appearance + defaultAnchorLayer: 'gmlayer', + defaultAnchorSize: 35, + defaultAnchorName: 'Anchor', + defaultAnchorAuraColor: '#00ffff', + defaultAnchorAuraVisible: true, + // Roll20's built-in default character token image — a relative path that + // is available to all users without any library upload required. + // Users can override this via !anchor config default-anchor-imgsrc or + // the API Scripts page useroptions field. + defaultAnchorImgsrc: 'https://s3.amazonaws.com/files.d20.io/images/58010319/4S4xdTsHxQGVttCDSPsmnw/thumb.png?1531339299', + // Permissions + allowPlayerUse: false, + }; + + // All anchored transform components. + // 'zorder' is special — it has no single graphic attribute but is managed + // via toFront/toBack and stored as front/back ordered lists on the anchor. + const COMPONENTS = { + left: 'left', + top: 'top', + rotation: 'rotation', + width: 'width', + height: 'height', + layer: 'layer', + flipv: 'flipv', + fliph: 'fliph', + zorder: null, // managed separately, not a graphic attribute + }; + + // Components included in the default set (everything except zorder). + const DEFAULT_COMPONENTS = ['left','top','rotation','width','height','flipv','fliph']; + + // All components including layer and zorder. + const ALL_COMPONENTS = [...DEFAULT_COMPONENTS, 'layer', 'zorder']; + + // Long-form command flags that expand to component sets. + // Short aliases (e.g. -x, -rot) map to the same expansions via ALIAS_MAP below. + const FLAG_EXPANSIONS = { + // Explicit component flags (long form: anchor-, short form: -) + 'anchor-all': ALL_COMPONENTS, + 'anchor': DEFAULT_COMPONENTS, + 'anchor-position': ['left', 'top'], + 'anchor-x': ['left'], + 'anchor-y': ['top'], + 'anchor-rotation': ['rotation'], + 'anchor-scale': ['width', 'height'], + 'anchor-width': ['width'], + 'anchor-height': ['height'], + 'anchor-layer': ['layer'], + 'anchor-flip': ['flipv', 'fliph'], + 'anchor-flipv': ['flipv'], + 'anchor-fliph': ['fliph'], + 'anchor-z': ['zorder'], + }; + + // Short alias → canonical long-form flag + const ALIAS_MAP = { + '-all': 'anchor-all', + '-pos': 'anchor-position', + '-x': 'anchor-x', + '-y': 'anchor-y', + '-rot': 'anchor-rotation', + '-scale': 'anchor-scale', + '-w': 'anchor-width', + '-h': 'anchor-height', + '-layer': 'anchor-layer', + '-flip': 'anchor-flip', + '-flipv': 'anchor-flipv', + '-fliph': 'anchor-fliph', + '-z': 'anchor-z', + '--persist': 'persist', + '--ignore-selected': 'ignore-selected', + }; + + const ALL_COMMAND_FLAGS = [ + ...Object.keys(FLAG_EXPANSIONS), + ...Object.keys(ALIAS_MAP), + 'remove', 'lock', 'unlock', 'center', 'update', 'info', + 'track', 'untrack', 'retrack', + 'chain', 'unchain', + 'ignore-selected', 'persist', '--new', '--up', '--down', + 'config', + '--help', + ]; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + const cfg = () => state[SCRIPT_NAME].config; + + /** + * Whisper a message back to the sender. Optionally include a tag label. + * @param {object} msg - Roll20 chat message object + * @param {string} tagOrText - If `text` omitted, used as the message body. + * Otherwise used as a bracketed tag prefix. + * @param {string} [text] + */ + const reply = (msg, tagOrText, text) => { + const tag = text !== undefined ? ` [${tagOrText}]` : ''; + const body = text !== undefined ? text : tagOrText; + const recipient = msg.who.split(' ')[0]; + sendChat(`${SCRIPT_NAME}${tag}`, `/w ${recipient} ${body}`); + }; + + const isValidGraphic = (objId) => !!getObj('graphic', objId); + + /** Degrees to radians. */ + const toRad = (deg) => deg * Math.PI / 180; + + /** Normalise an angle to [0, 360). */ + const normDeg = (deg) => ((deg % 360) + 360) % 360; + + /** + * Build a 3×3 transform matrix for a graphic's current position + rotation. + */ + const buildTransform = (left, top, rotationDeg, scaleX, scaleY) => { + let m = MatrixMath.identity(3); + m = MatrixMath.multiply(m, MatrixMath.translate([left, top])); + m = MatrixMath.multiply(m, MatrixMath.rotate(toRad(rotationDeg))); + m = MatrixMath.multiply(m, MatrixMath.scale([scaleX, scaleY])); + return m; + }; + + // ------------------------------------------------------------------------- + // State helpers + // ------------------------------------------------------------------------- + + /** + * Derive the set of currently tracked components from an anchorInfo object. + * Returns an object like { left: true, rotation: true, ... }. + */ + const getTrackedComponentsFromInfo = (info) => { + const tracked = {}; + if ('left' in info) tracked.left = true; + if ('top' in info) tracked.top = true; + if ('rotation' in info) tracked.rotation = true; + if ('widthRatio' in info) tracked.width = true; + if ('heightRatio' in info) tracked.height = true; + if ('layerOffset' in info) tracked.layer = true; + if ('flipv' in info) tracked.flipv = true; + if ('fliph' in info) tracked.fliph = true; + if ('zorder' in info) tracked.zorder = true; + return tracked; + }; + + /** + * Read an object's current graphic attrs into a plain snapshot object. + */ + const snapshotObj = (obj) => ({ + left: obj.get('left'), + top: obj.get('top'), + rotation: obj.get('rotation'), + width: obj.get('width'), + height: obj.get('height'), + layer: obj.get('layer'), + flipv: obj.get('flipv'), + fliph: obj.get('fliph'), + }); + + /** + * Ensure an object has an entry in objectStates and return it. + * If the entry doesn't exist, create it from the live graphic. + */ + const ensureObjState = (objId) => { + if (!objId) return undefined; + const s = state[SCRIPT_NAME]; + if (!s.objectStates[objId]) { + const obj = getObj('graphic', objId); + if (!obj) return undefined; + s.objectStates[objId] = snapshotObj(obj); + } + return s.objectStates[objId]; + }; + + /** + * Overwrite an object's state snapshot with current live values. + */ + const refreshObjState = (obj) => { + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + if (s.objectStates[id]) { + Object.assign(s.objectStates[id], snapshotObj(obj)); + } + }; + + // ------------------------------------------------------------------------- + // Anchor relationship management + // ------------------------------------------------------------------------- + + /** + * Compute a child's relative transform values at the time anchoring is + * established. Returns an anchorInfo object containing only the components + * that are being anchored. + * + * Position (left/top) is stored relative to the anchor's local frame so + * that it survives the anchor rotating. + */ + const computeAnchorInfo = (anchorId, childId, components) => { + const anchor = getObj('graphic', anchorId); + const child = getObj('graphic', childId); + if (!anchor || !child) return undefined; + + const aLeft = anchor.get('left'); + const aTop = anchor.get('top'); + const aRot = anchor.get('rotation'); + const cLeft = child.get('left'); + const cTop = child.get('top'); + const cRot = child.get('rotation'); + const aW = anchor.get('width'); + const aH = anchor.get('height'); + + const info = { id: childId, anchor_id: anchorId }; + + if (components.left || components.top) { + // Express child position in anchor-local frame (undo rotation + scale + flip) + const aFlipH = components.fliph && anchor.get('fliph'); + const aFlipV = components.flipv && anchor.get('flipv'); + const sx = aW > 0 ? 1 / (aW * (aFlipH ? -1 : 1)) : 1; + const sy = aH > 0 ? 1 / (aH * (aFlipV ? -1 : 1)) : 1; + const relTransform = MatrixMath.multiply( + MatrixMath.scale([sx, sy]), + MatrixMath.multiply( + MatrixMath.rotate(toRad(-aRot)), + MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + ) + ); + if (components.left) info.left = relTransform[2][0]; + if (components.top) info.top = relTransform[2][1]; + } + + if (components.rotation) info.rotation = normDeg(cRot - aRot); + if (components.width) info.widthRatio = aW > 0 ? child.get('width') / aW : 1; + if (components.height) info.heightRatio = aH > 0 ? child.get('height') / aH : 1; + if (components.layer) info.layerOffset = 0; // always same layer as anchor + if (components.flipv) info.flipv = child.get('flipv') !== anchor.get('flipv'); // true = flipped relative to parent + if (components.fliph) info.fliph = child.get('fliph') !== anchor.get('fliph'); + + // Z-order is not stored in anchorInfo per-child; instead the anchor + // maintains front/back ordered lists. We flag it here so setAnchor + // knows to register the child into those lists. + if (components.zorder) info.zorder = true; + + return info; + }; + + /** + * Remove stale entries for a child from anchorChildrenByAnchorId and clean + * up the anchor's objectState if it's no longer needed. + */ + const detachChildFromAnchor = (childId, anchorId) => { + const s = state[SCRIPT_NAME]; + if (!anchorId || !(anchorId in s.anchorChildrenByAnchorId)) return; + + delete s.anchorChildrenByAnchorId[anchorId][childId]; + + // Remove child from z-order lists if present + if (s.anchorZOrder && s.anchorZOrder[anchorId]) { + s.anchorZOrder[anchorId].front = s.anchorZOrder[anchorId].front.filter(id => id !== childId); + s.anchorZOrder[anchorId].back = s.anchorZOrder[anchorId].back.filter(id => id !== childId); + if (s.anchorZOrder[anchorId].front.length === 0 && s.anchorZOrder[anchorId].back.length === 0) { + delete s.anchorZOrder[anchorId]; + } + } + + if (Object.keys(s.anchorChildrenByAnchorId[anchorId]).length === 0) { + delete s.anchorChildrenByAnchorId[anchorId]; + // Clean up anchor's own objectState if it's not also a child + if (!(anchorId in s.anchorInfoByChildId)) { + delete s.objectStates[anchorId]; + } + // Auto-destroy auto-created anchor tokens that have lost all children + maybeDestroyAutoAnchor(anchorId); + } + }; + + /** + * If anchorId is an auto-created anchor token that now has no children, + * remove it from the map and from state. + * Called automatically; safe to call even if anchorId is not auto-created. + */ + const maybeDestroyAutoAnchor = (anchorId) => { + const s = state[SCRIPT_NAME]; + if (!(anchorId in s.autoCreatedAnchors)) return; + // Don't destroy if there's a pending setup waiting for add:graphic + if (anchorId in s.pendingAnchors) return; + if (anchorId in s.anchorChildrenByAnchorId) return; + delete s.autoCreatedAnchors[anchorId]; + delete s.objectStates[anchorId]; + const obj = getObj('graphic', anchorId); + if (obj) obj.remove(); + }; + + // ------------------------------------------------------------------------- + // Lock helpers + // ------------------------------------------------------------------------- + + /** + * Return the Set of locked components for a child, or an empty Set if none. + * Does NOT create an entry — use getOrCreateLockedSet for mutation. + */ + const getLockedComponents = (childId) => { + const entry = state[SCRIPT_NAME].lockedObjects[childId]; + if (!entry) return new Set(); + // Migrate old flat-value format (childId: childId) to Set on first access + if (!(entry instanceof Set)) { + const migrated = new Set(Object.keys(entry)); + state[SCRIPT_NAME].lockedObjects[childId] = migrated; + return migrated; + } + return entry; + }; + + const getOrCreateLockedSet = (childId) => { + const s = state[SCRIPT_NAME]; + if (!s.lockedObjects[childId] || !(s.lockedObjects[childId] instanceof Set)) { + s.lockedObjects[childId] = new Set(); + } + return s.lockedObjects[childId]; + }; + + /** + * Lock the given components for a child. + * If components is empty/undefined, locks all components (tracked + all possible). + * Components that aren't currently tracked are stored as "pre-locked". + */ + const lockComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + const locked = getOrCreateLockedSet(childId); + const toAdd = (components && Object.keys(components).length > 0) + ? Object.keys(components) + : ALL_COMPONENTS; + toAdd.forEach(c => locked.add(c)); + setPlacementLockIfNeeded(childId); + }; + + /** + * Set lockMovement if both left+top are tracked AND locked and we haven't already. + */ + const setPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + const info = s.anchorInfoByChildId[childId]; + if (!info || !('left' in info) || !('top' in info)) return; + const locked = getLockedComponents(childId); + if (!locked.has('left') || !locked.has('top')) return; + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; + } + }; + + /** + * Unlock the given components for a child. + * If components is empty/undefined, unlocks everything (clears the entry). + */ + const unlockComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + if (!components || Object.keys(components).length === 0) { + delete s.lockedObjects[childId]; + } else { + const locked = getLockedComponents(childId); + Object.keys(components).forEach(c => locked.delete(c)); + if (locked.size === 0) delete s.lockedObjects[childId]; + } + clearPlacementLockIfNeeded(childId); + }; + + /** + * Return true if the given component is locked for this child. + */ + const isComponentLocked = (childId, component) => + getLockedComponents(childId).has(component); + + /** + * Return true if ANY component is locked for this child. + */ + const isAnyComponentLocked = (childId) => + getLockedComponents(childId).size > 0; + + // ------------------------------------------------------------------------- + // Tracking helpers (add/remove/replace tracked components) + // ------------------------------------------------------------------------- + + /** + * Add component tracking to an existing child relationship. + * Records the current live relative state for each new component. + * Preserves all existing tracked component offsets. + */ + const addTrackedComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + const existing = s.anchorInfoByChildId[childId]; + if (!existing) return; // not anchored + + // Compute fresh info for just the new components + const freshInfo = computeAnchorInfo(existing.anchor_id, childId, components); + if (!freshInfo) return; + + // Merge new component data into existing info + Object.keys(components).forEach(c => { + // Map component name to the actual key(s) stored in anchorInfo + switch(c) { + case 'left': if ('left' in freshInfo) existing.left = freshInfo.left; break; + case 'top': if ('top' in freshInfo) existing.top = freshInfo.top; break; + case 'rotation': if ('rotation' in freshInfo) existing.rotation = freshInfo.rotation; break; + case 'width': if ('widthRatio' in freshInfo) existing.widthRatio = freshInfo.widthRatio; break; + case 'height': if ('heightRatio' in freshInfo) existing.heightRatio = freshInfo.heightRatio; break; + case 'layer': if ('layerOffset' in freshInfo) existing.layerOffset = freshInfo.layerOffset; break; + case 'flipv': if ('flipv' in freshInfo) existing.flipv = freshInfo.flipv; break; + case 'fliph': if ('fliph' in freshInfo) existing.fliph = freshInfo.fliph; break; + case 'zorder': + existing.zorder = true; + registerChildZOrder(existing.anchor_id, childId); + break; + } + }); + setPlacementLockIfNeeded(childId); + }; + + /** + * Remove component tracking from an existing child relationship. + * Deletes the stored offset data for those components. + */ + const removeTrackedComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + const existing = s.anchorInfoByChildId[childId]; + if (!existing) return; + + Object.keys(components).forEach(c => { + switch(c) { + case 'left': delete existing.left; break; + case 'top': delete existing.top; break; + case 'rotation': delete existing.rotation; break; + case 'width': delete existing.widthRatio; break; + case 'height': delete existing.heightRatio; break; + case 'layer': delete existing.layerOffset; break; + case 'flipv': delete existing.flipv; break; + case 'fliph': delete existing.fliph; break; + case 'zorder': + delete existing.zorder; + // Remove from z-order lists + if (s.anchorZOrder && s.anchorZOrder[existing.anchor_id]) { + const lists = s.anchorZOrder[existing.anchor_id]; + lists.front = lists.front.filter(id => id !== childId); + lists.back = lists.back.filter(id => id !== childId); + } + break; + } + }); + clearPlacementLockIfNeeded(childId); + }; + + /** + * Clear lockMovement if we set it and conditions no longer hold + * (left+top must both be tracked AND locked). + */ + const clearPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + if (!s.placementLockedByAnchor || !s.placementLockedByAnchor[childId]) return; + const info = s.anchorInfoByChildId[childId]; + const locked = getLockedComponents(childId); + const bothTrackedAndLocked = info && 'left' in info && 'top' in info && locked.has('left') && locked.has('top'); + if (!bothTrackedAndLocked) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } + }; + + /** + * Set or remove the anchor relationship for a single child. + * Pass anchorId = undefined to remove. + * Pass components = undefined when removing (ignored in that case). + */ + const setAnchor = (childId, anchorId, components) => { + if (!childId) return; + const s = state[SCRIPT_NAME]; + + // Detach from any previous anchor + const existingInfo = s.anchorInfoByChildId[childId]; + if (existingInfo) { + detachChildFromAnchor(childId, existingInfo.anchor_id); + } + + if (!anchorId || childId === anchorId) { + // Remove relationship entirely + delete s.anchorInfoByChildId[childId]; + if (!(childId in s.anchorChildrenByAnchorId)) { + delete s.objectStates[childId]; + } + delete s.lockedObjects[childId]; + clearPlacementLockIfNeeded(childId); + return; + } + + const info = computeAnchorInfo(anchorId, childId, components); + if (!info) return; + + s.anchorInfoByChildId[childId] = info; + s.anchorChildrenByAnchorId[anchorId] = s.anchorChildrenByAnchorId[anchorId] || {}; + s.anchorChildrenByAnchorId[anchorId][childId] = childId; + + // If z-order tracking is requested, register child into the anchor's + // front/back lists based on current live z-position. + if (components.zorder) { + registerChildZOrder(anchorId, childId); + } + }; + + /** + * Read the live _zorder for the anchor's page and insert childId into + * the anchor's front[] or back[] list at the correct position relative + * to the anchor and any already-registered z-ordered children. + */ + const registerChildZOrder = (anchorId, childId) => { + const s = state[SCRIPT_NAME]; + const anchor = getObj('graphic', anchorId); + const child = getObj('graphic', childId); + if (!anchor || !child) return; + + const pageId = anchor.get('_pageid'); + const page = getObj('page', pageId); + if (!page) return; + + const zorder = page.get('_zorder').split(','); + const aIdx = zorder.indexOf(anchorId); + const cIdx = zorder.indexOf(childId); + if (aIdx === -1 || cIdx === -1) return; + + // Ensure the anchor has z-order lists + s.anchorZOrder = s.anchorZOrder || {}; + s.anchorZOrder[anchorId] = s.anchorZOrder[anchorId] || { front: [], back: [] }; + const lists = s.anchorZOrder[anchorId]; + + // Remove from both lists in case of re-registration + lists.front = lists.front.filter(id => id !== childId); + lists.back = lists.back.filter(id => id !== childId); + + if (cIdx > aIdx) { + // Child is in front of anchor — insert into front[] maintaining order + // front[] is ordered front-to-back (highest index first) + let inserted = false; + for (let i = 0; i < lists.front.length; i++) { + const existingIdx = zorder.indexOf(lists.front[i]); + if (cIdx > existingIdx) { + lists.front.splice(i, 0, childId); + inserted = true; + break; + } + } + if (!inserted) lists.front.push(childId); + } else { + // Child is behind anchor — insert into back[] maintaining order + // back[] is ordered front-to-back (highest index first) + let inserted = false; + for (let i = 0; i < lists.back.length; i++) { + const existingIdx = zorder.indexOf(lists.back[i]); + if (cIdx > existingIdx) { + lists.back.splice(i, 0, childId); + inserted = true; + break; + } + } + if (!inserted) lists.back.push(childId); + } + }; + + /** + * Establish anchor for multiple children at once, also ensuring objectStates + * are initialised for anchor and all children. + */ + const setAnchors = (anchorId, childIds, components) => { + ensureObjState(anchorId); + childIds.forEach(id => ensureObjState(id)); + childIds.forEach(id => setAnchor(id, anchorId, components)); + }; + + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // Auto-created anchor token + // ------------------------------------------------------------------------- + + /** + * Create a new invisible anchor graphic at the position of referenceObj + * (or the centre of the current player page if referenceObj is undefined). + * + * Made invisible via tint_color:"transparent" and isdrawing:true so it + * stays out of the turn tracker and targeting systems. A GM-only cyan + * aura (radius 0, square) gives the GM a visible click target. + * + * No imgsrc is set — Roll20 renders a plain square hidden by the tint. + * Portable: works for any user without requiring an image upload. + * + * Returns the new graphic object, or undefined on failure. + */ + const createAnchorToken = (referenceObj) => { + const c = cfg(); + const pageId = referenceObj + ? referenceObj.get('_pageid') + : Campaign().get('playerpageid'); + const left = referenceObj ? referenceObj.get('left') : 0; + const top = referenceObj ? referenceObj.get('top') : 0; + + // Create with only the properties Roll20 reliably accepts at creation time. + // Aura and visibility properties are applied via .set() immediately after + // to avoid silent creation failures from unrecognised attributes. + const token = createObj('graphic', { + pageid: pageId, + left: left, + top: top, + width: c.defaultAnchorSize, + height: c.defaultAnchorSize, + layer: c.defaultAnchorLayer, + name: c.defaultAnchorName, + // A transparent 1×1 PNG from the script author's Roll20 library. + // Roll20 requires a valid imgsrc to render the token at all — + // without it the token exists in the data model but is invisible + // and unselectable. The tint_color below then hides the image. + // Per Roll20 docs, images from the author's library work for all users. + imgsrc: c.defaultAnchorImgsrc, + }); + + if (!token) { + log(`${SCRIPT_NAME}: createObj failed — could not create anchor token on page ${pageId}`); + return undefined; + } + + // Apply additional properties after creation. + // NOTE: isdrawing intentionally omitted for now — it prevents token + // selection with the token tool and hides it from Ctrl+A on token layer. + token.set({ + tint_color: 'transparent', + // isdrawing intentionally omitted — marks token as drawing which + // prevents selection with the token tool and hides it from Ctrl+A + showname: false, + controlledby: '', + aura1_radius: c.defaultAnchorAuraVisible ? '0' : '', + aura1_color: c.defaultAnchorAuraColor, + aura1_square: true, + showplayers_aura1: false, + playersedit_aura1: false, + }); + + return token; + }; + + // Transform application + // ------------------------------------------------------------------------- + + /** + * Apply the anchor's current transform to a single child. + * If onlyComponents is provided, only those components are applied. + * Otherwise, all tracked but UNLOCKED components are applied. + */ + const applyAnchorToChild = (childId, onlyComponents, visited) => { + if (!visited) visited = new Set(); + if (visited.has(childId)) return; + visited.add(childId); + + const s = state[SCRIPT_NAME]; + const info = s.anchorInfoByChildId[childId]; + if (!info) { setAnchor(childId, undefined); return; } + + const child = getObj('graphic', childId); + const anchor = getObj('graphic', info.anchor_id); + + if (!anchor) { setAnchor(childId, undefined); return; } + if (!child) { setAnchor(childId, undefined); return; } + + // Determine which components to apply: + // If onlyComponents provided, use that. Otherwise apply all tracked + // components that are not locked. + const locked = getLockedComponents(childId); + const shouldApply = (component) => { + if (onlyComponents) return component in onlyComponents; + return !locked.has(component); + }; + + const updates = {}; + + if (('left' in info || 'top' in info) && (shouldApply('left') || shouldApply('top'))) { + // Include flip as negative scale in the transform (for offset mirroring only) + const aFlipH = ('fliph' in info) && anchor.get('fliph'); + const aFlipV = ('flipv' in info) && anchor.get('flipv'); + const sx = anchor.get('width') * (aFlipH ? -1 : 1); + const sy = anchor.get('height') * (aFlipV ? -1 : 1); + + const anchorTransform = buildTransform( + anchor.get('left'), anchor.get('top'), anchor.get('rotation'), sx, sy + ); + + const localLeft = ('left' in info) ? info.left : 0; + const localTop = ('top' in info) ? info.top : 0; + + const childWorld = MatrixMath.multiply( + anchorTransform, + MatrixMath.translate([localLeft, localTop]) + ); + if ('left' in info && shouldApply('left')) updates.left = childWorld[2][0]; + if ('top' in info && shouldApply('top')) updates.top = childWorld[2][1]; + } + + if ('rotation' in info && shouldApply('rotation')) { + updates.rotation = normDeg(anchor.get('rotation') + info.rotation); + } + + if ('widthRatio' in info && shouldApply('width')) { + updates.width = anchor.get('width') * info.widthRatio; + } + + if ('heightRatio' in info && shouldApply('height')) { + updates.height = anchor.get('height') * info.heightRatio; + } + + if ('layerOffset' in info && shouldApply('layer')) { + // Only propagate layer when it has actually changed on the anchor + // (not on every positional update). This prevents auto-created anchors + // on the GM layer from pulling children to that layer. + const prevState = s.objectStates[info.anchor_id]; + const anchorLayer = anchor.get('layer'); + if (prevState && prevState.layer !== anchorLayer) { + updates.layer = anchorLayer; + } + } + + if ('flipv' in info && shouldApply('flipv')) { + updates.flipv = info.flipv ? !anchor.get('flipv') : anchor.get('flipv'); + } + + if ('fliph' in info && shouldApply('fliph')) { + updates.fliph = info.fliph ? !anchor.get('fliph') : anchor.get('fliph'); + } + + child.set(updates); + + // Update snapshot to reflect the new position + if (s.objectStates[childId]) { + Object.assign(s.objectStates[childId], updates); + } + + // Propagate to this child's own children (if it is also an anchor) + if (childId in s.anchorChildrenByAnchorId) { + Object.keys(s.anchorChildrenByAnchorId[childId]) + .forEach(grandchildId => applyAnchorToChild(grandchildId, undefined, visited)); + } + }; + + /** + * Restack all z-order-tracked children relative to the anchor. + * Call this after moving the anchor in z-order (e.g. via EasyReZorder). + * + * Stack order built by calling toFront back-to-front: + * last of back[], ..., first of back[], anchor, last of front[], ..., first of front[] + * Result from front to back: front[0], front[1], ..., anchor, back[0], back[1], ... + */ + const applyZOrderToChildren = (anchorId) => { + const s = state[SCRIPT_NAME]; + if (!s.anchorZOrder || !s.anchorZOrder[anchorId]) return; + + const { front, back } = s.anchorZOrder[anchorId]; + const anchor = getObj('graphic', anchorId); + if (!anchor) return; + + // Call toFront in back-to-front build order: + // deepest back children first, then shallower back, then anchor, then front + const buildOrder = [...back].reverse() + .concat([anchorId]) + .concat([...front].reverse()); + + buildOrder.forEach(id => { + const obj = id === anchorId ? anchor : getObj('graphic', id); + if (obj) toFront(obj); + }); + }; + + /** + * Called when a graphic changes. Handles two cases: + * + * 1. The changed object is an ANCHOR — propagate its new transform to + * all unlocked children. Locked children are skipped here; pollUpdates + * enforces their position every tick instead. + * + * 2. The changed object is an UNLOCKED CHILD — the GM has manually + * repositioned it, so re-record its new offset relative to its anchor. + * Locked children are intentionally ignored here; pollUpdates will + * undo any manual move on the next tick. + * + * childImmediateUpdate: when true, offset re-recording happens synchronously + * (used by the public API after programmatic moves). When false it is deferred + * via setTimeout so Roll20's own position-settling can complete first. + */ + const onObjectChanged = (obj, _prev, childImmediateUpdate = false) => { + if (!obj) return; + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + + // Case 1: changed object is a child — re-record offsets for unlocked components. + // If ALL tracked components are locked, skip entirely (poll handles enforcement). + if (id in s.anchorInfoByChildId) { + const info = s.anchorInfoByChildId[id]; + const locked = getLockedComponents(id); + // Determine which tracked components are not locked + const trackedComponents = getTrackedComponentsFromInfo(info); + const unlockedTracked = Object.fromEntries( + Object.keys(trackedComponents).filter(c => !locked.has(c)).map(c => [c, true]) + ); + if (Object.keys(unlockedTracked).length > 0) { + const recordOffset = () => { + const newInfo = computeAnchorInfo(info.anchor_id, id, unlockedTracked); + if (newInfo) { + // Merge only unlocked component data back in + Object.assign(s.anchorInfoByChildId[id], newInfo); + } + }; + childImmediateUpdate ? recordOffset() : setTimeout(recordOffset, 0); + } + } + + // Case 2: changed object is an anchor — push to children for their unlocked components + if (id in s.anchorChildrenByAnchorId) { + const visited = new Set([id]); + Object.keys(s.anchorChildrenByAnchorId[id]) + .forEach(childId => applyAnchorToChild(childId, undefined, visited)); + } + + refreshObjState(obj); + }; + + const onObjectChangedImmediate = (obj) => onObjectChanged(obj, undefined, true); + + // ------------------------------------------------------------------------- + // Polling + // ------------------------------------------------------------------------- + + /** + * Poll for position changes that the change:graphic events may have missed + * (e.g. bulk moves, map imports). Also enforces locked-child positions. + * + * Lock semantics: + * LOCKED — child is frozen relative to anchor; any manual move is undone + * every poll tick by re-applying the stored anchor transform. + * UNLOCKED — child follows anchor on change events normally, and if the + * child is manually moved the new relative offset is recorded. + */ + const pollUpdates = () => { + const s = state[SCRIPT_NAME]; + + // Process any pending anchor setups (fallback for add:graphic not firing) + Object.keys(s.pendingAnchors).forEach(anchorId => { + if (!isValidGraphic(anchorId)) return; + const pending = s.pendingAnchors[anchorId]; + delete s.pendingAnchors[anchorId]; + setAnchors(anchorId, pending.childIds, pending.components); + }); + + // Enforce locked children: for each child with any locked components, + // re-apply just the locked tracked components to undo any manual moves. + Object.keys(s.lockedObjects).forEach(id => { + if (!(id in s.anchorInfoByChildId)) return; + const info = s.anchorInfoByChildId[id]; + const locked = getLockedComponents(id); + const tracked = getTrackedComponentsFromInfo(info); + // Only enforce components that are both tracked AND locked + const lockedTracked = Object.fromEntries( + Object.keys(tracked).filter(c => locked.has(c)).map(c => [c, true]) + ); + if (Object.keys(lockedTracked).length > 0) { + applyAnchorToChild(id, lockedTracked); + } + }); + + // Detect external changes by comparing live values to snapshot + Object.entries(s.objectStates).forEach(([id, snap]) => { + const obj = getObj('graphic', id); + if (!obj) { + delete s.objectStates[id]; + return; + } + const live = snapshotObj(obj); + const changed = Object.keys(COMPONENTS).some(k => live[k] !== snap[k]); + if (changed) onObjectChanged(obj, snap); + }); + }; + + // ------------------------------------------------------------------------- + // Cleanup + // ------------------------------------------------------------------------- + + /** + * Remove all state entries that reference non-existent graphics. + * Called on ready and can be triggered manually. + */ + const cleanInvalidEntries = () => { + const s = state[SCRIPT_NAME]; + + // Clean anchorChildrenByAnchorId: remove bad anchor IDs and bad children + Object.keys(s.anchorChildrenByAnchorId).forEach(anchorId => { + if (!isValidGraphic(anchorId)) { + delete s.anchorChildrenByAnchorId[anchorId]; + return; + } + Object.keys(s.anchorChildrenByAnchorId[anchorId]).forEach(childId => { + if (!isValidGraphic(childId)) + delete s.anchorChildrenByAnchorId[anchorId][childId]; + }); + if (Object.keys(s.anchorChildrenByAnchorId[anchorId]).length === 0) + delete s.anchorChildrenByAnchorId[anchorId]; + }); + + // Clean anchorInfoByChildId + Object.keys(s.anchorInfoByChildId).forEach(childId => { + const info = s.anchorInfoByChildId[childId]; + if (!isValidGraphic(childId) || + !info || + !isValidGraphic(info.anchor_id) || + !(info.anchor_id in s.anchorChildrenByAnchorId) || + !(childId in s.anchorChildrenByAnchorId[info.anchor_id]) + ) { + delete s.anchorInfoByChildId[childId]; + } + }); + + // Clean lockedObjects — remove entries for invalid graphics + // and migrate old flat-value entries to Sets + Object.keys(s.lockedObjects).forEach(id => { + if (!isValidGraphic(id)) { + delete s.lockedObjects[id]; + return; + } + // Migrate old format (value was just the id string) + if (!(s.lockedObjects[id] instanceof Set)) { + s.lockedObjects[id] = new Set( + typeof s.lockedObjects[id] === 'object' + ? Object.keys(s.lockedObjects[id]) + : [] + ); + } + // Remove empty sets + if (s.lockedObjects[id].size === 0) delete s.lockedObjects[id]; + }); + + // Clean objectStates: keep only objects that are active anchors or children + Object.keys(s.objectStates).forEach(id => { + if (!isValidGraphic(id) || + (!(id in s.anchorInfoByChildId) && !(id in s.anchorChildrenByAnchorId)) + ) { + delete s.objectStates[id]; + } + }); + + // Clean autoCreatedAnchors + Object.keys(s.autoCreatedAnchors).forEach(id => { + if (!isValidGraphic(id)) delete s.autoCreatedAnchors[id]; + }); + + // Clean pendingAnchors: remove entries whose anchor token no longer exists + // and filter out any child IDs that are no longer valid + Object.keys(s.pendingAnchors).forEach(anchorId => { + if (!isValidGraphic(anchorId)) { + delete s.pendingAnchors[anchorId]; + return; + } + s.pendingAnchors[anchorId].childIds = + s.pendingAnchors[anchorId].childIds.filter(isValidGraphic); + if (s.pendingAnchors[anchorId].childIds.length === 0) { + delete s.pendingAnchors[anchorId]; + } + }); + + // Clean anchorZOrder: remove entries for invalid anchors or invalid children + Object.keys(s.anchorZOrder).forEach(anchorId => { + if (!isValidGraphic(anchorId)) { + delete s.anchorZOrder[anchorId]; + return; + } + const lists = s.anchorZOrder[anchorId]; + lists.front = lists.front.filter(isValidGraphic); + lists.back = lists.back.filter(isValidGraphic); + if (lists.front.length === 0 && lists.back.length === 0) { + delete s.anchorZOrder[anchorId]; + } + }); + }; + + // ------------------------------------------------------------------------- + // Event: destroy + // ------------------------------------------------------------------------- + + const onAddGraphic = (obj) => { + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + const pending = s.pendingAnchors[id]; + if (!pending) return; + + delete s.pendingAnchors[id]; + setAnchors(id, pending.childIds, pending.components); + }; + + const onDestroyObject = (obj) => { + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + + // If destroyed object was a child, detach it (may trigger auto-destroy + // of its anchor if that anchor is auto-created and now childless) + if (id in s.anchorInfoByChildId) { + const anchorId = s.anchorInfoByChildId[id].anchor_id; + detachChildFromAnchor(id, anchorId); + delete s.anchorInfoByChildId[id]; + } + + // If the destroyed object was itself an auto-created anchor, clean up + if (id in s.autoCreatedAnchors) { + delete s.autoCreatedAnchors[id]; + } + + // If destroyed object was an anchor, release all its children + if (id in s.anchorChildrenByAnchorId) { + Object.keys(s.anchorChildrenByAnchorId[id]) + .forEach(childId => { + delete s.anchorInfoByChildId[childId]; + if (!(childId in s.anchorChildrenByAnchorId)) + delete s.objectStates[childId]; + }); + delete s.anchorChildrenByAnchorId[id]; + } + + delete s.objectStates[id]; + }; + + // ------------------------------------------------------------------------- + // Chat command helpers + // ------------------------------------------------------------------------- + + const HELP_TEXT = [ + `${SCRIPT_NAME} v${SCRIPT_VERSION}`, + '', + `${CMD_TOKEN} [anchor_id] [flags] [--ignore-selected] [--new] [--persist] [child_id...]`, + 'Anchor selected tokens. First selected = parent, rest = children.', + '--new: force auto-create invisible anchor (all selected = children).', + '--persist: keep auto-created anchor even when childless.', + 'Long form: anchor-all, anchor-position, anchor-x, anchor-y,', + 'anchor-rotation, anchor-scale, anchor-width, anchor-height, anchor-layer,', + 'anchor-flip, anchor-flipv, anchor-fliph, anchor-z', + 'Short aliases: -all, -pos, -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z', + 'Default (no flags): position+rotation+scale+flip. -all adds layer+z-order.', + '', + `${CMD_TOKEN} remove [--up] [--down] [--ignore-selected] [child_id...]`, + 'Remove anchor relationships. Parent selected = unanchor children.', + '--up: remove only parent link. --down: remove only children.', + '', + `${CMD_TOKEN} lock [component flags] [--ignore-selected] [child_id...]`, + 'Lock components — re-enforced every poll tick. No flags = lock all.', + '', + `${CMD_TOKEN} unlock [component flags] [--ignore-selected] [child_id...]`, + 'Unlock components. No flags = unlock all.', + '', + `${CMD_TOKEN} track [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Add tracking to existing relationship. --down: apply to children.', + '', + `${CMD_TOKEN} untrack [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Remove tracking. --down: apply to children.', + '', + `${CMD_TOKEN} retrack [component flags] [--ignore-selected] [child_id...]`, + 'Replace tracked set entirely. No flags = default set.', + '', + `${CMD_TOKEN} center [--ignore-selected] [child_id...]`, + 'Snap child(ren) to anchor centre (0 offset, 0 rotation, 1:1 scale).', + '', + `${CMD_TOKEN} chain [component flags] [--ignore-selected] [child_id...]`, + 'Mutually anchor tokens in a ring (A\u2192B, B\u2192C, C\u2192A). Move any one, all follow.', + '', + `${CMD_TOKEN} unchain [--ignore-selected] [child_id...]`, + 'Dissolve a chain ring. Select any one token in the ring.', + '', + `${CMD_TOKEN} update [ignore-selected] [child_id...]`, + 'Force immediate transform sync.', + '', + `${CMD_TOKEN} info [ignore-selected] [child_id...]`, + 'Show anchor state for token(s).', + '', + `${CMD_TOKEN} config [key value] [reset]`, + 'View or change configuration. Keys: poll-interval, default-anchor-layer,', + 'default-anchor-size, default-anchor-name, default-anchor-aura-color,', + 'default-anchor-aura-visible, allow-player-use. Use reset to clear runtime overrides.', + ].join('
'); + + /** + * Parse a flat args array into { flags: Set, otherArgs: string[] }. + * Recognised command flags are pulled out; everything else stays in otherArgs. + */ + const parseArgs = (argsArray) => { + const flags = new Set(); + const otherArgs = []; + argsArray.forEach(arg => { + // Resolve short aliases to their canonical long-form flag + const canonical = ALIAS_MAP[arg] || arg; + if (ALL_COMMAND_FLAGS.includes(canonical)) flags.add(canonical); + else otherArgs.push(arg); + }); + return { flags, otherArgs }; + }; + + /** + * Determine which COMPONENTS are being anchored based on the set of flags. + * If no component flags are present, defaults to DEFAULT_COMPONENTS (all + * except zorder). Use anchor-all / -all to include zorder. + */ + const resolveComponents = (flags) => { + const anchorFlags = Object.keys(FLAG_EXPANSIONS).filter(f => flags.has(f)); + if (anchorFlags.length === 0) { + // No explicit component flags → use default set (no zorder) + return Object.fromEntries(DEFAULT_COMPONENTS.map(k => [k, true])); + } + const active = {}; + anchorFlags.forEach(f => FLAG_EXPANSIONS[f].forEach(c => (active[c] = true))); + return active; + }; + + /** + * Like resolveComponents but returns null (not the default set) when no + * component flags are present. Used by lock/unlock where "no flags" means + * "operate on all components" rather than "use default set". + */ + const resolveComponentsOrNone = (flags) => { + const anchorFlags = Object.keys(FLAG_EXPANSIONS).filter(f => flags.has(f)); + if (anchorFlags.length === 0) return null; + const active = {}; + anchorFlags.forEach(f => FLAG_EXPANSIONS[f].forEach(c => (active[c] = true))); + return active; + }; + + /** + * Resolve the list of child IDs from the message context. + * Combines selected tokens (unless ignore-selected) with explicitly listed IDs. + */ + const resolveChildIds = (msg, flags, otherArgs) => { + const fromSelected = flags.has('ignore-selected') + ? [] + : (msg.selected || []).map(s => s._id); + return [...fromSelected, ...otherArgs].filter(isValidGraphic); + }; + + // ------------------------------------------------------------------------- + // Info display + // ------------------------------------------------------------------------- + + const showInfo = (msg, id) => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isAnchor = id in s.anchorChildrenByAnchorId; + const info = isChild ? s.anchorInfoByChildId[id] : null; + + let out = `Token: ${id}
`; + out += `Anchor: ${info ? info.anchor_id : 'None'}
`; + + if (isChild && info) { + const locked = getLockedComponents(id); + const tracked = getTrackedComponentsFromInfo(info); + + // Build tracked component display with lock status and stored values + const trackedDisplay = []; + if ('left' in info || 'top' in info) { + const locL = locked.has('left'); + const locT = locked.has('top'); + const lockStr = (locL && locT) ? ' 🔒' : locL ? ' (x🔒)' : locT ? ' (y🔒)' : ''; + trackedDisplay.push(`pos (${(info.left||0).toFixed(1)}, ${(info.top||0).toFixed(1)})${lockStr}`); + } + if ('rotation' in info) { + trackedDisplay.push(`rot ${info.rotation.toFixed(1)}°${locked.has('rotation') ? ' 🔒' : ''}`); + } + if ('widthRatio' in info) { + trackedDisplay.push(`w×${info.widthRatio.toFixed(3)}${locked.has('width') ? ' 🔒' : ''}`); + } + if ('heightRatio' in info) { + trackedDisplay.push(`h×${info.heightRatio.toFixed(3)}${locked.has('height') ? ' 🔒' : ''}`); + } + if ('layerOffset' in info) { + trackedDisplay.push(`layer${locked.has('layer') ? ' 🔒' : ''}`); + } + if ('flipv' in info) { + trackedDisplay.push(`flipv(${info.flipv ? 'flipped' : 'same'})${locked.has('flipv') ? ' 🔒' : ''}`); + } + if ('fliph' in info) { + trackedDisplay.push(`fliph(${info.fliph ? 'flipped' : 'same'})${locked.has('fliph') ? ' 🔒' : ''}`); + } + if ('zorder' in info) { + trackedDisplay.push(`z-order${locked.has('zorder') ? ' 🔒' : ''}`); + } + out += `Tracked: ${trackedDisplay.join(', ') || 'none'}
`; + + // Pre-locked: locked but not tracked + const preLocked = [...locked].filter(c => !(c in tracked)); + if (preLocked.length > 0) { + out += `Pre-locked (untracked): ${preLocked.join(', ')}
`; + } + } + + if (isAnchor) { + const childIds = Object.keys(s.anchorChildrenByAnchorId[id]); + out += `Children: ${childIds.join(', ')}
`; + if (s.anchorZOrder && s.anchorZOrder[id]) { + const { front, back } = s.anchorZOrder[id]; + if (front.length > 0) out += `Z-front (front→back): ${front.join(', ')}
`; + if (back.length > 0) out += `Z-back (front→back): ${back.join(', ')}
`; + } + } + + const isAutoCreated = id in s.autoCreatedAnchors; + if (isAutoCreated) out += `Auto-created: yes (will auto-destroy when childless)
`; + + reply(msg, 'Info', out); + }; + + // ------------------------------------------------------------------------- + // Config commands + // ------------------------------------------------------------------------- + + const showConfig = (msg) => { + const c = cfg(); + const lines = [ + `poll-interval: ${c.pollIntervalMs}ms`, + `default-anchor-layer: ${c.defaultAnchorLayer}`, + `default-anchor-size: ${c.defaultAnchorSize}px`, + `default-anchor-name: ${c.defaultAnchorName}`, + `default-anchor-imgsrc: ${c.defaultAnchorImgsrc ? '(set)' : '(not set)'}`, + `default-anchor-aura-color: ${c.defaultAnchorAuraColor}`, + `default-anchor-aura-visible: ${c.defaultAnchorAuraVisible}`, + `allow-player-use: ${c.allowPlayerUse}`, + ]; + reply(msg, 'Config', lines.join('
')); + }; + + const handleConfig = (msg, otherArgs) => { + const c = cfg(); + + if (otherArgs.length === 0) { showConfig(msg); return; } + if (otherArgs[0] === 'reset') { + // Delete the state config entirely so checkInstall rebuilds it + // from DEFAULTS + globalconfig on next sandbox restart. + // For immediate effect, also reassign from DEFAULTS now. + delete state[SCRIPT_NAME].config; + state[SCRIPT_NAME].config = Object.assign({}, DEFAULTS); + reply(msg, 'Config', 'Runtime config cleared. Values now reflect API Scripts page settings (or built-in defaults). Restart the sandbox to fully re-apply useroptions.'); + showConfig(msg); + return; + } + + const sub = otherArgs[0]; + const val = otherArgs[1]; + + if (sub === 'poll-interval') { + const ms = parseInt(val, 10); + if (isNaN(ms) || ms < 100) { + reply(msg, 'Config', 'poll-interval must be a number ≥ 100.'); + return; + } + c.pollIntervalMs = ms; + reply(msg, 'Config', `poll-interval set to ${ms}ms. Note: restart the API sandbox for the new interval to take effect.`); + return; + } + + if (sub === 'default-anchor-layer') { + const valid = ['gmlayer', 'objects', 'map']; + if (!valid.includes(val)) { + reply(msg, 'Config', `default-anchor-layer must be one of: ${valid.join(', ')}`); + return; + } + c.defaultAnchorLayer = val; + reply(msg, 'Config', `default-anchor-layer set to ${val}.`); + return; + } + + if (sub === 'default-anchor-size') { + const px = parseInt(val, 10); + if (isNaN(px) || px < 1) { + reply(msg, 'Config', 'default-anchor-size must be a positive integer.'); + return; + } + c.defaultAnchorSize = px; + reply(msg, 'Config', `default-anchor-size set to ${px}px.`); + return; + } + + if (sub === 'default-anchor-name') { + if (!val) { + reply(msg, 'Config', 'default-anchor-name requires a value.'); + return; + } + c.defaultAnchorName = val; + reply(msg, 'Config', `default-anchor-name set to "${val}".`); + return; + } + + if (sub === 'default-anchor-imgsrc') { + if (!val) { + reply(msg, 'Config', 'default-anchor-imgsrc requires a value — either a relative Roll20 path (e.g. /images/character.png) or a thumb URL from your Roll20 library.'); + return; + } + c.defaultAnchorImgsrc = val; + reply(msg, 'Config', `default-anchor-imgsrc set.`); + return; + } + + if (sub === 'default-anchor-aura-color') { + if (!val || !/^#[0-9a-fA-F]{6}$/.test(val)) { + reply(msg, 'Config', 'default-anchor-aura-color must be a hex color (e.g. #00ffff).'); + return; + } + c.defaultAnchorAuraColor = val; + reply(msg, 'Config', `default-anchor-aura-color set to ${val}.`); + return; + } + + if (sub === 'default-anchor-aura-visible') { + if (val !== 'true' && val !== 'false') { + reply(msg, 'Config', 'default-anchor-aura-visible must be true or false.'); + return; + } + c.defaultAnchorAuraVisible = val === 'true'; + reply(msg, 'Config', `default-anchor-aura-visible set to ${val}.`); + return; + } + + if (sub === 'allow-player-use') { + if (val !== 'true' && val !== 'false') { + reply(msg, 'Config', 'allow-player-use must be true or false.'); + return; + } + c.allowPlayerUse = val === 'true'; + reply(msg, 'Config', `allow-player-use set to ${val}.`); + return; + } + + const validKeys = [ + 'poll-interval', 'default-anchor-layer', 'default-anchor-size', + 'default-anchor-name', 'default-anchor-imgsrc', + 'default-anchor-aura-color', 'default-anchor-aura-visible', + 'allow-player-use', 'reset', + ]; + reply(msg, 'Config', `Unknown config key: ${sub}. Valid keys: ${validKeys.join(', ')}`); + }; + + // ------------------------------------------------------------------------- + // Main command handler + // ------------------------------------------------------------------------- + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + // Must start with the command token + if (msg.content.split(' ')[0] !== CMD_TOKEN) return; + + try { + const rawArgs = msg.content.slice(CMD_TOKEN.length).split(' ').filter(Boolean); + const { flags, otherArgs } = parseArgs(rawArgs); + + const isGM = playerIsGM(msg.playerid); + + // Non-GMs are blocked entirely unless allowPlayerUse is on. + // Even with allowPlayerUse, non-GMs cannot change config or + // create/remove anchor relationships — only info/lock/unlock/update/center + // on tokens they control. + if (!isGM && !cfg().allowPlayerUse) { + reply(msg, 'Error', 'Only the GM can use Anchor commands.'); + return; + } + + if (!isGM && (flags.has('config') || flags.has('remove') || + Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || + flags.size === 0)) { + reply(msg, 'Error', 'Players may only use: lock, unlock, update, center, info.'); + return; + } + + // --help + if (flags.has('--help')) { + reply(msg, HELP_TEXT); + return; + } + + // gen-dev-docs + if (flags.has('gen-dev-docs')) { + const handoutName = `Help: ${SCRIPT_NAME}/Scripting API`; + let hh = findObjs({ type: 'handout', name: handoutName })[0]; + if (!hh) { + hh = createObj('handout', { name: handoutName, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); + } + let html = `

${SCRIPT_NAME} — Scripting API

`; + html += `

Access via Anchor.* after on('ready') fires.

`; + html += `

Querying Relationships

`; + html += `
Anchor.getAnchor(childId)       // → anchorId or undefined\nAnchor.getChildren(anchorId)    // → [graphic objects]
`; + html += `

Creating / Removing

`; + html += `
Anchor.anchorObj(childId, anchorId, components)\nAnchor.createAnchorFor(obj, components, persist)  // → new anchor obj\nAnchor.removeAnchor(childId)
`; + html += `

Chain Linking

`; + html += `
Anchor.chainAnchorObjs(ids, components)   // ring-link: A→B, B→C, C→A\nAnchor.unchainAnchorObjs(startId)          // dissolve ring from any member → [ids] or null
`; + html += `

Position (anchor-local)

`; + html += `
Anchor.getPosition(obj)           // → [left, top]\nAnchor.setPosition(obj, left, top)
`; + html += `

Rotation (anchor-local)

`; + html += `
Anchor.getRotation(obj)           // → degrees\nAnchor.setRotation(obj, degrees)
`; + html += `

Scale (anchor-local)

`; + html += `
Anchor.getScale(obj)              // → [widthRatio, heightRatio]\nAnchor.setScale(obj, widthRatio, heightRatio)
`; + html += `

Flip (relative to parent)

`; + html += `
Anchor.getFlipV(obj)              // → true (flipped) / false (same) / undefined\nAnchor.setFlipV(obj, flipped)\nAnchor.getFlipH(obj)\nAnchor.setFlipH(obj, flipped)
`; + html += `

Semantics: true = flipped relative to parent. Consistent with world-space: an unanchored token with flipv=true is flipped relative to the world origin.

`; + html += `

Z-Order

`; + html += `
Anchor.getZOffset(obj)            // → number (read-only)
`; + html += `

Call Anchor.updateZOrder(anchorObj) after moving anchor in z-order to propagate.

`; + html += `

Lock / Unlock

`; + html += `
Anchor.getLocked(obj)             // → ['left','top',...]\nAnchor.getUnlocked(obj)           // → ['rotation',...]\nAnchor.lock(obj, ['left','top'])  // null = lock all\nAnchor.unlock(obj, ['rotation'])  // null = unlock all
`; + html += `

Forcing Updates

`; + html += `
Anchor.updateObj(anchorObj)        // sync all children now\nAnchor.updateZOrder(anchorObj)    // restack z-order children
`; + html += `

Integration with Choreograph

`; + html += `

When Choreograph is loaded, Anchor registers token variables accessible as token.anchor.*:

`; + html += `
    `; + html += `
  • parent — anchor token (or null)
  • `; + html += `
  • left, top, rotation, scaleW, scaleH — local-space values
  • `; + html += `
  • flipV, flipH — flip state relative to parent
  • `; + html += `
  • zOffset — z-order offset
  • `; + html += `
  • locked, unlocked — component arrays
  • `; + html += `
  • siblings, children — related token arrays
  • `; + html += `
`; + html += `

Integration with Sequence

`; + html += `

When Sequence is loaded, Anchor registers virtual attributes anchor.left, anchor.top, anchor.rotation for animating in anchor-local space.

`; + hh.set('notes', html); + reply(msg, `Generated ${handoutName} — check your journal.`); + return; + } + + // config subcommand + if (flags.has('config')) { + handleConfig(msg, otherArgs); + return; + } + + // Validate: lock and unlock are mutually exclusive + if (flags.has('lock') && flags.has('unlock')) { + reply(msg, 'Error', 'lock and unlock cannot be used together.'); + return; + } + + // Validate: remove cannot be combined with anchor-type flags + if (flags.has('remove')) { + const anchorFlags = Object.keys(FLAG_EXPANSIONS).filter(f => flags.has(f)); + if (anchorFlags.length > 0) { + reply(msg, 'Error', 'remove cannot be combined with anchor flags.'); + return; + } + } + + // Only skip the first otherArg as a potential anchor ID when we're + // establishing a new anchor relationship AND it's actually a valid graphic. + // If there's no valid graphic as the first arg, all otherArgs are child IDs. + const ACTION_FLAGS = ['remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain']; + const hasAction = ACTION_FLAGS.some(f => flags.has(f)); + const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('--new') || flags.size === 0); + const firstArgIsAnchor = isNewAnchor && + !flags.has('remove') && + otherArgs.length > 0 && + isValidGraphic(otherArgs[0]); + const childArgOffset = firstArgIsAnchor ? 1 : 0; + const childIds = resolveChildIds(msg, flags, otherArgs.slice(flags.has('remove') ? 0 : childArgOffset)); + + // New anchor relationship + if (isNewAnchor && !flags.has('remove')) { + // Must have at least one child to anchor + if (childIds.length === 0) { + reply(msg, 'Error', 'Select or specify at least one token to anchor.'); + return; + } + + let anchorId; + let isAutoCreated = false; + + if (otherArgs.length > 0 && isValidGraphic(otherArgs[0])) { + // Use the supplied existing token as the anchor + anchorId = otherArgs[0]; + } else if (!flags.has('--new') && childIds.length > 1) { + // Selection-based: first selected = parent, rest = children + anchorId = childIds.shift(); + } else if (childIds.length === 1 && getAnchor(childIds[0]) && !flags.has('--new')) { + // Single token already anchored — modify existing (handled below by lock/track) + reply(msg, 'Info', 'Token is already anchored. Use lock/unlock/track/untrack to modify, or --new to create a new parent.'); + return; + } else { + // Auto-create invisible anchor (single selected or --new flag) + const refObj = getObj('graphic', childIds[0]); + const newToken = createAnchorToken(refObj); + if (!newToken) { + reply(msg, 'Error', 'Failed to auto-create anchor token. Try providing an existing token ID instead.'); + return; + } + anchorId = newToken.get('id'); + isAutoCreated = !flags.has('persist'); + if (isAutoCreated) { + state[SCRIPT_NAME].autoCreatedAnchors[anchorId] = true; + } + reply(msg, 'Info', + `Created new anchor token: ${anchorId}` + + (isAutoCreated ? ' (auto-destroy when last child removed; use persist flag to keep)' : ' (persistent)') + ); + } + + const components = resolveComponents(flags); + if (isAutoCreated) { + // Queue the anchor setup to be completed by the permanent + // add:graphic handler once Roll20 has fully committed the token. + state[SCRIPT_NAME].pendingAnchors[anchorId] = { childIds, components }; + } else { + setAnchors(anchorId, childIds, components); + } + } + + // Remove + if (flags.has('remove')) { + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent) { + // Both — require disambiguation + if (flags.has('--up')) { + setAnchor(id, undefined); + } else if (flags.has('--down')) { + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); + children.forEach(cid => setAnchor(cid, undefined)); + } else { + reply(msg, 'Error', 'Token is both parent and child. Use --up (remove from parent) or --down (unanchor children).'); + return; + } + } else if (isParent) { + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); + children.forEach(cid => setAnchor(cid, undefined)); + } else { + setAnchor(id, undefined); + } + }); + } + + // Center + if (flags.has('center')) { + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (center on parent) or --down (center children on self).'); + return; + } + + const centerChild = (cid) => { + const info = s.anchorInfoByChildId[cid]; + if (!info) return; + if ('left' in info) info.left = 0; + if ('top' in info) info.top = 0; + if ('rotation' in info) info.rotation = 0; + if ('widthRatio' in info) info.widthRatio = 1; + if ('heightRatio' in info) info.heightRatio = 1; + applyAnchorToChild(cid); + }; + + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } else if (flags.has('--up') && isChild) { + centerChild(id); + } else if (isChild) { + centerChild(id); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } + }); + } + + // Update (force immediate sync) + if (flags.has('update')) { + childIds.forEach(id => onObjectChangedImmediate(getObj('graphic', id))); + } + + // Lock / unlock — component flags specify which components to lock/unlock. + // With no component flags: lock/unlock ALL components (tracked + pre-lock). + if (flags.has('unlock')) { + const unlockComps = resolveComponentsOrNone(flags); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (unlock own parent link) or --down (unlock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } else if (flags.has('--up') && isChild) { + unlockComponents(id, unlockComps); + } else if (isChild) { + unlockComponents(id, unlockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } + }); + } else if (flags.has('lock')) { + const lockComps = resolveComponentsOrNone(flags); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (lock own parent link) or --down (lock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } else if (flags.has('--up') && isChild) { + lockComponents(id, lockComps); + } else if (isChild) { + lockComponents(id, lockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } + }); + } + + // Track / untrack / retrack — modify which components are tracked + // on existing anchor relationships without disturbing other offsets. + if (flags.has('track')) { + const comps = resolveComponents(flags); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + addTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); + return; + } else if (isChild) { + addTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); + } else { + reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); + } + }); + } + + if (flags.has('untrack')) { + const comps = resolveComponents(flags); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + removeTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); + return; + } else if (isChild) { + removeTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); + } + }); + } + + if (flags.has('retrack')) { + // Replace the tracked set entirely with the resolved components. + // No flags = default set (DEFAULT_COMPONENTS). + const comps = resolveComponents(flags); + childIds.forEach(id => { + if (!(id in state[SCRIPT_NAME].anchorInfoByChildId)) { + reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); + return; + } + const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; + const currentTracked = getTrackedComponentsFromInfo(info); + // Remove components that are tracked but not in new set + const toRemove = Object.fromEntries( + Object.keys(currentTracked).filter(c => !(c in comps)).map(c => [c, true]) + ); + // Add components that are in new set but not tracked + const toAdd = Object.fromEntries( + Object.keys(comps).filter(c => !(c in currentTracked)).map(c => [c, true]) + ); + if (Object.keys(toRemove).length > 0) removeTrackedComponents(id, toRemove); + if (Object.keys(toAdd).length > 0) addTrackedComponents(id, toAdd); + }); + } + + // Chain — circular anchor ring: A→B, B→C, C→A + if (flags.has('chain')) { + const comps = resolveComponents(flags); + const ids = resolveChildIds(msg, flags, otherArgs); + if (ids.length < 2) { + reply(msg, 'Error', 'Chain requires at least 2 tokens.'); + } else { + chainAnchorObjs(ids, comps); + reply(msg, 'Info', 'Chain-linked ' + ids.length + ' tokens in a ring.'); + } + } + + // Unchain — dissolve a chain ring from any member + if (flags.has('unchain')) { + const ids = resolveChildIds(msg, flags, otherArgs); + if (ids.length === 0) { + reply(msg, 'Error', 'Select or specify a token in the chain.'); + } else { + var unchained = unchainAnchorObjs(ids[0]); + if (unchained) { + reply(msg, 'Info', 'Unchained ' + unchained.length + ' tokens.'); + } else { + reply(msg, 'Error', 'Token is not part of a chain ring.'); + } + } + } + + // Info + if (flags.has('info')) { + if (childIds.length > 0) { + // Explicit selection or specified IDs — show exactly what was asked for + childIds.forEach(id => showInfo(msg, id)); + } else { + // Nothing selected or specified — show all tracked objects, + // but filter to the page the sender is currently viewing. + const playerPages = Campaign().get('playerspecificpages'); + const viewedPageId = (playerPages && playerPages[msg.playerid]) + || Campaign().get('playerpageid'); + + const s = state[SCRIPT_NAME]; + const allTracked = new Set([ + ...Object.keys(s.anchorInfoByChildId), + ...Object.keys(s.anchorChildrenByAnchorId), + ]); + + const onViewedPage = [...allTracked].filter(id => { + const obj = getObj('graphic', id); + return obj && obj.get('_pageid') === viewedPageId; + }); + + if (onViewedPage.length === 0) { + reply(msg, 'Info', 'No anchor relationships are active on your current page.'); + } else { + onViewedPage.forEach(id => showInfo(msg, id)); + } + } + } + + } catch (err) { + log(`${SCRIPT_NAME} error in handleInput: ${err}`); + reply(msg, 'Error', `An internal error occurred: ${err.message}`); + } + }; + + // ------------------------------------------------------------------------- + // Public API (for use by other scripts, e.g. an animation script) + // ------------------------------------------------------------------------- + + /** + * Returns the anchor graphic object for `objId`, or undefined if not anchored. + */ + const getAnchor = (objId) => { + const info = state[SCRIPT_NAME].anchorInfoByChildId[objId]; + return info ? getObj('graphic', info.anchor_id) : undefined; + }; + + /** + * Returns an array of child graphic objects anchored to `objId`. + */ + const getChildren = (objId) => { + const children = state[SCRIPT_NAME].anchorChildrenByAnchorId[objId]; + if (!children) return []; + return Object.keys(children).map(id => getObj('graphic', id)).filter(Boolean); + }; + + /** + * Programmatically anchor `childId` to `anchorId`. + * `components` is an optional object like `{ left: true, top: true, rotation: true }`. + * Defaults to all components if omitted. + */ + const anchorObj = (childId, anchorId, components) => { + const resolved = components || Object.fromEntries(Object.keys(COMPONENTS).map(k => [k, true])); + ensureObjState(anchorId); + ensureObjState(childId); + setAnchor(childId, anchorId, resolved); + }; + + /** Remove the anchor relationship from a child object. */ + const removeAnchor = (childId) => setAnchor(childId, undefined); + + /** + * Mutually anchor a list of token IDs in a ring (A→B, B→C, C→A). + * Move any one and all others follow. + * `components` is optional; defaults to all components. + */ + const chainAnchorObjs = (ids, components) => { + if (!ids || ids.length < 2) { + log(SCRIPT_NAME + ': chainAnchorObjs requires at least 2 token IDs.'); + return; + } + for (var i = 0; i < ids.length; i++) { + var nextIdx = (i + 1) % ids.length; + anchorObj(ids[i], ids[nextIdx], components); + } + }; + + /** + * Walk the anchor chain from a starting token and find the ring. + * Returns the array of IDs forming the ring, or null if no ring found. + * The starting token does not need to be in the ring itself — if it's + * a child of a ring member, the ring is still found. + */ + const walkChain = (startId) => { + const s = state[SCRIPT_NAME]; + const visited = []; + var current = startId; + while (true) { + var info = s.anchorInfoByChildId[current]; + if (!info) return null; // not a child — dead end, no ring + visited.push(current); + var nextId = info.anchor_id; + var idx = visited.indexOf(nextId); + if (idx !== -1) return visited.slice(idx); // found the ring + current = nextId; + if (visited.length > 1000) return null; // safety cap + } + }; + + /** + * Unchain a ring of anchored tokens. Given any token ID in the ring, + * walks the chain and removes all anchor relationships. + * Returns the array of unchained IDs, or null if the token is not in a ring. + */ + const unchainAnchorObjs = (startId) => { + var ids = walkChain(startId); + if (!ids) { + log(SCRIPT_NAME + ': unchainAnchorObjs — token is not part of a chain ring.'); + return null; + } + ids.forEach(function(id) { removeAnchor(id); }); + return ids; + }; + + /** + * Programmatically create an invisible auto-anchor token for `obj` and + * establish the anchor relationship immediately. + * + * Equivalent to the GM running !anchor on the token from chat, but callable + * from other scripts. The anchor is marked as auto-created and will be + * destroyed when its last child is removed (same as the chat command). + * + * `components` is optional — defaults to DEFAULT_COMPONENTS (no z-order). + * `persist` (bool, default false) — if true, the anchor token survives + * becoming childless (same as the persist flag in the chat command). + * + * Returns the new anchor graphic object, or undefined on failure. + */ + const createAnchorFor = (obj, components, persist) => { + const childId = obj.get('id'); + if (!isValidGraphic(childId)) return undefined; + + const resolved = components || Object.fromEntries(DEFAULT_COMPONENTS.map(k => [k, true])); + const token = createAnchorToken(obj); + if (!token) return undefined; + + const anchorId = token.get('id'); + if (!persist) { + state[SCRIPT_NAME].autoCreatedAnchors[anchorId] = true; + } + + // Queue via pendingAnchors so setAnchors runs after Roll20 commits the token + state[SCRIPT_NAME].pendingAnchors[anchorId] = { + childIds: [childId], + components: resolved, + }; + + return token; + }; + + /** + * Force an immediate transform sync for `obj` (anchor → children). + * Call this after your script moves an anchor programmatically. + */ + const updateObj = (obj) => onObjectChangedImmediate(obj); + + /** + * Restack z-order-tracked children relative to their anchor. + * Call this after moving an anchor in z-order (e.g. via EasyReZorder). + * @param {Roll20Object} anchorObj — the anchor graphic + */ + const updateZOrder = (anchorObj) => applyZOrderToChildren(anchorObj.get('id')); + + /** + * Get the child's position [left, top] in anchor-local coordinates. + * If not anchored, returns [left, top] in world coordinates. + */ + const getPosition = (obj) => { + const info = state[SCRIPT_NAME].anchorInfoByChildId[obj.get('id')]; + return info ? [info.left || 0, info.top || 0] : [obj.get('left'), obj.get('top')]; + }; + + /** + * Set the child's position in anchor-local coordinates and apply immediately. + */ + const setPosition = (obj, left, top) => { + const id = obj.get('id'); + const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; + if (info) { + if ('left' in info) info.left = left; + if ('top' in info) info.top = top; + applyAnchorToChild(id); + } else { + obj.set({ left, top }); + } + }; + + /** + * Get the child's rotation in anchor-local degrees. + * If not anchored, returns world rotation. + */ + const getRotation = (obj) => { + const info = state[SCRIPT_NAME].anchorInfoByChildId[obj.get('id')]; + return info && 'rotation' in info ? info.rotation : obj.get('rotation'); + }; + + /** + * Set the child's rotation in anchor-local degrees and apply immediately. + */ + const setRotation = (obj, degrees) => { + const id = obj.get('id'); + const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; + if (info && 'rotation' in info) { + info.rotation = normDeg(degrees); + applyAnchorToChild(id); + } else { + obj.set('rotation', normDeg(degrees)); + } + }; + + /** + * Get the child's scale relative to its anchor [widthRatio, heightRatio]. + * If not anchored (or scale not tracked), returns [1, 1]. + */ + const getScale = (obj) => { + const info = state[SCRIPT_NAME].anchorInfoByChildId[obj.get('id')]; + return [ + info && 'widthRatio' in info ? info.widthRatio : 1, + info && 'heightRatio' in info ? info.heightRatio : 1, + ]; + }; + + /** + * Set the child's scale relative to its anchor and apply immediately. + */ + const setScale = (obj, widthRatio, heightRatio) => { + const id = obj.get('id'); + const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; + if (info) { + if ('widthRatio' in info) info.widthRatio = widthRatio; + if ('heightRatio' in info) info.heightRatio = heightRatio; + applyAnchorToChild(id); + } else { + const anchor = getObj('graphic', info && info.anchor_id); + if (anchor) { + obj.set({ width: anchor.get('width') * widthRatio, height: anchor.get('height') * heightRatio }); + } + } + }; + + /** + * Get whether child is flipped vertically relative to its anchor. + * true = flipped relative to parent, false = same as parent. + */ + const getFlipV = (obj) => { + const info = state[SCRIPT_NAME].anchorInfoByChildId[obj.get('id')]; + return info && 'flipv' in info ? info.flipv : undefined; + }; + + /** + * Set the child's flipv state relative to anchor. + * true = flipped relative to parent, false = same as parent. + */ + const setFlipV = (obj, flipped) => { + const id = obj.get('id'); + const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; + if (info && 'flipv' in info) { + info.flipv = !!flipped; + applyAnchorToChild(id); + } + }; + + /** + * Get whether child is flipped horizontally relative to its anchor. + */ + const getFlipH = (obj) => { + const info = state[SCRIPT_NAME].anchorInfoByChildId[obj.get('id')]; + return info && 'fliph' in info ? info.fliph : undefined; + }; + + /** + * Set the child's fliph state relative to anchor. + */ + const setFlipH = (obj, flipped) => { + const id = obj.get('id'); + const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; + if (info && 'fliph' in info) { + info.fliph = !!flipped; + applyAnchorToChild(id); + } + }; + + /** + * Get the child's z-order offset relative to anchor (read-only). + * Returns 0 if not tracked. + */ + const getZOffset = (obj) => { + const info = state[SCRIPT_NAME].anchorInfoByChildId[obj.get('id')]; + return info && 'z_offset' in info ? info.z_offset : 0; + }; + + /** + * Get array of locked component names for a child. + */ + const getLocked = (obj) => { + const s = state[SCRIPT_NAME]; + const set = s.lockedObjects && s.lockedObjects[obj.get('id')]; + return set instanceof Set ? [...set] : []; + }; + + /** + * Get array of tracked-but-unlocked component names for a child. + */ + const getUnlocked = (obj) => { + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + const info = s.anchorInfoByChildId[id]; + if (!info) return []; + const lockedSet = s.lockedObjects && s.lockedObjects[id]; + const tracked = Object.keys(info).filter(k => k !== 'anchor_id' && !k.startsWith('_')); + return tracked.filter(k => !(lockedSet instanceof Set) || !lockedSet.has(k)); + }; + + /** + * Lock components on a child. components is an array of names or null for all. + */ + const lock = (obj, components) => { + const comps = components ? components.reduce((o, c) => { o[c] = true; return o; }, {}) : null; + lockComponents(obj.get('id'), comps); + }; + + /** + * Unlock components on a child. components is an array of names or null for all. + */ + const unlock = (obj, components) => { + const comps = components ? components.reduce((o, c) => { o[c] = true; return o; }, {}) : null; + unlockComponents(obj.get('id'), comps); + }; + + // ------------------------------------------------------------------------- + // Initialisation + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // State migration + // ------------------------------------------------------------------------- + + /** + * Migrate state from older versions to the current format. + * Safe to run on every startup — each migration is idempotent and gated + * on the presence of the old format. + * + * v1 → v2 changes: + * - s.stateVersion added (absent in v1) + * - lockedObjects values: flat string (childId) → Set + * v1 stored { childId: childId }; a lock meant "all components locked". + * Migrated to Set containing ALL_COMPONENTS. + * - objectStates snapshots: v1 only stored left/top/rotation. + * New fields (width/height/layer/flipv/fliph) will be populated on the + * next poll tick via ensureObjState — no explicit migration needed. + * - anchorInfoByChildId: v1 entries with only left/top/rotation are valid + * v2 entries tracking just those components — no migration needed. + * - New state keys (anchorZOrder, autoCreatedAnchors, pendingAnchors) + * are initialised by checkInstall via the || {} pattern — no migration needed. + */ + const migrateState = (s) => { + const currentVersion = 2; + const stateVersion = s.stateVersion || 1; + + if (stateVersion >= currentVersion) return; + + if (stateVersion < 2) { + log(`${SCRIPT_NAME}: migrating state from v${stateVersion} to v2...`); + + // Migrate lockedObjects: { childId: childId } → { childId: Set(ALL_COMPONENTS) } + // In v1, being in lockedObjects meant all components were locked. + // We detect the old format by checking if the value is a string (not a Set). + let migratedLocks = 0; + Object.keys(s.lockedObjects || {}).forEach(childId => { + const val = s.lockedObjects[childId]; + // Old format: value is the childId string itself + // Also catch any other non-Set value + if (!(val instanceof Set)) { + s.lockedObjects[childId] = new Set(ALL_COMPONENTS); + migratedLocks++; + } + }); + + if (migratedLocks > 0) { + log(`${SCRIPT_NAME}: migrated ${migratedLocks} locked object(s) to per-component format (all components locked).`); + } + + s.stateVersion = 2; + log(`${SCRIPT_NAME}: migration to v2 complete.`); + } + }; + + const checkInstall = () => { + state[SCRIPT_NAME] = state[SCRIPT_NAME] || {}; + const s = state[SCRIPT_NAME]; + + // Read globalconfig (set via the API Scripts page useroptions UI). + // Only available when installed via one-click; falls back to DEFAULTS + // when pasted manually. Note: checkbox values arrive as strings "true"/"false". + const gc = (typeof globalconfig !== 'undefined' && globalconfig[SCRIPT_NAME]) || {}; + + const gcConfig = {}; + if (gc.pollIntervalMs !== undefined) { + const ms = parseInt(gc.pollIntervalMs, 10); + if (!isNaN(ms) && ms >= 100) gcConfig.pollIntervalMs = ms; + } + if (gc.defaultAnchorLayer !== undefined) + gcConfig.defaultAnchorLayer = gc.defaultAnchorLayer; + if (gc.defaultAnchorSize !== undefined) { + const px = parseInt(gc.defaultAnchorSize, 10); + if (!isNaN(px) && px >= 1) gcConfig.defaultAnchorSize = px; + } + if (gc.defaultAnchorName !== undefined) + gcConfig.defaultAnchorName = gc.defaultAnchorName; + if (gc.defaultAnchorAuraColor !== undefined) + gcConfig.defaultAnchorAuraColor = gc.defaultAnchorAuraColor; + if (gc.defaultAnchorAuraVisible !== undefined) + gcConfig.defaultAnchorAuraVisible = gc.defaultAnchorAuraVisible !== 'false'; + if (gc.defaultAnchorImgsrc !== undefined) + gcConfig.defaultAnchorImgsrc = gc.defaultAnchorImgsrc; + if (gc.allowPlayerUse !== undefined) + gcConfig.allowPlayerUse = gc.allowPlayerUse === 'true'; + + // Merge order: hardcoded DEFAULTS < globalconfig < existing state (runtime overrides). + // This means !anchor config changes persist across restarts even if globalconfig + // is also present, giving GMs fine-grained runtime control on top of the UI. + s.config = Object.assign({}, DEFAULTS, gcConfig, s.config || {}); + + s.anchorChildrenByAnchorId = s.anchorChildrenByAnchorId || {}; + s.anchorInfoByChildId = s.anchorInfoByChildId || {}; + s.objectStates = s.objectStates || {}; + // lockedObjects: { [childId]: Set } + // An entry exists even for untracked components ("pre-locked"). + // Empty set means "nothing locked" — entries should be deleted when empty. + s.lockedObjects = s.lockedObjects || {}; + // Z-order lists: { [anchorId]: { front: [id,...], back: [id,...] } } + // front[] and back[] are ordered front-to-back relative to the anchor. + s.anchorZOrder = s.anchorZOrder || {}; + // IDs of anchor tokens auto-created by the script. + // These are destroyed automatically when their last child is removed. + // Use the persist flag (!anchor persist ...) to opt out of auto-destroy. + s.autoCreatedAnchors = s.autoCreatedAnchors || {}; + // pendingAnchors: { [anchorId]: { childIds, components } } + // Set when an auto-created anchor token is waiting for add:graphic to fire. + // Processed and cleared by the permanent add:graphic handler. + s.pendingAnchors = s.pendingAnchors || {}; + // stateVersion tracks which migrations have been applied. + // Set to current version on fresh installs; migrateState() handles upgrades. + s.stateVersion = s.stateVersion || 2; + + migrateState(s); + cleanInvalidEntries(); + + // Warn if the imgsrc is not a valid Roll20 library URL — auto-created + // anchor tokens will fail to appear without one. + const imgsrc = s.config.defaultAnchorImgsrc || ''; + if (!imgsrc.startsWith('https://s3.amazonaws.com/files.d20.io/images/')) { + log(`${SCRIPT_NAME} WARNING: default-anchor-imgsrc is not set to a valid Roll20 library URL. Auto-created anchor tokens will be invisible and unselectable. Upload a transparent PNG to your Roll20 library and set the thumb URL via: !anchor config default-anchor-imgsrc `); + } + + // Generate Help: Anchor handout + (() => { + const helpName = `Help: ${SCRIPT_NAME}`; + let hh = findObjs({ type: 'handout', name: helpName })[0]; + if (!hh) { + hh = createObj('handout', { name: helpName, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); + } + let html = `

${SCRIPT_NAME} v${SCRIPT_VERSION}

`; + html += `

Attach child tokens to an anchor token so they automatically follow its position, rotation, scale, and flip. Layer and z-order tracking are opt-in via flags.

`; + html += `

Quick Start

`; + html += `

Select a parent token and one or more children, then run !anchor. The first selected token becomes the parent — the rest follow it.

`; + html += `

To create an invisible anchor instead (children move together with no visible parent): !anchor --new

`; + html += `

Commands

`; + html += `
    `; + html += `
  • !anchor [anchor_id] [flags] [--new] [--persist] — Anchor selected tokens. First selected = parent, rest = children. --new forces auto-create. --persist keeps auto-created anchors when childless.
  • `; + html += `
  • !anchor remove [--up] [--down] — Remove relationships. Parent selected = unanchor children. Child selected = detach from parent.
  • `; + html += `
  • !anchor lock [flags] — Lock components (manual moves are undone)
  • `; + html += `
  • !anchor unlock [flags] — Unlock components
  • `; + html += `
  • !anchor track [flags] [--down] — Add component tracking. --down applies to children.
  • `; + html += `
  • !anchor untrack [flags] [--down] — Remove component tracking. --down applies to children.
  • `; + html += `
  • !anchor retrack [flags] — Replace tracked set entirely
  • `; + html += `
  • !anchor center [--up] [--down] — Snap to center. --up centers on parent, --down centers children on self.
  • `; + html += `
  • !anchor update — Force immediate sync
  • `; + html += `
  • !anchor info — Show anchor state
  • `; + html += `
  • !anchor chain [flags] — Mutually anchor tokens in a ring (A→B→C→A)
  • `; + html += `
  • !anchor unchain — Dissolve a chain ring from any member
  • `; + html += `
  • !anchor config [key] [value] — Configuration
  • `; + html += `
  • !anchor --help — Command reference in chat
  • `; + html += `
`; + html += `

Modifiers

`; + html += `
    `; + html += `
  • --new — Force auto-create invisible anchor (all selected become children)
  • `; + html += `
  • --persist — Keep auto-created anchor even when childless
  • `; + html += `
  • --up — Operate on own parent relationship
  • `; + html += `
  • --down — Operate on children's relationships
  • `; + html += `
  • --ignore-selected — Skip current selection (use explicit IDs only)
  • `; + html += `
`; + html += `

When a token is both parent and child, --up/--down is required to disambiguate.

`; + html += `

Component Flags

`; + html += `

-all (everything incl. layer/z), -pos (x+y), -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z

`; + html += `

Default (no flags): position + rotation + scale + flip. Use -all to include layer and z-order.

`; + hh.set('notes', html); + })(); + + // State migration: normalize pixel offsets to anchor-size-relative (v2.2.1) + (() => { + const s = state[SCRIPT_NAME]; + if (s.schemaVersion >= 2.21) return; + Object.entries(s.anchorInfoByChildId || {}).forEach(([childId, info]) => { + const anchor = getObj('graphic', info.anchor_id); + if (!anchor) return; + const aW = anchor.get('width'); + const aH = anchor.get('height'); + if ('left' in info && aW > 0) info.left = info.left / aW; + if ('top' in info && aH > 0) info.top = info.top / aH; + }); + s.schemaVersion = 2.21; + })(); + + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('add:graphic', onAddGraphic); + on('change:graphic:left', onObjectChanged); + on('change:graphic:top', onObjectChanged); + on('change:graphic:rotation', onObjectChanged); + on('change:graphic:width', onObjectChanged); + on('change:graphic:height', onObjectChanged); + on('change:graphic:layer', onObjectChanged); + on('change:graphic:flipv', onObjectChanged); + on('change:graphic:fliph', onObjectChanged); + on('destroy:graphic', onDestroyObject); + + setInterval(pollUpdates, cfg().pollIntervalMs); + + // ── Sequence integration ────────────────────────────────────────── + const registerWithSequence = () => { + if (typeof Sequence === 'undefined') return; + + // Helper: update anchor object after modifying child's local state + const refreshAnchor = (obj) => { + const anchorId = getAnchor(obj.get('id')); + if (anchorId) { const a = getObj('graphic', anchorId); if (a) updateObj(a); } + }; + + // Register anchor-local position as virtual attributes + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'left', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local X position. Animates relative to anchor.', + valueType: 'number', + examples: ['+70 move right 70px in anchor space', '=0 snap to anchor center'], + startWatch: null, stopWatch: null, + get: (obj) => { const p = getPosition(obj); return p ? p[0] : obj.get('left'); }, + set: (obj, val) => { setPosition(obj, val, getPosition(obj)[1]); refreshAnchor(obj); }, + diff: (prev, curr) => { const d = Math.round((curr - prev) * 10000) / 10000; return d === 0 ? null : d; }, + apply: (obj, delta) => { const p = getPosition(obj); setPosition(obj, p[0] + delta, p[1]); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'top', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local Y position. Animates relative to anchor.', + valueType: 'number', + examples: ['+70 move down 70px in anchor space', '=0 snap to anchor center'], + startWatch: null, stopWatch: null, + get: (obj) => { const p = getPosition(obj); return p ? p[1] : obj.get('top'); }, + set: (obj, val) => { const p = getPosition(obj); setPosition(obj, p[0], val); refreshAnchor(obj); }, + diff: (prev, curr) => { const d = Math.round((curr - prev) * 10000) / 10000; return d === 0 ? null : d; }, + apply: (obj, delta) => { const p = getPosition(obj); setPosition(obj, p[0], p[1] + delta); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'rotation', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local rotation in degrees.', + valueType: 'number', + examples: ['+90 rotate 90° in anchor space'], + startWatch: null, stopWatch: null, + get: (obj) => { const r = getRotation(obj); return r !== undefined ? r : 0; }, + set: (obj, val) => { setRotation(obj, val); refreshAnchor(obj); }, + diff: (prev, curr) => { const d = Math.round((curr - prev) * 10000) / 10000; return d === 0 ? null : d; }, + apply: (obj, delta) => { setRotation(obj, getRotation(obj) + delta); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'scaleW', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local width scale (multiplicative — ×2 doubles).', + valueType: 'scale', + examples: ['×2 double width in anchor space', '×0.5 halve width'], + startWatch: null, stopWatch: null, + get: (obj) => { const s = getScale(obj); return s ? s[0] : 1; }, + set: (obj, val) => { const s = getScale(obj); setScale(obj, val, s ? s[1] : 1); refreshAnchor(obj); }, + diff: (prev, curr) => { if (!prev || prev === 0 || curr === prev) return null; const r = Math.round((curr / prev) * 10000) / 10000; return r === 1 ? null : r; }, + apply: (obj, ratio) => { const s = getScale(obj); setScale(obj, (s ? s[0] : 1) * ratio, s ? s[1] : 1); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 1 }), + format: (ratio) => `×${ratio}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + if (s.startsWith('×') || s.startsWith('*')) return { delta: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'scaleH', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local height scale (multiplicative).', + valueType: 'scale', + examples: ['×2 double height in anchor space'], + startWatch: null, stopWatch: null, + get: (obj) => { const s = getScale(obj); return s ? s[1] : 1; }, + set: (obj, val) => { const s = getScale(obj); setScale(obj, s ? s[0] : 1, val); refreshAnchor(obj); }, + diff: (prev, curr) => { if (!prev || prev === 0 || curr === prev) return null; const r = Math.round((curr / prev) * 10000) / 10000; return r === 1 ? null : r; }, + apply: (obj, ratio) => { const s = getScale(obj); setScale(obj, s ? s[0] : 1, (s ? s[1] : 1) * ratio); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 1 }), + format: (ratio) => `×${ratio}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + if (s.startsWith('×') || s.startsWith('*')) return { delta: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'flipV', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local vertical flip (true = flipped relative to parent).', + valueType: 'boolean', + examples: ['=true flip vertically', '=false unflip'], + startWatch: null, stopWatch: null, + get: (obj) => { const v = getFlipV(obj); return v !== undefined ? v : false; }, + set: (obj, val) => { setFlipV(obj, val); refreshAnchor(obj); }, + diff: (prev, curr) => curr === prev ? null : curr, + apply: (obj, val) => { setFlipV(obj, val); refreshAnchor(obj); }, + lerp: null, + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + const v = s.startsWith('=') ? s.slice(1) : s; + return { abs: v === 'true' || v === '1' }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'flipH', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local horizontal flip (true = flipped relative to parent).', + valueType: 'boolean', + examples: ['=true flip horizontally', '=false unflip'], + startWatch: null, stopWatch: null, + get: (obj) => { const v = getFlipH(obj); return v !== undefined ? v : false; }, + set: (obj, val) => { setFlipH(obj, val); refreshAnchor(obj); }, + diff: (prev, curr) => curr === prev ? null : curr, + apply: (obj, val) => { setFlipH(obj, val); refreshAnchor(obj); }, + lerp: null, + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + const v = s.startsWith('=') ? s.slice(1) : s; + return { abs: v === 'true' || v === '1' }; + }, + }); + + log(`${SCRIPT_NAME}: registered anchor-local attributes with Sequence`); + }; + + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content === '!sequence-ready') registerWithSequence(); + }); + registerWithSequence(); + + // ── Choreograph integration ─────────────────────────────────────── + const registerWithChoreograph = () => { + if (typeof Choreograph === 'undefined') return; + + // Token variables (appear as token.anchor.parent, token.anchor.left, etc.) + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'parent', namespace: 'anchor', + description: 'The anchor token this token is attached to (or null)', + returns: 'token', + fn: (token) => { + const id = getAnchor(token.get('id')); + return id ? getObj('graphic', id) : null; + }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'left', namespace: 'anchor', + description: 'Anchor-local X position', + returns: 'number', + fn: (token) => { const p = getPosition(token); return p ? p[0] : 0; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'top', namespace: 'anchor', + description: 'Anchor-local Y position', + returns: 'number', + fn: (token) => { const p = getPosition(token); return p ? p[1] : 0; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'rotation', namespace: 'anchor', + description: 'Anchor-local rotation in degrees', + returns: 'number', + fn: (token) => { const r = getRotation(token); return r !== undefined ? r : 0; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'scaleW', namespace: 'anchor', + description: 'Anchor-local width scale ratio', + returns: 'number', + fn: (token) => { const s = getScale(token); return s ? s[0] : 1; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'scaleH', namespace: 'anchor', + description: 'Anchor-local height scale ratio', + returns: 'number', + fn: (token) => { const s = getScale(token); return s ? s[1] : 1; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'flipV', namespace: 'anchor', + description: 'Flipped vertically relative to anchor', + returns: 'boolean', + fn: (token) => { const v = getFlipV(token); return v !== undefined ? v : false; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'flipH', namespace: 'anchor', + description: 'Flipped horizontally relative to anchor', + returns: 'boolean', + fn: (token) => { const v = getFlipH(token); return v !== undefined ? v : false; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'zOffset', namespace: 'anchor', + description: 'Z-order offset relative to anchor', + returns: 'number', + fn: (token) => getZOffset(token), + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'locked', namespace: 'anchor', + description: 'Locked component names', + returns: 'string[]', + fn: (token) => getLocked(token), + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'unlocked', namespace: 'anchor', + description: 'Tracked but unlocked component names', + returns: 'string[]', + fn: (token) => getUnlocked(token), + }); + + // Functions returning token arrays + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'siblings', namespace: 'anchor', + description: 'Other tokens anchored to the same parent', + returns: 'token[]', + fn: (token) => { + const anchorId = getAnchor(token.get('id')); + if (!anchorId) return []; + return (getChildren(anchorId) || []) + .filter(t => t.get('id') !== token.get('id')); + }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'children', namespace: 'anchor', + description: 'Tokens anchored to this token as children', + returns: 'token[]', + fn: (token) => getChildren(token.get('id')) || [], + }); + + // Lifecycle hook for !anchor commands in scenes + Choreograph.registerLifecycleHook(SCRIPT_NAME, { + commands: [/^!anchor\b/], + start: (ctx) => { handleInput(ctx); }, + stop: null, + pause: null, + resume: null, + }); + + log(`${SCRIPT_NAME}: registered with Choreograph`); + }; + + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content === '!choreograph-ready') registerWithChoreograph(); + }); + registerWithChoreograph(); + }; + + // ------------------------------------------------------------------------- + // Module export + // ------------------------------------------------------------------------- + + return { + // Lifecycle (called by on('ready')) + checkInstall, + registerEventHandlers, + + // Public API for other scripts + API: { + getAnchor, + getChildren, + anchorObj, + createAnchorFor, + removeAnchor, + updateObj, + updateZOrder, + getPosition, + setPosition, + getRotation, + setRotation, + getScale, + setScale, + getFlipV, + setFlipV, + getFlipH, + setFlipH, + getZOffset, + getLocked, + getUnlocked, + lock, + unlock, + chainAnchorObjs, + unchainAnchorObjs, + }, + }; +})(); + +on('ready', () => { + 'use strict'; + Anchor.checkInstall(); + Anchor.registerEventHandlers(); + // Expose the public API at the top level for other scripts: + // Anchor.getAnchor(id), Anchor.anchorObj(...), etc. + Object.assign(Anchor, Anchor.API); + delete Anchor.API; +}); \ No newline at end of file diff --git a/Anchor/README.md b/Anchor/README.md index 707a226472..0a3e443d84 100644 --- a/Anchor/README.md +++ b/Anchor/README.md @@ -20,15 +20,21 @@ Attach child tokens to an anchor token so they automatically follow its position ## Quick Start -Select one or more tokens and run: +Select a parent token and one or more child tokens, then run: ``` !anchor ``` -An invisible anchor token is auto-created at the first selected token's position on the GM layer. Move the anchor — the children follow. +The first selected token becomes the parent anchor. The rest become children that follow the parent's transforms. -To use an existing token as the anchor instead: +To auto-create an invisible anchor instead (children move together with no visible parent): + +``` +!anchor --new +``` + +To use an existing token as the anchor by ID: ``` !anchor @@ -38,15 +44,22 @@ To use an existing token as the anchor instead: ## Commands -All commands accept `[ignore-selected]` to skip the current token selection, and `[child_id...]` to specify tokens explicitly by ID. +All commands accept `[--ignore-selected]` to skip the current token selection, and `[child_id...]` to specify tokens explicitly by ID. ### Anchoring ``` -!anchor [anchor_id] [component flags] [ignore-selected] [child_id...] +!anchor [anchor_id] [component flags] [--ignore-selected] [--new] [--persist] [child_id...] ``` -Anchor selected/listed tokens to `anchor_id`. If `anchor_id` is omitted or not a valid token, an invisible anchor token is auto-created at the first child's position. The auto-created token is destroyed automatically when its last child is removed. Add `persist` to keep it even when childless. +Anchor selected/listed tokens to `anchor_id`. + +**Selection-based parenting** (when no `anchor_id` is given): +- **Multiple tokens selected:** first selected = parent, rest = children. +- **One token selected (not already anchored):** auto-creates an invisible anchor. +- **One token selected (already anchored):** reports existing relationship. +- **`--new` flag:** force auto-create an invisible anchor (all selected become children). +- **`--persist`:** keep auto-created anchors even when childless (otherwise auto-destroyed). **Default components** (when no flags given): position, rotation, scale, width, height, flipv, fliph. @@ -71,11 +84,12 @@ Anchor selected/listed tokens to `anchor_id`. If `anchor_id` is omitted or not a **Examples:** ``` -!anchor -- anchor selected token(s), all default components +!anchor -- first selected = parent, rest = children +!anchor --new -- auto-create invisible anchor, all selected = children +!anchor --new --persist -- same, but anchor persists when childless !anchor -x -rot -layer -- anchor x-position, rotation, and layer only !anchor anchor-all -- anchor everything including z-order -!anchor -OtyABCDEF123 -- anchor to an existing token -!anchor persist -- auto-create anchor and keep it when childless +!anchor -OtyABCDEF123 -- anchor selected to an existing token by ID ``` When z-order anchoring (`-z` / `anchor-z`) is active, call `Anchor.updateZOrder(anchorObj)` from another script (e.g. EasyReZorder) after moving the anchor in z-order to propagate the new stack to children. @@ -85,24 +99,29 @@ When z-order anchoring (`-z` / `anchor-z`) is active, call `Anchor.updateZOrder( ### Removing ``` -!anchor remove [ignore-selected] [child_id...] +!anchor remove [--up] [--down] [--ignore-selected] [child_id...] ``` -Remove the anchor relationship from tokens. Does not delete the anchor token itself. +Remove anchor relationships from tokens. +- **Child selected:** removes it from its parent. +- **Parent selected:** unanchors all its children. +- **Both parent and child:** removes both directions. Use `--up` (remove parent link only) or `--down` (remove children only) to disambiguate. --- ### Locking and Unlocking ``` -!anchor lock [component flags] [ignore-selected] [child_id...] -!anchor unlock [component flags] [ignore-selected] [child_id...] +!anchor lock [component flags] [--up] [--down] [--ignore-selected] [child_id...] +!anchor unlock [component flags] [--up] [--down] [--ignore-selected] [child_id...] ``` -**Lock** freezes components — manual moves are undone every poll tick, and anchor changes to those components are ignored. With no component flags, locks all components. +**Lock** freezes components — manual moves are undone every poll tick, and anchor changes to those components are ignored. With no component flags, locks all components. When both left+top are tracked and locked, `lockMovement` is set on the token to prevent dragging entirely. **Unlock** releases components. With no component flags, unlocks everything. +**`--down`** — when a parent is selected, apply to all children. **`--up`** — apply to own parent link. Required when token is both parent and child. + Components can be locked before they are tracked ("pre-locked") — they will activate automatically when tracking is added via `track`. --- @@ -110,9 +129,9 @@ Components can be locked before they are tracked ("pre-locked") — they will ac ### Tracking ``` -!anchor track [component flags] [ignore-selected] [child_id...] -!anchor untrack [component flags] [ignore-selected] [child_id...] -!anchor retrack [component flags] [ignore-selected] [child_id...] +!anchor track [component flags] [--down] [--ignore-selected] [child_id...] +!anchor untrack [component flags] [--down] [--ignore-selected] [child_id...] +!anchor retrack [component flags] [--ignore-selected] [child_id...] ``` Modify which components are tracked on existing relationships without disturbing other stored offsets. @@ -120,33 +139,34 @@ Modify which components are tracked on existing relationships without disturbing - **`track`** — add components, recording the current relative state as the offset. - **`untrack`** — remove components. Does not affect locked state. - **`retrack`** — replace the tracked set entirely. No flags = default set. +- **`--down`** — when a parent is selected, apply to all children instead of the parent's own relationship. --- ### Other Commands ``` -!anchor center [ignore-selected] [child_id...] +!anchor center [--ignore-selected] [child_id...] ``` Snap children to anchor centre (offset 0,0, rotation 0°, scale 1:1). ``` -!anchor update [ignore-selected] [child_id...] +!anchor update [--ignore-selected] [child_id...] ``` Force an immediate transform sync for children. ``` -!anchor info [ignore-selected] [child_id...] +!anchor info [--ignore-selected] [child_id...] ``` Whisper anchor state to the caller. Shows tracked components with stored values, lock status (🔒), and pre-locked untracked components. With no tokens selected or specified, shows all anchored tokens on the current page. ``` -!anchor chain [component flags] [ignore-selected] [child_id...] +!anchor chain [component flags] [--ignore-selected] [child_id...] ``` Mutually anchor tokens in a ring (A→B, B→C, C→A). Move any one and all others follow. Useful for syncing tokens across pages or creating peer-linked groups. ``` -!anchor unchain [ignore-selected] [child_id...] +!anchor unchain [--ignore-selected] [child_id...] ``` Dissolve a chain ring. Select any one token in the ring (or even a child of a ring member) — the ring is detected and all relationships in it are removed. @@ -289,6 +309,19 @@ The public API changed: ## Changelog +### v2.2.1 +- Fix: child position offset now scales with parent size (proper matrix transform with scale) +- Fix: flip mirroring handled via scale matrix instead of manual negation +- Auto `lockMovement` when both left+top are tracked AND locked (clears when conditions change) +- State migration: old pixel offsets auto-normalized to anchor-size-relative on first load + +### v2.2.0 +- Selection-based parenting: first selected = parent when multiple tokens selected +- `--new` flag: force auto-create invisible anchor (all selected become children) +- `--up` / `--down` modifiers for `remove`, `track`, `untrack` disambiguation +- `--persist` and `--ignore-selected` as preferred syntax (bare words still work) +- `remove` from parent now unanchors all children + ### v2.1.0 - Rewrite with full ES6 modernisation (IIFE module pattern, `const`/`let`, arrow functions) - Added component flags system with short aliases (`-x`, `-rot`, `-z`, etc.) diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 398b29fdb2..e5d4754c22 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -1,6 +1,6 @@ // ============================================================================= -// Anchor v2.1.0 -// Last Updated: 2026-06-12 +// Anchor v2.2.1 +// Last Updated: 2026-06-26 // Author: Kenan Millet // // Description: @@ -102,7 +102,7 @@ var Anchor = Anchor || (() => { // ------------------------------------------------------------------------- const SCRIPT_NAME = 'Anchor'; - const SCRIPT_VERSION = '2.1.0'; + const SCRIPT_VERSION = '2.2.1'; const CMD_TOKEN = '!anchor'; const DEFAULTS = { @@ -179,6 +179,8 @@ var Anchor = Anchor || (() => { '-flipv': 'anchor-flipv', '-fliph': 'anchor-fliph', '-z': 'anchor-z', + '--persist': 'persist', + '--ignore-selected': 'ignore-selected', }; const ALL_COMMAND_FLAGS = [ @@ -187,7 +189,7 @@ var Anchor = Anchor || (() => { 'remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain', - 'ignore-selected', 'persist', + 'ignore-selected', 'persist', '--new', '--up', '--down', 'config', '--help', ]; @@ -223,10 +225,11 @@ var Anchor = Anchor || (() => { /** * Build a 3×3 transform matrix for a graphic's current position + rotation. */ - const buildTransform = (left, top, rotationDeg) => { + const buildTransform = (left, top, rotationDeg, scaleX, scaleY) => { let m = MatrixMath.identity(3); m = MatrixMath.multiply(m, MatrixMath.translate([left, top])); m = MatrixMath.multiply(m, MatrixMath.rotate(toRad(rotationDeg))); + m = MatrixMath.multiply(m, MatrixMath.scale([scaleX, scaleY])); return m; }; @@ -321,10 +324,17 @@ var Anchor = Anchor || (() => { const info = { id: childId, anchor_id: anchorId }; if (components.left || components.top) { - // Express child position in anchor-local frame (undo anchor rotation) + // Express child position in anchor-local frame (undo rotation + scale + flip) + const aFlipH = components.fliph && anchor.get('fliph'); + const aFlipV = components.flipv && anchor.get('flipv'); + const sx = aW > 0 ? 1 / (aW * (aFlipH ? -1 : 1)) : 1; + const sy = aH > 0 ? 1 / (aH * (aFlipV ? -1 : 1)) : 1; const relTransform = MatrixMath.multiply( - MatrixMath.rotate(toRad(-aRot)), - MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + MatrixMath.scale([sx, sy]), + MatrixMath.multiply( + MatrixMath.rotate(toRad(-aRot)), + MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + ) ); if (components.left) info.left = relTransform[2][0]; if (components.top) info.top = relTransform[2][1]; @@ -432,6 +442,24 @@ var Anchor = Anchor || (() => { ? Object.keys(components) : ALL_COMPONENTS; toAdd.forEach(c => locked.add(c)); + setPlacementLockIfNeeded(childId); + }; + + /** + * Set lockMovement if both left+top are tracked AND locked and we haven't already. + */ + const setPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + const info = s.anchorInfoByChildId[childId]; + if (!info || !('left' in info) || !('top' in info)) return; + const locked = getLockedComponents(childId); + if (!locked.has('left') || !locked.has('top')) return; + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; + } }; /** @@ -442,11 +470,12 @@ var Anchor = Anchor || (() => { const s = state[SCRIPT_NAME]; if (!components || Object.keys(components).length === 0) { delete s.lockedObjects[childId]; - return; + } else { + const locked = getLockedComponents(childId); + Object.keys(components).forEach(c => locked.delete(c)); + if (locked.size === 0) delete s.lockedObjects[childId]; } - const locked = getLockedComponents(childId); - Object.keys(components).forEach(c => locked.delete(c)); - if (locked.size === 0) delete s.lockedObjects[childId]; + clearPlacementLockIfNeeded(childId); }; /** @@ -497,6 +526,7 @@ var Anchor = Anchor || (() => { break; } }); + setPlacementLockIfNeeded(childId); }; /** @@ -529,6 +559,24 @@ var Anchor = Anchor || (() => { break; } }); + clearPlacementLockIfNeeded(childId); + }; + + /** + * Clear lockMovement if we set it and conditions no longer hold + * (left+top must both be tracked AND locked). + */ + const clearPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + if (!s.placementLockedByAnchor || !s.placementLockedByAnchor[childId]) return; + const info = s.anchorInfoByChildId[childId]; + const locked = getLockedComponents(childId); + const bothTrackedAndLocked = info && 'left' in info && 'top' in info && locked.has('left') && locked.has('top'); + if (!bothTrackedAndLocked) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } }; /** @@ -553,6 +601,7 @@ var Anchor = Anchor || (() => { delete s.objectStates[childId]; } delete s.lockedObjects[childId]; + clearPlacementLockIfNeeded(childId); return; } @@ -743,21 +792,18 @@ var Anchor = Anchor || (() => { const updates = {}; if (('left' in info || 'top' in info) && (shouldApply('left') || shouldApply('top'))) { + // Include flip as negative scale in the transform (for offset mirroring only) + const aFlipH = ('fliph' in info) && anchor.get('fliph'); + const aFlipV = ('flipv' in info) && anchor.get('flipv'); + const sx = anchor.get('width') * (aFlipH ? -1 : 1); + const sy = anchor.get('height') * (aFlipV ? -1 : 1); + const anchorTransform = buildTransform( - anchor.get('left'), anchor.get('top'), anchor.get('rotation') + anchor.get('left'), anchor.get('top'), anchor.get('rotation'), sx, sy ); - // Mirror offsets when flip components are tracked and anchor is flipped. - // fliph flips the anchor horizontally → mirror the x (left) offset. - // flipv flips the anchor vertically → mirror the y (top) offset. - const aFlipH = anchor.get('fliph'); - const aFlipV = anchor.get('flipv'); - const localLeft = ('left' in info) - ? (('fliph' in info) && aFlipH ? -(info.left) : info.left) - : 0; - const localTop = ('top' in info) - ? (('flipv' in info) && aFlipV ? -(info.top) : info.top) - : 0; + const localLeft = ('left' in info) ? info.left : 0; + const localTop = ('top' in info) ? info.top : 0; const childWorld = MatrixMath.multiply( anchorTransform, @@ -1101,42 +1147,42 @@ var Anchor = Anchor || (() => { const HELP_TEXT = [ `${SCRIPT_NAME} v${SCRIPT_VERSION}`, '', - `${CMD_TOKEN} [anchor_id] [flags] [ignore-selected] [child_id...]`, - 'Anchor selected/listed tokens. Auto-creates anchor token if no anchor_id given.', - 'Long form: anchor-all, anchor, anchor-position, anchor-x, anchor-y,', + `${CMD_TOKEN} [anchor_id] [flags] [--ignore-selected] [--new] [--persist] [child_id...]`, + 'Anchor selected tokens. First selected = parent, rest = children.', + '--new: force auto-create invisible anchor (all selected = children).', + '--persist: keep auto-created anchor even when childless.', + 'Long form: anchor-all, anchor-position, anchor-x, anchor-y,', 'anchor-rotation, anchor-scale, anchor-width, anchor-height, anchor-layer,', 'anchor-flip, anchor-flipv, anchor-fliph, anchor-z', 'Short aliases: -all, -pos, -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z', - 'Default (no flags): position+rotation+scale+flip. anchor-all/-all adds layer+z-order.', - '', - 'Add persist flag to keep an auto-created anchor token even when childless.', + 'Default (no flags): position+rotation+scale+flip. -all adds layer+z-order.', '', - `${CMD_TOKEN} remove [ignore-selected] [child_id...]`, - 'Remove anchor from tokens.', + `${CMD_TOKEN} remove [--up] [--down] [--ignore-selected] [child_id...]`, + 'Remove anchor relationships. Parent selected = unanchor children.', + '--up: remove only parent link. --down: remove only children.', '', - `${CMD_TOKEN} lock [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} lock [component flags] [--ignore-selected] [child_id...]`, 'Lock components — re-enforced every poll tick. No flags = lock all.', - 'Untracked components are pre-locked (activate when tracking is added).', '', - `${CMD_TOKEN} unlock [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} unlock [component flags] [--ignore-selected] [child_id...]`, 'Unlock components. No flags = unlock all.', '', - `${CMD_TOKEN} track [component flags] [ignore-selected] [child_id...]`, - 'Add tracking to existing relationship (records current relative state).', + `${CMD_TOKEN} track [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Add tracking to existing relationship. --down: apply to children.', '', - `${CMD_TOKEN} untrack [component flags] [ignore-selected] [child_id...]`, - 'Remove tracking. Does not affect locked state.', + `${CMD_TOKEN} untrack [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Remove tracking. --down: apply to children.', '', - `${CMD_TOKEN} retrack [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} retrack [component flags] [--ignore-selected] [child_id...]`, 'Replace tracked set entirely. No flags = default set.', '', - `${CMD_TOKEN} center [ignore-selected] [child_id...]`, + `${CMD_TOKEN} center [--ignore-selected] [child_id...]`, 'Snap child(ren) to anchor centre (0 offset, 0 rotation, 1:1 scale).', '', - `${CMD_TOKEN} chain [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} chain [component flags] [--ignore-selected] [child_id...]`, 'Mutually anchor tokens in a ring (A\u2192B, B\u2192C, C\u2192A). Move any one, all follow.', '', - `${CMD_TOKEN} unchain [ignore-selected] [child_id...]`, + `${CMD_TOKEN} unchain [--ignore-selected] [child_id...]`, 'Dissolve a chain ring. Select any one token in the ring.', '', `${CMD_TOKEN} update [ignore-selected] [child_id...]`, @@ -1518,7 +1564,7 @@ var Anchor = Anchor || (() => { // If there's no valid graphic as the first arg, all otherArgs are child IDs. const ACTION_FLAGS = ['remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain']; const hasAction = ACTION_FLAGS.some(f => flags.has(f)); - const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.size === 0); + const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('--new') || flags.size === 0); const firstArgIsAnchor = isNewAnchor && !flags.has('remove') && otherArgs.length > 0 && @@ -1540,9 +1586,15 @@ var Anchor = Anchor || (() => { if (otherArgs.length > 0 && isValidGraphic(otherArgs[0])) { // Use the supplied existing token as the anchor anchorId = otherArgs[0]; + } else if (!flags.has('--new') && childIds.length > 1) { + // Selection-based: first selected = parent, rest = children + anchorId = childIds.shift(); + } else if (childIds.length === 1 && getAnchor(childIds[0]) && !flags.has('--new')) { + // Single token already anchored — modify existing (handled below by lock/track) + reply(msg, 'Info', 'Token is already anchored. Use lock/unlock/track/untrack to modify, or --new to create a new parent.'); + return; } else { - // No valid anchor ID supplied — auto-create an invisible anchor token. - // Place it at the first child's position. + // Auto-create invisible anchor (single selected or --new flag) const refObj = getObj('graphic', childIds[0]); const newToken = createAnchorToken(refObj); if (!newToken) { @@ -1572,20 +1624,62 @@ var Anchor = Anchor || (() => { // Remove if (flags.has('remove')) { - childIds.forEach(id => setAnchor(id, undefined)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent) { + // Both — require disambiguation + if (flags.has('--up')) { + setAnchor(id, undefined); + } else if (flags.has('--down')) { + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); + children.forEach(cid => setAnchor(cid, undefined)); + } else { + reply(msg, 'Error', 'Token is both parent and child. Use --up (remove from parent) or --down (unanchor children).'); + return; + } + } else if (isParent) { + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); + children.forEach(cid => setAnchor(cid, undefined)); + } else { + setAnchor(id, undefined); + } + }); } // Center if (flags.has('center')) { childIds.forEach(id => { - const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; - if (!info) return; - if ('left' in info) info.left = 0; - if ('top' in info) info.top = 0; - if ('rotation' in info) info.rotation = 0; - if ('widthRatio' in info) info.widthRatio = 1; - if ('heightRatio' in info) info.heightRatio = 1; - applyAnchorToChild(id); + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (center on parent) or --down (center children on self).'); + return; + } + + const centerChild = (cid) => { + const info = s.anchorInfoByChildId[cid]; + if (!info) return; + if ('left' in info) info.left = 0; + if ('top' in info) info.top = 0; + if ('rotation' in info) info.rotation = 0; + if ('widthRatio' in info) info.widthRatio = 1; + if ('heightRatio' in info) info.heightRatio = 1; + applyAnchorToChild(cid); + }; + + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } else if (flags.has('--up') && isChild) { + centerChild(id); + } else if (isChild) { + centerChild(id); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } }); } @@ -1598,10 +1692,44 @@ var Anchor = Anchor || (() => { // With no component flags: lock/unlock ALL components (tracked + pre-lock). if (flags.has('unlock')) { const unlockComps = resolveComponentsOrNone(flags); - childIds.forEach(id => unlockComponents(id, unlockComps)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (unlock own parent link) or --down (unlock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } else if (flags.has('--up') && isChild) { + unlockComponents(id, unlockComps); + } else if (isChild) { + unlockComponents(id, unlockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } + }); } else if (flags.has('lock')) { const lockComps = resolveComponentsOrNone(flags); - childIds.forEach(id => lockComponents(id, lockComps)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (lock own parent link) or --down (lock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } else if (flags.has('--up') && isChild) { + lockComponents(id, lockComps); + } else if (isChild) { + lockComponents(id, lockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } + }); } // Track / untrack / retrack — modify which components are tracked @@ -1609,19 +1737,44 @@ var Anchor = Anchor || (() => { if (flags.has('track')) { const comps = resolveComponents(flags); childIds.forEach(id => { - if (!(id in state[SCRIPT_NAME].anchorInfoByChildId)) { - reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + addTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); return; + } else if (isChild) { + addTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); + } else { + reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); } - addTrackedComponents(id, comps); }); } if (flags.has('untrack')) { const comps = resolveComponents(flags); childIds.forEach(id => { - if (!(id in state[SCRIPT_NAME].anchorInfoByChildId)) return; - removeTrackedComponents(id, comps); + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + removeTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); + return; + } else if (isChild) { + removeTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); + } }); } @@ -2152,29 +2305,56 @@ var Anchor = Anchor || (() => { } let html = `

${SCRIPT_NAME} v${SCRIPT_VERSION}

`; html += `

Attach child tokens to an anchor token so they automatically follow its position, rotation, scale, and flip. Layer and z-order tracking are opt-in via flags.

`; + html += `

Quick Start

`; + html += `

Select a parent token and one or more children, then run !anchor. The first selected token becomes the parent — the rest follow it.

`; + html += `

To create an invisible anchor instead (children move together with no visible parent): !anchor --new

`; html += `

Commands

`; html += `
    `; - html += `
  • !anchor [anchor_id] [flags] — Anchor selected tokens
  • `; - html += `
  • !anchor remove — Remove anchor relationship
  • `; - html += `
  • !anchor lock [flags] — Lock components
  • `; + html += `
  • !anchor [anchor_id] [flags] [--new] [--persist] — Anchor selected tokens. First selected = parent, rest = children. --new forces auto-create. --persist keeps auto-created anchors when childless.
  • `; + html += `
  • !anchor remove [--up] [--down] — Remove relationships. Parent selected = unanchor children. Child selected = detach from parent.
  • `; + html += `
  • !anchor lock [flags] — Lock components (manual moves are undone)
  • `; html += `
  • !anchor unlock [flags] — Unlock components
  • `; - html += `
  • !anchor track [flags] — Add component tracking
  • `; - html += `
  • !anchor untrack [flags] — Remove component tracking
  • `; - html += `
  • !anchor retrack [flags] — Replace tracked set
  • `; - html += `
  • !anchor center — Snap children to anchor center
  • `; + html += `
  • !anchor track [flags] [--down] — Add component tracking. --down applies to children.
  • `; + html += `
  • !anchor untrack [flags] [--down] — Remove component tracking. --down applies to children.
  • `; + html += `
  • !anchor retrack [flags] — Replace tracked set entirely
  • `; + html += `
  • !anchor center [--up] [--down] — Snap to center. --up centers on parent, --down centers children on self.
  • `; html += `
  • !anchor update — Force immediate sync
  • `; html += `
  • !anchor info — Show anchor state
  • `; - html += `
  • !anchor chain — Mutually anchor tokens in a ring
  • `; + html += `
  • !anchor chain [flags] — Mutually anchor tokens in a ring (A→B→C→A)
  • `; html += `
  • !anchor unchain — Dissolve a chain ring from any member
  • `; html += `
  • !anchor config [key] [value] — Configuration
  • `; - html += `
  • !anchor --help — Command reference
  • `; - html += `
  • !anchor gen-dev-docs — Generate scripting API handout
  • `; + html += `
  • !anchor --help — Command reference in chat
  • `; + html += `
`; + html += `

Modifiers

`; + html += `
    `; + html += `
  • --new — Force auto-create invisible anchor (all selected become children)
  • `; + html += `
  • --persist — Keep auto-created anchor even when childless
  • `; + html += `
  • --up — Operate on own parent relationship
  • `; + html += `
  • --down — Operate on children's relationships
  • `; + html += `
  • --ignore-selected — Skip current selection (use explicit IDs only)
  • `; html += `
`; + html += `

When a token is both parent and child, --up/--down is required to disambiguate.

`; html += `

Component Flags

`; - html += `

-all (everything), -pos (x+y), -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z

`; + html += `

-all (everything incl. layer/z), -pos (x+y), -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z

`; + html += `

Default (no flags): position + rotation + scale + flip. Use -all to include layer and z-order.

`; hh.set('notes', html); })(); + // State migration: normalize pixel offsets to anchor-size-relative (v2.2.1) + (() => { + const s = state[SCRIPT_NAME]; + if (s.schemaVersion >= 2.21) return; + Object.entries(s.anchorInfoByChildId || {}).forEach(([childId, info]) => { + const anchor = getObj('graphic', info.anchor_id); + if (!anchor) return; + const aW = anchor.get('width'); + const aH = anchor.get('height'); + if ('left' in info && aW > 0) info.left = info.left / aW; + if ('top' in info && aH > 0) info.top = info.top / aH; + }); + s.schemaVersion = 2.21; + })(); + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); }; diff --git a/Anchor/script.json b/Anchor/script.json index bac868e416..656c3ae038 100644 --- a/Anchor/script.json +++ b/Anchor/script.json @@ -1,8 +1,8 @@ { "name": "Anchor", "script": "anchor.js", - "version": "2.1.0", - "previousversions": [ + "version": "2.2.1", + "previousversions": ["2.1.0", "1.0.0" ], "description": "Attach child tokens to an anchor token so they automatically follow its position, rotation, scale, layer, and flip. When the anchor moves, rotates, scales, or flips, all anchored children update to match their stored relative transform. Chains are supported: a child can itself be an anchor to grandchildren.\n\nAuto-creates an invisible anchor token when no anchor ID is supplied. The auto-created token is destroyed when its last child is removed.\n\n**Setup:** Upload a small transparent PNG to your Roll20 image library and set its thumb URL via !anchor config default-anchor-imgsrc, or on the API Scripts page. Without this, auto-created anchor tokens will not be visible or selectable.\n\n**Commands**\n\n\t!anchor [anchor_id] [flags] [ignore-selected] [child_id...]\n\tAnchor tokens. Component flags (long/short): anchor-all/-all, anchor/-default, anchor-position/-pos, anchor-x/-x, anchor-y/-y, anchor-rotation/-rot, anchor-scale/-scale, anchor-width/-w, anchor-height/-h, anchor-layer/-layer, anchor-flip/-flip, anchor-flipv/-flipv, anchor-fliph/-fliph, anchor-z/-z\n\n\t!anchor remove -- Remove anchor relationship\n\n\t!anchor lock [flags] -- Lock components (re-enforced every poll tick). No flags = lock all.\n\t!anchor unlock [flags] -- Unlock components. No flags = unlock all.\n\n\t!anchor track [flags] -- Add component tracking to existing relationship.\n\t!anchor untrack [flags] -- Remove component tracking.\n\t!anchor retrack [flags] -- Replace tracked set. No flags = default set.\n\n\t!anchor center -- Snap to anchor centre (offset 0,0, rotation 0, scale 1:1).\n\t!anchor update -- Force immediate transform sync.\n\t!anchor info -- Show anchor state (tracked components, lock status).\n\t!anchor config [key value] [reset] -- View or change configuration.\n\t!anchor --help -- Show command reference.\n\n**Scripting API** (for use by other scripts after ready):\n\tAnchor.getAnchor(id), Anchor.getChildren(id), Anchor.anchorObj(childId, anchorId, components)\n\tAnchor.createAnchorFor(obj, components, persist) -- auto-create anchor token programmatically\n\tAnchor.removeAnchor(id), Anchor.updateObj(obj), Anchor.updateZOrder(obj)\n\tAnchor.getPosition/setPosition, getRotation/setRotation, getScale/setScale\n\n**Dependencies:** MatrixMath", diff --git a/Gaslight/1.1.0/Gaslight.js b/Gaslight/1.1.0/Gaslight.js new file mode 100644 index 0000000000..d023044c71 --- /dev/null +++ b/Gaslight/1.1.0/Gaslight.js @@ -0,0 +1,1825 @@ +// ============================================================================= +// Gaslight v1.1.0 +// Last Updated: 2026-06-25 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor and Mirror. Each player can see +// different things while token movement stays consistent across all copies. +// Commands auto-relay to all player pages transparently. +// +// Dependencies: Anchor, Mirror, SelectManager +// +// Commands: +// !gaslight setup Quick-configure from duplicates +// !gaslight split [--force] Activate a prepared group +// !gaslight merge [group] Tear down links, return players +// !gaslight test Dry-run linking resolution +// !gaslight link [|new] [ids...] Set gaslight_link on tokens +// !gaslight unlink [ids...|--group ] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight ungroup Remove page from group +// !gaslight stage [players...] Propagate tokens to player pages +// !gaslight view [player|master] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight 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.1.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + + var relaying = new Set(); + + const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body); + }; + + const genId = () => { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + }; + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + activeGroups: {}, + config: { autoCommit: false, relayCommands: [] }, + view: null + }; + } + if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + }; + + // ========================================================================= + // 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. + */ + /** + * Find a matching token on another page by gaslight_link, represents+name, or represents alone. + */ + const findMatchingToken = (sourceToken, targetPageId) => { + // By gaslight_link + var linkId = getLinkId(sourceToken); + if (linkId) { + var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + var match = targets.find(function(t) { return getLinkId(t) === linkId; }); + if (match) return match; + } + // By represents + name + var charId = sourceToken.get('represents'); + if (charId) { + var name = sourceToken.get('name'); + var byName = findObjs({ _type: 'graphic', _pageid: targetPageId, represents: charId, _subtype: 'token' }); + if (name) byName = byName.filter(function(t) { return t.get('name') === name; }); + if (byName.length === 1) return byName[0]; + } + return null; + }; + + /** + * Stage a single token to target pages using 3-step logic. + * Returns number of clones created. + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPageIds.forEach(function(pageId) { + var targets = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPageIds; + } + + var cloned = 0; + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + var newToken = createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + if (newToken) setLinkId(newToken, getLinkId(token)); + cloned++; + }); + return cloned; + }; + + const autoPopulateLinkId = (token) => { + if (getLinkId(token)) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr && attr.get('current')) { + setLinkId(token, attr.get('current')); + } + }; + + /** + * Read the gaslight_sync character attribute. + * Returns: + * null — attribute absent (default: sync all non-spatial) + * '' — attribute present but empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (charId) => { + if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (!attr) return null; + var val = attr.get('current'); + if (val === undefined || val === null) return null; + val = val.trim(); + if (val === '') return ''; + // Parse comma-separated props, resolve groups + // Prefix with ! to exclude (e.g. "!anchor" = everything except anchor props) + var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var includes = []; + var excludes = []; + parts.forEach(function(p) { + var isExclude = p.startsWith('!'); + var name = isExclude ? p.slice(1) : p; + var expanded; + if (name === 'base' || name === 'anchor') { + expanded = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[name]) { + expanded = Mirror.PROP_GROUPS[name]; + } else { + expanded = [name]; + } + if (isExclude) excludes = excludes.concat(expanded); + else includes = includes.concat(expanded); + }); + // If only excludes specified, start from all known props and subtract + var resolved; + if (includes.length === 0 && excludes.length > 0) { + var allProps = typeof Mirror !== 'undefined' ? Mirror.getKnownProps() : + ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'statusmarkers', 'tint_color', 'name', 'light_radius', 'light_dimradius', 'baseOpacity', 'currentSide']; + resolved = allProps.filter(function(p) { return excludes.indexOf(p) === -1; }); + } else { + resolved = includes.filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return resolved.filter(function(p, i) { return resolved.indexOf(p) === i; }); // dedupe + }; + + // ========================================================================= + // Token Linking Resolution + // ========================================================================= + + /** + * Resolve links from sourcePageId to targetPageId. + * Returns array of { source, target, step } objects. + * Unmatched sources returned as { source, target: null, step: 'unlinked' }. + */ + const resolveLinks = (sourcePageId, targetPageId) => { + const sourceTokens = findObjs({ _type: 'graphic', _pageid: sourcePageId, _subtype: 'token' }); + const targetTokens = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + const results = []; + const matchedTargets = new Set(); + + // Step 1: gaslight_link in GM notes + sourceTokens.forEach(src => { + const linkId = getLinkId(src); + if (!linkId) return; + const match = targetTokens.find(t => !matchedTargets.has(t.get('id')) && getLinkId(t) === linkId); + if (match) { + results.push({ source: src, target: match, step: 1 }); + matchedTargets.add(match.get('id')); + } + }); + + const unmatchedSources = sourceTokens.filter(s => + !results.some(r => r.source.get('id') === s.get('id')) + ); + + // Step 2: represents + name + const step2Sources = unmatchedSources.filter(s => s.get('represents')); + step2Sources.forEach(src => { + const charId = src.get('represents'); + const name = src.get('name'); + // Check uniqueness on source page + const samePairOnSource = sourceTokens.filter(t => + t.get('represents') === charId && t.get('name') === name && + !results.some(r => r.source.get('id') === t.get('id')) + ); + if (samePairOnSource.length !== 1) return; // ambiguous on source page + + const candidates = targetTokens.filter(t => + !matchedTargets.has(t.get('id')) && + t.get('represents') === charId && t.get('name') === name + ); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 2 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 3: represents + fingerprint + const unmatchedAfter2 = unmatchedSources.filter(s => + s.get('represents') && !results.some(r => r.source.get('id') === s.get('id')) + ); + const FINGERPRINT_PROPS = ['represents', 'left', 'top', 'width', 'height', 'rotation', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max']; + + unmatchedAfter2.forEach(src => { + const srcFP = FINGERPRINT_PROPS.map(p => String(src.get(p))); + const candidates = targetTokens.filter(t => { + if (matchedTargets.has(t.get('id'))) return false; + const tFP = FINGERPRINT_PROPS.map(p => String(t.get(p))); + return srcFP.every((v, i) => v === tFP[i]); + }); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 3 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 4: unlinked — only master-page represents tokens + unmatchedSources.forEach(src => { + if (!results.some(r => r.source.get('id') === src.get('id'))) { + if (src.get('represents')) { + results.push({ source: src, target: null, step: 4 }); + } + } + }); + + return results; + }; + + /** + * Check for warning conditions across all pages in a group. + * Returns array of { message, severity } where severity is 'info'|'warning'|'error'. + */ + const checkWarnings = (groupInfo) => { + const warnings = []; + const allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + + // Collect all gaslight_link IDs and their page locations + const linkIdPages = {}; // linkId → Set of pageIds + const linkIdDupes = {}; // pageId → Set of linkIds that appear more than once + allPageIds.forEach(function(pid) { + var tokens = findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }); + var seenOnPage = {}; + tokens.forEach(function(t) { + var lid = getLinkId(t); + if (!lid) return; + if (!linkIdPages[lid]) linkIdPages[lid] = new Set(); + linkIdPages[lid].add(pid); + // Check for duplicates on same page + if (seenOnPage[lid]) { + if (!linkIdDupes[pid]) linkIdDupes[pid] = new Set(); + linkIdDupes[pid].add(lid); + } + seenOnPage[lid] = true; + }); + }); + + // Error: duplicate gaslight_link on same page + Object.entries(linkIdDupes).forEach(function(entry) { + var pid = entry[0], dupes = entry[1]; + var page = getObj('page', pid); + var pageName = page ? page.get('name') : pid; + dupes.forEach(function(lid) { + warnings.push({ message: 'Duplicate gaslight_link "' + lid + '" on page "' + pageName + '"', severity: 'error' }); + }); + }); + + // Info/Warning: gaslight_link missing from pages + Object.entries(linkIdPages).forEach(function(entry) { + var lid = entry[0], pages = entry[1]; + if (pages.size === 1) { + warnings.push({ message: 'gaslight_link "' + lid + '" exists on only 1 page (likely mistake)', severity: 'warning' }); + } else if (pages.size < allPageIds.length) { + warnings.push({ message: 'gaslight_link "' + lid + '" missing from some pages', severity: 'info' }); + } + }); + + return warnings; + }; + + const formatWarnings = (warnings) => { + if (warnings.length === 0) return ''; + var out = '
Warnings:
'; + warnings.forEach(function(w) { + var icon = w.severity === 'error' ? '🔴' : w.severity === 'warning' ? '🟡' : 'ℹ️'; + out += icon + ' ' + w.message + '
'; + }); + return out; + }; + + // ========================================================================= + // Anchor Integration + // ========================================================================= + + const countControllersInGroup = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return 0; + const character = getObj('character', charId); + if (!character) return 0; + const controlledBy = character.get('controlledby') || ''; + if (controlledBy === 'all') return Object.keys(groupInfo.players).length; + const controllerIds = controlledBy.split(',').filter(Boolean); + const groupPlayerIds = new Set(Object.keys(groupInfo.players)); + return controllerIds.filter(id => groupPlayerIds.has(id)).length; + }; + + const getControllingPlayerName = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return null; + const character = getObj('character', charId); + if (!character) return null; + const controlledBy = character.get('controlledby') || ''; + if (!controlledBy) return null; + if (controlledBy === 'all') { + // All players control it — return first group player as representative + var firstPlayer = Object.keys(groupInfo.players)[0]; + return firstPlayer || null; + } + const controllerIds = controlledBy.split(',').filter(Boolean); + for (var i = 0; i < controllerIds.length; i++) { + if (groupInfo.players[controllerIds[i]]) return controllerIds[i]; + } + return null; + }; + + const stripSight = (token) => { + token.set({ has_bright_light_vision: false, has_night_vision: false, light_hassight: false }); + }; + + /** + * Set up Anchor links based on resolved token pairs. + * Also writes gaslight_link IDs to token GM notes for any pair matched + * via steps 2-3, so re-split/restart will catch them via step 1. + */ + const establishLinks = (groupName, groupInfo, allLinks) => { + const s = state[SCRIPT_NAME]; + if (!s.activeGroups[groupName]) { + s.activeGroups[groupName] = { + masterPageId: groupInfo.master, + playerPages: groupInfo.players, + linkedTokens: {} + }; + } + const active = s.activeGroups[groupName]; + + if (typeof Anchor === 'undefined') { + log(SCRIPT_NAME + ': ERROR \u2014 Anchor not loaded. Cannot establish links.'); + return; + } + + // Group all link results by gaslight_link ID + var linkGroups = {}; // linkId -> { id: tokenObj } + allLinks.forEach(function(link) { + if (!link.target) return; + var src = link.source; + var tgt = link.target; + + // Ensure both have a gaslight_link ID + var existingId = getLinkId(src) || getLinkId(tgt); + var linkId = existingId || genId(); + if (!getLinkId(src)) setLinkId(src, linkId); + if (!getLinkId(tgt)) setLinkId(tgt, linkId); + + if (!linkGroups[linkId]) linkGroups[linkId] = {}; + linkGroups[linkId][src.get('id')] = src; + linkGroups[linkId][tgt.get('id')] = tgt; + }); + + // For each link group, determine anchoring strategy + Object.values(linkGroups).forEach(function(tokenMap) { + var tokens = Object.values(tokenMap); + if (tokens.length < 2) return; + + // Find all controlling player IDs in the group for this token + var controllerIds = []; + // Check the character's controlledby — use first token's character as representative + var repCharId = null; + for (var i = 0; i < tokens.length; i++) { + if (tokens[i].get('represents')) { repCharId = tokens[i].get('represents'); break; } + } + if (repCharId) { + var repChar = getObj('character', repCharId); + if (repChar) { + var cb = repChar.get('controlledby') || ''; + if (cb === 'all') { + controllerIds = Object.keys(groupInfo.players); + } else { + var cbIds = cb.split(',').filter(Boolean); + controllerIds = cbIds.filter(function(id) { return !!groupInfo.players[id]; }); + } + } + } + + var ids = tokens.map(function(t) { return t.get('id'); }); + + // Check gaslight_sync attribute + var syncProps = getGaslightSync(repCharId); + // syncProps: null = default (base spatial), '' = no sync at all, array = specific + + // If empty string, skip all linking for this group + if (syncProps === '') return; + + // Determine which props go to Anchor vs Mirror + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var needsAnchor = true; + var anchorComponents = null; // null = use Anchor defaults + var mirrorProps = null; // null = all non-anchor + if (Array.isArray(syncProps)) { + var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0; + // Pass specific components to Anchor if not the full default set + if (needsAnchor) { + anchorComponents = {}; + anchorRequested.forEach(function(p) { anchorComponents[p] = true; }); + } + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; + } + + // Set up Anchor links (spatial sync) + if (needsAnchor) { + if (controllerIds.length === 0) { + // NPC: master is parent, all others are children + var parent = tokens.find(function(t) { return t.get('_pageid') === groupInfo.master; }); + if (!parent) parent = tokens[0]; + tokens.forEach(function(t) { + if (t.get('id') === parent.get('id')) return; + Anchor.anchorObj(t.get('id'), parent.get('id'), anchorComponents); + }); + } else { + // Player-controlled: chain-link master + controlling players' pages + var chainPageIds = [groupInfo.master]; + controllerIds.forEach(function(pid) { + if (groupInfo.players[pid]) chainPageIds.push(groupInfo.players[pid].pageId); + }); + + var chainTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) !== -1; }); + var childTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) === -1; }); + + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds, anchorComponents); + } + + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id'), anchorComponents); + }); + } + } + } + + // Strip sight: only controlling players' pages keep sight + tokens.forEach(function(t) { + var pageId = t.get('_pageid'); + if (controllerIds.length > 0) { + // Keep sight only on pages belonging to controlling players + var isControllerPage = controllerIds.some(function(pid) { + return groupInfo.players[pid] && groupInfo.players[pid].pageId === pageId; + }); + if (!isControllerPage) stripSight(t); + } else { + // NPC: strip sight from children (not master) + if (pageId !== groupInfo.master) stripSight(t); + } + }); + + // Set up Mirror chain for non-spatial property sync + if (typeof Mirror !== 'undefined' && mirrorProps !== false) { + if (mirrorProps === null) { + // Default: sync all minus whatever Anchor is handling + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + Mirror.chainLink(ids, null, mirrorExcludes); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props + Mirror.chainLink(ids, mirrorProps); + } + } + + // Track links for merge teardown + ids.forEach(function(id) { + if (!active.linkedTokens[id]) active.linkedTokens[id] = []; + }); + ids.forEach(function(id) { + ids.forEach(function(otherId) { + if (id !== otherId) active.linkedTokens[id].push(otherId); + }); + }); + }); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + /** + * Quick setup: auto-configure a group from duplicate pages. + * !gaslight setup [--selected | player1 player2 ...] + * Expects N+1 pages with the same name (or name prefix). Assigns master + players. + */ + const doSetup = (msg, args) => { + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + var groupName = args.shift(); + + // Determine players: selected tokens + named args, fallback to party tags + var playerIds = []; + + // From selected tokens + if (msg.selected && msg.selected.length > 0) { + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (!obj) return; + var charId = obj.get('represents'); + if (!charId) return; + var character = getObj('character', charId); + if (!character) return; + var cb = character.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + // From named args + args.forEach(function(name) { + var resolved = resolvePlayer(msg, name, CMD + ' setup ' + groupName); + if (resolved && resolved !== 'ambiguous' && resolved.id !== 'GM') { + if (playerIds.indexOf(resolved.id) === -1) playerIds.push(resolved.id); + } + }); + + // Fallback: party-tagged characters (only if no selected and no args) + if (playerIds.length === 0) { + var characters = findObjs({ _type: 'character' }); + characters.forEach(function(c) { + var tags = c.get('tags') || ''; + if (!tags.toLowerCase().includes('party')) return; + var cb = c.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + if (playerIds.length === 0) { reply(msg, 'Error', 'No players found. Use --selected, provide names, or tag party characters.'); return; } + + // Find the master page (where selected token is, or current player page) + var masterPageId = resolvePageId(msg, []); + var masterPage = getObj('page', masterPageId); + if (!masterPage) { reply(msg, 'Error', 'Could not determine master page. Select a token on the master page.'); return; } + var masterName = masterPage.get('name'); + + // Find candidate pages: same base name (strip recursive "Copy of " prefixes), or already has this group's config + var allPages = findObjs({ _type: 'page' }); + var stripCopyOf = function(name) { + while (name.indexOf('Copy of ') === 0) name = name.slice(8); + return name; + }; + var candidates = allPages.filter(function(p) { + var name = stripCopyOf(p.get('name')); + if (name === masterName) return true; + // Check if page already has config for this group + var cfg = getGroupConfigOnPage(p.get('_id'), groupName); + if (cfg) return true; + return false; + }); + + // We need N+1 pages (1 master + N players) + var needed = playerIds.length + 1; + if (candidates.length < needed) { + reply(msg, 'Error', 'Found ' + candidates.length + ' page(s) named "' + masterName + '..." but need ' + needed + ' (1 master + ' + playerIds.length + ' players). Duplicate the page ' + (needed - candidates.length) + ' more time(s).'); + return; + } + + // Assign: first candidate = master, rest = players (arbitrary order) + var masterCandidate = candidates.find(function(p) { return p.get('_id') === masterPageId; }) || candidates[0]; + var playerCandidates = candidates.filter(function(p) { return p.get('_id') !== masterCandidate.get('_id'); }).slice(0, playerIds.length); + + // Rename and configure + masterCandidate.set('name', masterName + ' (master)'); + setConfigOnPage(masterCandidate.get('_id'), groupName, { player: 'GM' }); + + var assignments = []; + playerIds.forEach(function(pid, i) { + var page = playerCandidates[i]; + var player = getObj('player', pid); + var playerName = player ? player.get('_displayname') : pid; + page.set('name', masterName + ' (' + playerName + ')'); + setConfigOnPage(page.get('_id'), groupName, { player: playerName, playerid: pid }); + assignments.push(playerName + ' → ' + page.get('name')); + }); + + var out = 'Group "' + groupName + '" set up:
'; + out += 'Master: ' + masterCandidate.get('name') + '
'; + out += assignments.join('
'); + out += '

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

' + + '' + CMD + ' 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 'setup': doSetup(msg, args); break; + case 'split': doSplit(msg, args); break; + case 'merge': doMerge(msg, args); break; + case 'test': doTest(msg, args); break; + case 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'relay': doRelay(msg, args); break; + case 'view': doView(msg, args); break; + case 'stage': doStage(msg, args); break; + case 'config': doConfig(msg, args); break; + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const HANDOUT_NAME = 'Help: Gaslight'; + const HANDOUT_AVATAR = 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385'; + + const createHelpHandout = () => { + var existing = findObjs({ type: 'handout', name: HANDOUT_NAME }); + var h = existing.length > 0 ? existing[0] : createObj('handout', { name: HANDOUT_NAME, avatar: HANDOUT_AVATAR }); + if (HANDOUT_AVATAR) h.set('avatar', HANDOUT_AVATAR); + h.set('notes', [ + '

Gaslight v' + SCRIPT_VERSION + '

', + '

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

', + '

Quick Start

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

Commands

', + '

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

', + '

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

', + '

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

', + '

!gaslight test <group> — Dry-run linking

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

!gaslight status — Show state

', + '

Auto-Relay

', + '

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

', + '

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

', + '

Selective Relay

', + '

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

', + '

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

', + '

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

', + '

Token Linking

', + '

Tokens are linked across pages automatically by:

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

Sync Control

', + '

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

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

Staging

', + '

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

', + '

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

', + ].join('')); + }; + + const checkInstall = () => { + ensureState(); + createHelpHandout(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + /** + * When a linked token is deleted, delete its counterparts on other pages. + */ + var destroying = false; + const onTokenDestroyed = (obj) => { + if (destroying) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find if this token is tracked in any active group + var linkedIds = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + // Clean up tracking + delete active.linkedTokens[tokenId]; + linkedIds.forEach(function(id) { + if (active.linkedTokens[id]) { + active.linkedTokens[id] = active.linkedTokens[id].filter(function(lid) { return lid !== tokenId; }); + } + }); + } else { + // Check if it's in someone else's list + Object.entries(active.linkedTokens).forEach(function(entry) { + var idx = entry[1].indexOf(tokenId); + if (idx !== -1) { + entry[1].splice(idx, 1); + if (!linkedIds) linkedIds = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + + // Remove Anchor/Mirror links and delete counterparts + destroying = true; + linkedIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + if (typeof Mirror !== 'undefined') Mirror.unlink([id]); + var target = getObj('graphic', id); + if (target) target.remove(); + }); + destroying = false; + }; + + /** + * Universal relay interceptor. Automatically relays commands to linked tokens: + * - If selected tokens or IDs in command reference master-page linked tokens + * AND no player-page tokens are selected/referenced → relay to all player pages. + * - If player-page tokens are involved → only relay if command is in relayCommands. + */ + const viewInterceptor = (msg) => { + if (msg.type !== 'api') return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var content = msg.content.trim(); + if (!content) return; + var firstWord = content.split(' ')[0]; + if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; + + // Check relaying set to prevent loops + var selectedIds = (msg.selected || []).map(function(sel) { return sel._id; }); + var key = relayKey(content, 'player|' + msg.playerid, selectedIds); + if (relaying.delete(key)) return; + if (content.indexOf('{& select') !== -1) return; + + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + + // Scan command for token IDs that belong to linked groups + var idRx = /-[A-Za-z0-9_-]{19}/g; + var idsInCommand = (content.match(idRx) || []).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); + + // Classify: which IDs/tokens are on master pages vs player pages? + var masterTokens = []; + var hasPlayerPageRef = false; + var activeEntry = null; + + // Check selected tokens + tokens.forEach(function(t) { + var pid = t.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + masterTokens.push(t); + if (!activeEntry) activeEntry = entry; + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + // Check IDs in command text + idsInCommand.forEach(function(id) { + // Skip IDs already accounted for by selection + if (tokens.some(function(t) { return t.get('id') === id; })) return; + var obj = getObj('graphic', id); + if (!obj) return; + var pid = obj.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + // Check if this token is actually linked + var linked = entry[1].linkedTokens[id] || []; + var isLinked = linked.length > 0 || Object.values(entry[1].linkedTokens).some(function(arr) { return arr.indexOf(id) !== -1; }); + if (isLinked) { + masterTokens.push(obj); + if (!activeEntry) activeEntry = entry; + } + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + if (masterTokens.length === 0 && !hasPlayerPageRef) return; + + // Universal relay: master-page refs, no player-page refs + if (masterTokens.length > 0 && !hasPlayerPageRef) { + var viewPlayerId = s.view; + var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); + executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); + return; + } + + // Player-page involved: only relay if relayCommands allows it + if (hasPlayerPageRef && s.config.relayCommands.indexOf(firstWord) !== -1) { + // Find source player page + var sourcePlayerId = null; + var entry = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + var srcToken = tokens.find(function(t) { return t.get('_pageid') === pp[1].pageId; }); + if (srcToken) { entry = e; sourcePlayerId = pp[0]; } + }); + }); + if (!entry) return; + var targetPlayerIds = Object.keys(entry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, content, targetPlayerIds, true); + } + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('chat:message', viewInterceptor); + on('add:graphic', onTokenAdded); + on('destroy:graphic', onTokenDestroyed); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/2.0.0/Gaslight.js b/Gaslight/2.0.0/Gaslight.js new file mode 100644 index 0000000000..63648f7284 --- /dev/null +++ b/Gaslight/2.0.0/Gaslight.js @@ -0,0 +1,2868 @@ +// ============================================================================= +// Gaslight v2.0.0 +// Last Updated: 2026-06-25 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor and Mirror. Each player can see +// different things while token movement stays consistent across all copies. +// Commands auto-relay to all player pages transparently. +// +// Dependencies: Anchor, Mirror, SelectManager, RollCapture (optional) +// +// Commands: +// !gaslight setup Quick-configure from duplicates +// !gaslight split [--force] Activate a prepared group +// !gaslight merge [group] Tear down links, return players +// !gaslight test Dry-run linking resolution +// !gaslight link [|new] [ids...] Set gaslight_link on tokens +// !gaslight unlink [ids...|--group ] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight ungroup Remove page from group +// !gaslight stage [players...] Propagate tokens to player pages +// !gaslight view [player|master] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight eval [--dry-run] [--all|] Evaluate script pins +// !gaslight status Show current state +// !gaslight --help Command reference +// ============================================================================= + +/* global on, sendChat, getObj, findObjs, createObj, Campaign, playerIsGM, log, state, generateUUID */ + +var Gaslight = Gaslight || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Gaslight'; + const SCRIPT_VERSION = '2.0.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + const GLS_TAG = '[GLS]'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const stripGlsTag = (name) => { + return (name || '').replace(/^\[GLS\]\s*/i, '').trim(); + }; + + var relaying = new Set(); + var scripting = false; + + const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body); + }; + + const genId = () => { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + }; + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + activeGroups: {}, + config: { autoCommit: false, relayCommands: [] }, + view: null + }; + } + if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + }; + + // ========================================================================= + // Config Storage — GM layer text objects + // ========================================================================= + + const getConfigsOnPage = (pageId) => { + const texts = findObjs({ _type: 'text', _pageid: pageId, layer: 'gmlayer' }); + const configs = []; + texts.forEach(t => { + const content = t.get('text') || ''; + if (!content.startsWith(CONFIG_HEADER)) return; + const data = parseConfig(content); + if (data) configs.push({ obj: t, data: data }); + }); + return configs; + }; + + const getGroupConfigOnPage = (pageId, groupName) => { + return getConfigsOnPage(pageId).find(c => c.data.group === groupName); + }; + + const parseConfig = (text) => { + const lines = text.split('\n').filter(l => l.trim() && l.trim() !== CONFIG_HEADER); + const data = {}; + lines.forEach(line => { + const idx = line.indexOf(':'); + if (idx === -1) return; + data[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, ''); + }); + return data.group ? data : null; + }; + + const serializeConfig = (data) => { + let text = CONFIG_HEADER + '\n'; + Object.entries(data).forEach(([key, val]) => { + if (val !== undefined && val !== '') text += key + ': ' + val + '\n'; + }); + return text.trim(); + }; + + const setConfigOnPage = (pageId, groupName, data) => { + const existing = getGroupConfigOnPage(pageId, groupName); + const fullData = Object.assign({ group: groupName }, data); + const text = serializeConfig(fullData); + if (existing) { + existing.obj.set('text', text); + } else { + createObj('text', { + pageid: pageId, + layer: 'gmlayer', + text: text, + left: 70, + top: 70, + font_size: 26, + font_family: 'Arial', + color: '#FFA500' + }); + } + }; + + // ========================================================================= + // Group Discovery + // ========================================================================= + + const discoverGroup = (groupName) => { + const pages = findObjs({ _type: 'page' }); + const result = { master: null, players: {} }; // players keyed by playerid → { pageId, name } + pages.forEach(page => { + const cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg) return; + if (cfg.data.player === 'GM') result.master = page.get('_id'); + else if (cfg.data.playerid) { + result.players[cfg.data.playerid] = { pageId: page.get('_id'), name: cfg.data.player }; + } + }); + return result; + }; + + // ========================================================================= + // Page Resolution + // ========================================================================= + + const resolvePageId = (msg, args) => { + // Check for --page argument + const pageIdx = args.indexOf('--page'); + if (pageIdx !== -1 && args[pageIdx + 1]) { + const pageName = args.splice(pageIdx, 2)[1]; + const page = findObjs({ _type: 'page', name: pageName })[0]; + if (page) return page.get('_id'); + } + // Fall back to selected token's page + if (msg.selected && msg.selected.length > 0) { + const obj = getObj(msg.selected[0]._type, msg.selected[0]._id); + if (obj) return obj.get('_pageid'); + } + // Last resort: player page + return Campaign().get('playerpageid'); + }; + + // ========================================================================= + // Party Detection + // ========================================================================= + + const getPartyTokens = (msg, masterPageId) => { + if (msg.selected && msg.selected.length > 0) { + return msg.selected.map(s => getObj(s._type, s._id)).filter(Boolean); + } + const characters = findObjs({ _type: 'character' }); + const partyChars = characters.filter(c => { + const tags = c.get('tags') || ''; + return tags.toLowerCase().includes('party'); + }); + if (partyChars.length > 0) { + const tokens = []; + partyChars.forEach(c => { + const t = findObjs({ _type: 'graphic', represents: c.get('_id'), _pageid: masterPageId, _subtype: 'token' }); + tokens.push.apply(tokens, t); + }); + return tokens.length > 0 ? tokens : null; + } + return null; + }; + + // ========================================================================= + // Player Resolution + // ========================================================================= + + const GM_ALIASES = ['gm', 'master']; + + /** + * Resolve a player arg to { id, name } or null. + * If ambiguous, whispers disambiguation buttons and returns 'ambiguous'. + * If GM alias, returns { id: 'GM', name: 'GM' }. + */ + const resolvePlayer = (msg, playerArg, cmdPrefix) => { + if (GM_ALIASES.indexOf(playerArg.toLowerCase()) !== -1) { + return { id: 'GM', name: 'GM' }; + } + + // Check if it's a player ID directly (starts with -) + if (playerArg.startsWith('-')) { + var byId = getObj('player', playerArg); + if (byId) return { id: byId.get('_id'), name: byId.get('_displayname') }; + reply(msg, 'Error', 'No player found with ID: ' + playerArg); + return null; + } + + // Search by display name + var players = findObjs({ _type: 'player' }); + var matches = players.filter(function(p) { + return p.get('_displayname').toLowerCase() === playerArg.toLowerCase(); + }); + + // Deduplicate by player ID (Roll20 can return duplicate player objects) + var uniqueById = {}; + matches.forEach(function(p) { uniqueById[p.get('_id')] = p; }); + matches = Object.values(uniqueById); + + if (matches.length === 1) { + return { id: matches[0].get('_id'), name: matches[0].get('_displayname') }; + } + if (matches.length === 0) { + reply(msg, 'Error', 'No player found named "' + playerArg + '".'); + return null; + } + + // Ambiguous — show disambiguation buttons + var out = 'Multiple players named "' + playerArg + '":
'; + matches.forEach(function(p) { + var chars = findObjs({ _type: 'character' }).filter(function(c) { + return (c.get('controlledby') || '').indexOf(p.get('_id')) !== -1; + }); + var charNames = chars.map(function(c) { return c.get('name'); }).join(', ') || 'no characters'; + out += '[' + p.get('_displayname') + ' (' + charNames + ')](' + cmdPrefix + ' ' + p.get('_id') + ')
'; + }); + reply(msg, 'Disambiguate', out); + return 'ambiguous'; + }; + + /** + * Find a player by name or ID (no disambiguation, used internally). + */ + const findPlayerByNameOrId = (nameOrId) => { + if (nameOrId === 'GM') return null; + if (nameOrId.startsWith('-')) return getObj('player', nameOrId); + var players = findObjs({ _type: 'player' }); + return players.find(function(p) { return p.get('_displayname').toLowerCase() === nameOrId.toLowerCase(); }); + }; + + // ========================================================================= + // Token GM Notes — gaslight_link + // ========================================================================= + + const getLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_link:\s*(\S+)/); + return match ? match[1].trim() : null; + }; + + const setLinkId = (token, linkId) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.match(/gaslight_link:\s*.+/)) { + notes = notes.replace(/gaslight_link:\s*.+/, LINK_KEY + ': ' + linkId); + } else { + notes = (notes ? notes + '\n' : '') + LINK_KEY + ': ' + linkId; + } + token.set('gmnotes', notes); + }; + + const removeLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/\n?gaslight_link:\s*.+/, '').trim(); + token.set('gmnotes', notes); + }; + + /** + * Auto-populate gaslight_link from character attribute if token doesn't already have one. + */ + /** + * Find a matching token on another page by gaslight_link, represents+name, or represents alone. + */ + const findMatchingToken = (sourceToken, targetPageId) => { + // By gaslight_link + var linkId = getLinkId(sourceToken); + if (linkId) { + var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + var match = targets.find(function(t) { return getLinkId(t) === linkId; }); + if (match) return match; + } + // By represents + name + var charId = sourceToken.get('represents'); + if (charId) { + var name = sourceToken.get('name'); + var byName = findObjs({ _type: 'graphic', _pageid: targetPageId, represents: charId, _subtype: 'token' }); + if (name) byName = byName.filter(function(t) { return t.get('name') === name; }); + if (byName.length === 1) return byName[0]; + } + return null; + }; + + /** + * Stage a single token to target pages using 3-step logic. + * Returns number of clones created. + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPageIds.forEach(function(pageId) { + var targets = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPageIds; + } + + var cloned = 0; + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + var newToken = createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + if (newToken) setLinkId(newToken, getLinkId(token)); + cloned++; + }); + return cloned; + }; + + const autoPopulateLinkId = (token) => { + if (getLinkId(token)) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr && attr.get('current')) { + setLinkId(token, attr.get('current')); + } + }; + + /** + * Read the gaslight_sync character attribute. + * Returns: + * null — attribute absent (default: sync all non-spatial) + * '' — attribute present but empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (charId) => { + if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (!attr) return null; + var val = attr.get('current'); + if (val === undefined || val === null) return null; + val = val.trim(); + if (val === '') return ''; + // Parse comma-separated props, resolve groups + // Prefix with ! to exclude (e.g. "!anchor" = everything except anchor props) + var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var includes = []; + var excludes = []; + parts.forEach(function(p) { + var isExclude = p.startsWith('!'); + var name = isExclude ? p.slice(1) : p; + var expanded; + if (name === 'base' || name === 'anchor') { + expanded = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[name]) { + expanded = Mirror.PROP_GROUPS[name]; + } else { + expanded = [name]; + } + if (isExclude) excludes = excludes.concat(expanded); + else includes = includes.concat(expanded); + }); + // If only excludes specified, start from all known props and subtract + var resolved; + if (includes.length === 0 && excludes.length > 0) { + var allProps = typeof Mirror !== 'undefined' ? Mirror.getKnownProps() : + ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'statusmarkers', 'tint_color', 'name', 'light_radius', 'light_dimradius', 'baseOpacity', 'currentSide']; + resolved = allProps.filter(function(p) { return excludes.indexOf(p) === -1; }); + } else { + resolved = includes.filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return resolved.filter(function(p, i) { return resolved.indexOf(p) === i; }); // dedupe + }; + + // ========================================================================= + // Token Linking Resolution + // ========================================================================= + + /** + * Resolve links from sourcePageId to targetPageId. + * Returns array of { source, target, step } objects. + * Unmatched sources returned as { source, target: null, step: 'unlinked' }. + */ + const resolveLinks = (sourcePageId, targetPageId) => { + const sourceTokens = findObjs({ _type: 'graphic', _pageid: sourcePageId, _subtype: 'token' }); + const targetTokens = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + const results = []; + const matchedTargets = new Set(); + + // Step 1: gaslight_link in GM notes + sourceTokens.forEach(src => { + const linkId = getLinkId(src); + if (!linkId) return; + const match = targetTokens.find(t => !matchedTargets.has(t.get('id')) && getLinkId(t) === linkId); + if (match) { + results.push({ source: src, target: match, step: 1 }); + matchedTargets.add(match.get('id')); + } + }); + + const unmatchedSources = sourceTokens.filter(s => + !results.some(r => r.source.get('id') === s.get('id')) + ); + + // Step 2: represents + name + const step2Sources = unmatchedSources.filter(s => s.get('represents')); + step2Sources.forEach(src => { + const charId = src.get('represents'); + const name = src.get('name'); + // Check uniqueness on source page + const samePairOnSource = sourceTokens.filter(t => + t.get('represents') === charId && t.get('name') === name && + !results.some(r => r.source.get('id') === t.get('id')) + ); + if (samePairOnSource.length !== 1) return; // ambiguous on source page + + const candidates = targetTokens.filter(t => + !matchedTargets.has(t.get('id')) && + t.get('represents') === charId && t.get('name') === name + ); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 2 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 3: represents + fingerprint + const unmatchedAfter2 = unmatchedSources.filter(s => + s.get('represents') && !results.some(r => r.source.get('id') === s.get('id')) + ); + const FINGERPRINT_PROPS = ['represents', 'left', 'top', 'width', 'height', 'rotation', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max']; + + unmatchedAfter2.forEach(src => { + const srcFP = FINGERPRINT_PROPS.map(p => String(src.get(p))); + const candidates = targetTokens.filter(t => { + if (matchedTargets.has(t.get('id'))) return false; + const tFP = FINGERPRINT_PROPS.map(p => String(t.get(p))); + return srcFP.every((v, i) => v === tFP[i]); + }); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 3 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 4: unlinked — only master-page represents tokens + unmatchedSources.forEach(src => { + if (!results.some(r => r.source.get('id') === src.get('id'))) { + if (src.get('represents')) { + results.push({ source: src, target: null, step: 4 }); + } + } + }); + + return results; + }; + + /** + * Check for warning conditions across all pages in a group. + * Returns array of { message, severity } where severity is 'info'|'warning'|'error'. + */ + const checkWarnings = (groupInfo) => { + const warnings = []; + const allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + + // Collect all gaslight_link IDs and their page locations + const linkIdPages = {}; // linkId → Set of pageIds + const linkIdDupes = {}; // pageId → Set of linkIds that appear more than once + allPageIds.forEach(function(pid) { + var tokens = findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }); + var seenOnPage = {}; + tokens.forEach(function(t) { + var lid = getLinkId(t); + if (!lid) return; + if (!linkIdPages[lid]) linkIdPages[lid] = new Set(); + linkIdPages[lid].add(pid); + // Check for duplicates on same page + if (seenOnPage[lid]) { + if (!linkIdDupes[pid]) linkIdDupes[pid] = new Set(); + linkIdDupes[pid].add(lid); + } + seenOnPage[lid] = true; + }); + }); + + // Error: duplicate gaslight_link on same page + Object.entries(linkIdDupes).forEach(function(entry) { + var pid = entry[0], dupes = entry[1]; + var page = getObj('page', pid); + var pageName = page ? page.get('name') : pid; + dupes.forEach(function(lid) { + warnings.push({ message: 'Duplicate gaslight_link "' + lid + '" on page "' + pageName + '"', severity: 'error' }); + }); + }); + + // Info/Warning: gaslight_link missing from pages + Object.entries(linkIdPages).forEach(function(entry) { + var lid = entry[0], pages = entry[1]; + if (pages.size === 1) { + warnings.push({ message: 'gaslight_link "' + lid + '" exists on only 1 page (likely mistake)', severity: 'warning' }); + } else if (pages.size < allPageIds.length) { + warnings.push({ message: 'gaslight_link "' + lid + '" missing from some pages', severity: 'info' }); + } + }); + + return warnings; + }; + + const formatWarnings = (warnings) => { + if (warnings.length === 0) return ''; + var out = '
Warnings:
'; + warnings.forEach(function(w) { + var icon = w.severity === 'error' ? '🔴' : w.severity === 'warning' ? '🟡' : 'ℹ️'; + out += icon + ' ' + w.message + '
'; + }); + return out; + }; + + // ========================================================================= + // Anchor Integration + // ========================================================================= + + const countControllersInGroup = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return 0; + const character = getObj('character', charId); + if (!character) return 0; + const controlledBy = character.get('controlledby') || ''; + if (controlledBy === 'all') return Object.keys(groupInfo.players).length; + const controllerIds = controlledBy.split(',').filter(Boolean); + const groupPlayerIds = new Set(Object.keys(groupInfo.players)); + return controllerIds.filter(id => groupPlayerIds.has(id)).length; + }; + + const getControllingPlayerName = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return null; + const character = getObj('character', charId); + if (!character) return null; + const controlledBy = character.get('controlledby') || ''; + if (!controlledBy) return null; + if (controlledBy === 'all') { + // All players control it — return first group player as representative + var firstPlayer = Object.keys(groupInfo.players)[0]; + return firstPlayer || null; + } + const controllerIds = controlledBy.split(',').filter(Boolean); + for (var i = 0; i < controllerIds.length; i++) { + if (groupInfo.players[controllerIds[i]]) return controllerIds[i]; + } + return null; + }; + + const stripSight = (token) => { + token.set({ has_bright_light_vision: false, has_night_vision: false, light_hassight: false }); + }; + + /** + * Set up Anchor links based on resolved token pairs. + * Also writes gaslight_link IDs to token GM notes for any pair matched + * via steps 2-3, so re-split/restart will catch them via step 1. + */ + const establishLinks = (groupName, groupInfo, allLinks) => { + const s = state[SCRIPT_NAME]; + if (!s.activeGroups[groupName]) { + s.activeGroups[groupName] = { + masterPageId: groupInfo.master, + playerPages: groupInfo.players, + linkedTokens: {} + }; + } + const active = s.activeGroups[groupName]; + + if (typeof Anchor === 'undefined') { + log(SCRIPT_NAME + ': ERROR \u2014 Anchor not loaded. Cannot establish links.'); + return; + } + + // Group all link results by gaslight_link ID + var linkGroups = {}; // linkId -> { id: tokenObj } + allLinks.forEach(function(link) { + if (!link.target) return; + var src = link.source; + var tgt = link.target; + + // Ensure both have a gaslight_link ID + var existingId = getLinkId(src) || getLinkId(tgt); + var linkId = existingId || genId(); + if (!getLinkId(src)) setLinkId(src, linkId); + if (!getLinkId(tgt)) setLinkId(tgt, linkId); + + if (!linkGroups[linkId]) linkGroups[linkId] = {}; + linkGroups[linkId][src.get('id')] = src; + linkGroups[linkId][tgt.get('id')] = tgt; + }); + + // For each link group, determine anchoring strategy + Object.values(linkGroups).forEach(function(tokenMap) { + var tokens = Object.values(tokenMap); + if (tokens.length < 2) return; + + // Find all controlling player IDs in the group for this token + var controllerIds = []; + // Check the character's controlledby — use first token's character as representative + var repCharId = null; + for (var i = 0; i < tokens.length; i++) { + if (tokens[i].get('represents')) { repCharId = tokens[i].get('represents'); break; } + } + if (repCharId) { + var repChar = getObj('character', repCharId); + if (repChar) { + var cb = repChar.get('controlledby') || ''; + if (cb === 'all') { + controllerIds = Object.keys(groupInfo.players); + } else { + var cbIds = cb.split(',').filter(Boolean); + controllerIds = cbIds.filter(function(id) { return !!groupInfo.players[id]; }); + } + } + } + + var ids = tokens.map(function(t) { return t.get('id'); }); + + // Check gaslight_sync attribute + var syncProps = getGaslightSync(repCharId); + // syncProps: null = default (base spatial), '' = no sync at all, array = specific + + // If empty string, skip all linking for this group + if (syncProps === '') return; + + // Determine which props go to Anchor vs Mirror + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var needsAnchor = true; + var anchorComponents = null; // null = use Anchor defaults + var mirrorProps = null; // null = all non-anchor + if (Array.isArray(syncProps)) { + var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0; + // Pass specific components to Anchor if not the full default set + if (needsAnchor) { + anchorComponents = {}; + anchorRequested.forEach(function(p) { anchorComponents[p] = true; }); + } + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; + } + + // Set up Anchor links (spatial sync) + if (needsAnchor) { + if (controllerIds.length === 0) { + // NPC: master is parent, all others are children + var parent = tokens.find(function(t) { return t.get('_pageid') === groupInfo.master; }); + if (!parent) parent = tokens[0]; + tokens.forEach(function(t) { + if (t.get('id') === parent.get('id')) return; + Anchor.anchorObj(t.get('id'), parent.get('id'), anchorComponents); + }); + } else { + // Player-controlled: chain-link master + controlling players' pages + var chainPageIds = [groupInfo.master]; + controllerIds.forEach(function(pid) { + if (groupInfo.players[pid]) chainPageIds.push(groupInfo.players[pid].pageId); + }); + + var chainTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) !== -1; }); + var childTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) === -1; }); + + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds, anchorComponents); + } + + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id'), anchorComponents); + }); + } + } + } + + // Strip sight: only controlling players' pages keep sight + tokens.forEach(function(t) { + var pageId = t.get('_pageid'); + if (controllerIds.length > 0) { + // Keep sight only on pages belonging to controlling players + var isControllerPage = controllerIds.some(function(pid) { + return groupInfo.players[pid] && groupInfo.players[pid].pageId === pageId; + }); + if (!isControllerPage) stripSight(t); + } else { + // NPC: strip sight from children (not master) + if (pageId !== groupInfo.master) stripSight(t); + } + }); + + // Set up Mirror chain for non-spatial property sync + if (typeof Mirror !== 'undefined' && mirrorProps !== false) { + if (mirrorProps === null) { + // Default: sync all minus whatever Anchor is handling + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + Mirror.chainLink(ids, null, mirrorExcludes); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props + Mirror.chainLink(ids, mirrorProps); + } + } + + // Track links for merge teardown + ids.forEach(function(id) { + if (!active.linkedTokens[id]) active.linkedTokens[id] = []; + }); + ids.forEach(function(id) { + ids.forEach(function(otherId) { + if (id !== otherId) active.linkedTokens[id].push(otherId); + }); + }); + }); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + /** + * Quick setup: auto-configure a group from duplicate pages. + * !gaslight setup [--selected | player1 player2 ...] + * Expects N+1 pages with the same name (or name prefix). Assigns master + players. + */ + const doSetup = (msg, args) => { + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + var groupName = args.shift(); + + // Determine players: selected tokens + named args, fallback to party tags + var playerIds = []; + + // From selected tokens + if (msg.selected && msg.selected.length > 0) { + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (!obj) return; + var charId = obj.get('represents'); + if (!charId) return; + var character = getObj('character', charId); + if (!character) return; + var cb = character.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + // From named args + args.forEach(function(name) { + var resolved = resolvePlayer(msg, name, CMD + ' setup ' + groupName); + if (resolved && resolved !== 'ambiguous' && resolved.id !== 'GM') { + if (playerIds.indexOf(resolved.id) === -1) playerIds.push(resolved.id); + } + }); + + // Fallback: party-tagged characters (only if no selected and no args) + if (playerIds.length === 0) { + var characters = findObjs({ _type: 'character' }); + characters.forEach(function(c) { + var tags = c.get('tags') || ''; + if (!tags.toLowerCase().includes('party')) return; + var cb = c.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + if (playerIds.length === 0) { reply(msg, 'Error', 'No players found. Use --selected, provide names, or tag party characters.'); return; } + + // Find the master page (where selected token is, or current player page) + var masterPageId = resolvePageId(msg, []); + var masterPage = getObj('page', masterPageId); + if (!masterPage) { reply(msg, 'Error', 'Could not determine master page. Select a token on the master page.'); return; } + var masterName = masterPage.get('name'); + + // Find candidate pages: same base name (strip recursive "Copy of " prefixes), or already has this group's config + var allPages = findObjs({ _type: 'page' }); + var stripCopyOf = function(name) { + while (name.indexOf('Copy of ') === 0) name = name.slice(8); + return name; + }; + var candidates = allPages.filter(function(p) { + var name = stripCopyOf(p.get('name')); + if (name === masterName) return true; + // Check if page already has config for this group + var cfg = getGroupConfigOnPage(p.get('_id'), groupName); + if (cfg) return true; + return false; + }); + + // We need N+1 pages (1 master + N players) + var needed = playerIds.length + 1; + if (candidates.length < needed) { + reply(msg, 'Error', 'Found ' + candidates.length + ' page(s) named "' + masterName + '..." but need ' + needed + ' (1 master + ' + playerIds.length + ' players). Duplicate the page ' + (needed - candidates.length) + ' more time(s).'); + return; + } + + // Assign: first candidate = master, rest = players (arbitrary order) + var masterCandidate = candidates.find(function(p) { return p.get('_id') === masterPageId; }) || candidates[0]; + var playerCandidates = candidates.filter(function(p) { return p.get('_id') !== masterCandidate.get('_id'); }).slice(0, playerIds.length); + + // Rename and configure + masterCandidate.set('name', masterName + ' (master)'); + setConfigOnPage(masterCandidate.get('_id'), groupName, { player: 'GM' }); + + var assignments = []; + playerIds.forEach(function(pid, i) { + var page = playerCandidates[i]; + var player = getObj('player', pid); + var playerName = player ? player.get('_displayname') : pid; + page.set('name', masterName + ' (' + playerName + ')'); + setConfigOnPage(page.get('_id'), groupName, { player: playerName, playerid: pid }); + assignments.push(playerName + ' → ' + page.get('name')); + }); + + var out = 'Group "' + groupName + '" set up:
'; + out += 'Master: ' + masterCandidate.get('name') + '
'; + out += assignments.join('
'); + out += '

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

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

Gaslight v' + SCRIPT_VERSION + '

', + '

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

', + '

Quick Start

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

Commands

', + '

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

', + '

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

', + '

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

', + '

!gaslight test <group> — Dry-run linking

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

!gaslight status — Show state

', + '

Auto-Relay

', + '

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

', + '

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

', + '

Selective Relay

', + '

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

', + '

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

', + '

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

', + '

Token Linking

', + '

Tokens are linked across pages automatically by:

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

Sync Control

', + '

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

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

Staging

', + '

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

', + '

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

', + '

Scripting

', + '

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

', + '

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

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

Script syntax:

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

Variables:

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

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

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

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

', + '

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

', + '

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

', + ].join('')); + }; + + const checkInstall = () => { + ensureState(); + createHelpHandout(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + /** + * When a linked token is deleted, delete its counterparts on other pages. + */ + var destroying = false; + const onTokenDestroyed = (obj) => { + if (destroying) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find if this token is tracked in any active group + var linkedIds = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + // Clean up tracking + delete active.linkedTokens[tokenId]; + linkedIds.forEach(function(id) { + if (active.linkedTokens[id]) { + active.linkedTokens[id] = active.linkedTokens[id].filter(function(lid) { return lid !== tokenId; }); + } + }); + } else { + // Check if it's in someone else's list + Object.entries(active.linkedTokens).forEach(function(entry) { + var idx = entry[1].indexOf(tokenId); + if (idx !== -1) { + entry[1].splice(idx, 1); + if (!linkedIds) linkedIds = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + + // Remove Anchor/Mirror links and delete counterparts + destroying = true; + linkedIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + if (typeof Mirror !== 'undefined') Mirror.unlink([id]); + var target = getObj('graphic', id); + if (target) target.remove(); + }); + destroying = false; + }; + + /** + * Universal relay interceptor. Automatically relays commands to linked tokens: + * - If selected tokens or IDs in command reference master-page linked tokens + * AND no player-page tokens are selected/referenced → relay to all player pages. + * - If player-page tokens are involved → only relay if command is in relayCommands. + */ + const viewInterceptor = (msg) => { + if (msg.type !== 'api') return; + if (scripting) return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var content = msg.content.trim(); + if (!content) return; + var firstWord = content.split(' ')[0]; + if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; + + // Check relaying set to prevent loops + var selectedIds = (msg.selected || []).map(function(sel) { return sel._id; }); + var key = relayKey(content, 'player|' + msg.playerid, selectedIds); + if (relaying.delete(key)) return; + if (content.indexOf('{& select') !== -1) return; + + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + + // Scan command for token IDs that belong to linked groups + var idRx = /-[A-Za-z0-9_-]{19}/g; + var idsInCommand = (content.match(idRx) || []).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); + + // Classify: which IDs/tokens are on master pages vs player pages? + var masterTokens = []; + var hasPlayerPageRef = false; + var activeEntry = null; + + // Check selected tokens + tokens.forEach(function(t) { + var pid = t.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + masterTokens.push(t); + if (!activeEntry) activeEntry = entry; + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + // Check IDs in command text + idsInCommand.forEach(function(id) { + // Skip IDs already accounted for by selection + if (tokens.some(function(t) { return t.get('id') === id; })) return; + var obj = getObj('graphic', id); + if (!obj) return; + var pid = obj.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + // Check if this token is actually linked + var linked = entry[1].linkedTokens[id] || []; + var isLinked = linked.length > 0 || Object.values(entry[1].linkedTokens).some(function(arr) { return arr.indexOf(id) !== -1; }); + if (isLinked) { + masterTokens.push(obj); + if (!activeEntry) activeEntry = entry; + } + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + if (masterTokens.length === 0 && !hasPlayerPageRef) return; + + // Universal relay: master-page refs, no player-page refs + if (masterTokens.length > 0 && !hasPlayerPageRef) { + var viewPlayerId = s.view; + var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); + executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); + return; + } + + // Player-page involved: only relay if relayCommands allows it + if (hasPlayerPageRef && s.config.relayCommands.indexOf(firstWord) !== -1) { + // Find source player page + var sourcePlayerId = null; + var entry = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + var srcToken = tokens.find(function(t) { return t.get('_pageid') === pp[1].pageId; }); + if (srcToken) { entry = e; sourcePlayerId = pp[0]; } + }); + }); + if (!entry) return; + var targetPlayerIds = Object.keys(entry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, content, targetPlayerIds, true); + } + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('chat:message', viewInterceptor); + on('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!rollcapture-ready') registerWithRollCapture(); + }); + registerWithRollCapture(); + on('add:graphic', onTokenAdded); + on('destroy:graphic', onTokenDestroyed); + on('change:attribute', onAttributeChanged); + on('change:graphic', onGraphicPropChanged); + on('change:graphic:gmnotes', onGmNotesChanged); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 94089efa96..63648f7284 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,23 +1,30 @@ // ============================================================================= -// Gaslight v1.0.0 -// Last Updated: 2026-06-14 +// Gaslight v2.0.0 +// Last Updated: 2026-06-25 // 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. +// with tokens synchronized via Anchor and Mirror. Each player can see +// different things while token movement stays consistent across all copies. +// Commands auto-relay to all player pages transparently. // -// Dependencies: Anchor +// Dependencies: Anchor, Mirror, SelectManager, RollCapture (optional) // // Commands: -// !gaslight split Activate a prepared gaslight group +// !gaslight setup Quick-configure from duplicates +// !gaslight split [--force] Activate a prepared group // !gaslight merge [group] Tear down links, return players // !gaslight test Dry-run linking resolution // !gaslight link [|new] [ids...] Set gaslight_link on tokens -// !gaslight unlink [ids...] Remove gaslight_link from tokens -// !gaslight group Assign page to group -// !gaslight master Designate page as group master +// !gaslight unlink [ids...|--group ] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight ungroup Remove page from group +// !gaslight stage [players...] Propagate tokens to player pages +// !gaslight view [player|master] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight eval [--dry-run] [--all|] Evaluate script pins // !gaslight status Show current state // !gaslight --help Command reference // ============================================================================= @@ -28,10 +35,24 @@ var Gaslight = Gaslight || (() => { 'use strict'; const SCRIPT_NAME = 'Gaslight'; - const SCRIPT_VERSION = '1.0.0'; + const SCRIPT_VERSION = '2.0.0'; const CMD = '!gaslight'; const CONFIG_HEADER = '---GASLIGHT---'; const LINK_KEY = 'gaslight_link'; + const GLS_TAG = '[GLS]'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const stripGlsTag = (name) => { + return (name || '').replace(/^\[GLS\]\s*/i, '').trim(); + }; + + var relaying = new Set(); + var scripting = false; + + const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); // ========================================================================= // Helpers @@ -261,7 +282,8 @@ var Gaslight = Gaslight || (() => { const getLinkId = (token) => { var notes = token.get('gmnotes') || ''; try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } - const match = notes.match(/gaslight_link:\s*(.+)/); + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_link:\s*(\S+)/); return match ? match[1].trim() : null; }; @@ -947,6 +969,10 @@ var Gaslight = Gaslight || (() => { summary += formatWarnings(globalWarnings); reply(msg, 'Split', summary); + // Build trigger map and register Fetch compProps for scripting engine + buildTriggerMap(); + registerAllCompProps(); + // Focus-ping each player to their character token on their page setTimeout(function() { Object.entries(groupInfo.players).forEach(function(entry) { @@ -1223,163 +1249,59 @@ var Gaslight = Gaslight || (() => { }; /** - * Shared relay execution: sends command to linked tokens on target pages. - * Returns number of tokens relayed to. - */ - /** - * Find all Roll20 IDs (starting with -) in a command string that match linked tokens. - * Returns { found: [{id, linkedIds}], hasIds: bool } - */ - const findLinkedIdsInCommand = (command, activeGroups) => { - var idRx = /-[A-Za-z0-9_-]{19}/g; - var matches = command.match(idRx) || []; - var found = []; - matches.forEach(function(id) { - var linkedIds = []; - Object.values(activeGroups).forEach(function(active) { - var allLinked = active.linkedTokens[id] || []; - Object.entries(active.linkedTokens).forEach(function(entry) { - if (entry[1].indexOf(id) !== -1) { - allLinked = allLinked.concat([entry[0]]).concat(entry[1]); - } - }); - allLinked = allLinked.filter(function(lid, i) { return allLinked.indexOf(lid) === i && lid !== id; }); - linkedIds = linkedIds.concat(allLinked); - }); - if (linkedIds.length > 0) found.push({ id: id, linkedIds: linkedIds }); - }); - return { found: found, hasIds: found.length > 0 }; - }; - - /** - * Path 2: Replace token IDs in command with linked counterparts per target page, emit immediately. - */ - const relayByIdReplacement = (sender, command, activeGroups, targetPlayerIds) => { - var idInfo = findLinkedIdsInCommand(command, activeGroups); - if (!idInfo.hasIds) return 0; - - var relayed = 0; - targetPlayerIds.forEach(function(playerId) { - var newCmd = command; - idInfo.found.forEach(function(entry) { - // Find the linked token that's on this player's page - var targetId = null; - Object.values(activeGroups).forEach(function(active) { - if (targetId) return; - var playerPage = active.playerPages[playerId]; - if (!playerPage) return; - entry.linkedIds.forEach(function(lid) { - if (targetId) return; - var obj = getObj('graphic', lid); - if (obj && obj.get('_pageid') === playerPage.pageId) targetId = lid; - }); - }); - if (targetId) newCmd = newCmd.replace(entry.id, targetId); - }); - if (newCmd !== command) { - sendChat(sender, newCmd); - relayed++; - } - }); - return relayed; - }; - - /** - * Path 1: Queue commands for execution when GM visits the target page. + * Relay execution: replaces token IDs in command with linked counterparts per + * target page and appends {& select} for SelectManager cross-page targeting. */ - const queueRelay = (sender, tokens, command, targetPlayerIds) => { + const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { var s = state[SCRIPT_NAME]; - if (!s.relayQueue) s.relayQueue = {}; + var relayed = 0; var tokenIds = tokens.map(function(t) { return t.get('id'); }); - var newlyQueued = 0; + + if (includeMaster) { + relaying.add(relayKey(command, sender, tokenIds)); + sendChat(sender, command + ' {& select ' + tokenIds.join(', ') + '}'); + relayed += tokenIds.length; + } targetPlayerIds.forEach(function(playerId) { - // Find the linked token IDs for this player page var linkedIds = []; + var newCmd = command; + Object.values(s.activeGroups).forEach(function(active) { var playerPage = active.playerPages[playerId]; if (!playerPage) return; + tokenIds.forEach(function(tokenId) { - var allLinked = active.linkedTokens[tokenId] || []; + // Find all linked counterparts + var allLinked = (active.linkedTokens[tokenId] || []).slice(); Object.entries(active.linkedTokens).forEach(function(entry) { - if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + if (entry[1].indexOf(tokenId) !== -1) { + allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + } }); - allLinked.filter(function(id) { + // Filter to ones on this player's page + var onPage = allLinked.filter(function(id, i, arr) { + if (arr.indexOf(id) !== i || id === tokenId) return false; var obj = getObj('graphic', id); return obj && obj.get('_pageid') === playerPage.pageId; - }).forEach(function(id) { + }); + onPage.forEach(function(id) { + newCmd = newCmd.split(tokenId).join(id); if (linkedIds.indexOf(id) === -1) linkedIds.push(id); }); }); }); if (linkedIds.length > 0) { - // Queue for when GM visits the page - var pageId = null; - Object.values(s.activeGroups).forEach(function(active) { - var pp = active.playerPages[playerId]; - if (pp) pageId = pp.pageId; - }); - if (pageId) { - if (!s.relayQueue[pageId]) s.relayQueue[pageId] = []; - s.relayQueue[pageId].push({ sender: sender, command: command, selectIds: linkedIds }); - newlyQueued++; - } + relaying.add(relayKey(newCmd, sender, linkedIds)); + sendChat(sender, newCmd + ' {& select ' + linkedIds.join(', ') + '}'); + relayed += linkedIds.length; } }); - if (newlyQueued > 0) { - var totalPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }).length; - sendChat(SCRIPT_NAME, '/w gm Queued for ' + newlyQueued + ' page(s). Total pending: ' + totalPages + '. Navigate to player pages to execute.'); - } - }; - - const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { - var s = state[SCRIPT_NAME]; - var relayed = 0; - - if (includeMaster) { - var masterIds = tokens.map(function(t) { return t.get('id'); }); - sendChat(sender, command + ' {& select ' + masterIds.join(', ') + '}'); - relayed += masterIds.length; - } - - if (targetPlayerIds.length > 0) { - // Path 2: try ID replacement first (works cross-page) - var idRelayed = relayByIdReplacement(sender, command, s.activeGroups, targetPlayerIds); - if (idRelayed > 0) { - relayed += idRelayed; - } else { - // Path 1: queue for when GM visits page (selection-based) - queueRelay(sender, tokens, command, targetPlayerIds); - } - } - return relayed; }; - /** - * Poll _lastpage to fire queued relay commands when GM arrives on a target page. - */ - const pollRelayQueue = () => { - var s = state[SCRIPT_NAME]; - if (!s.relayQueue) return; - - var gmPlayers = findObjs({ _type: 'player' }).filter(function(p) { return playerIsGM(p.get('_id')); }); - gmPlayers.forEach(function(gm) { - var lastPage = gm.get('_lastpage'); - if (!lastPage) return; - var queue = s.relayQueue[lastPage]; - if (!queue || queue.length === 0) return; - - // Fire all queued commands for this page - queue.forEach(function(entry) { - sendChat(entry.sender, entry.command + ' {& select ' + entry.selectIds.join(', ') + '}'); - }); - delete s.relayQueue[lastPage]; - }); - }; - /** * Stage selected tokens: duplicate to player pages and link. * !gaslight stage [playerName1 playerName2 ...] @@ -1653,6 +1575,821 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' status -- Show state
' + '' + CMD + ' --help -- This help
'; + // ========================================================================= + // Scripting Engine — Fetch Integration + // ========================================================================= + + // Module-level evaluation context for Fetch compProp resolution + var evaluationContext = { scope: 'token', targetId: null, viewerPlayerId: null }; + + /** + * Read a gl_ field from a token's gmnotes. + */ + const readGlField = (gmnotes, fieldName) => { + var notes = gmnotes || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + var rx = new RegExp(fieldName + '\\s*:\\s*(\\S+)'); + var match = notes.match(rx); + return match ? match[1] : ''; + }; + + /** + * Register a gl_ field as a Fetch compProp on the graphic type. + * Resolution depends on evaluationContext.scope. + */ + const registerGlCompProp = (fieldName) => { + if (typeof Fetch === 'undefined' || !Fetch.CustomPropsByType) return; + if (Fetch.CustomPropsByType.graphic.compProps[fieldName]) return; + + var valFn = function(o) { + if (evaluationContext.scope === 'token') { + return readGlField(o.gmnotes, fieldName); + } else { + var charId = o.represents; + if (!charId) return ''; + return getAttrByName(charId, fieldName) || ''; + } + }; + + Fetch.CustomPropsByType.graphic.compProps[fieldName] = { nicks: [], val: valFn }; + // Also inject into the cached PropContainers so Fetch uses it immediately + if (Fetch.PropContainers && Fetch.PropContainers.graphic) { + Fetch.PropContainers.graphic[fieldName] = valFn; + } + log(SCRIPT_NAME + ': registered Fetch compProp "' + fieldName + '"'); + }; + + /** + * Scan a script for gl_ references and register compProps for each. + */ + const registerCompPropsFromScript = (content) => { + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + var rx = /@\([^)]*\.(gl_[a-zA-Z0-9_]+)\)/g; + var match; + while ((match = rx.exec(text)) !== null) { + registerGlCompProp(match[1]); + } + }; + + /** + * Scan all active script handouts and register compProps. + * Called on split and when handouts change. + */ + const registerAllCompProps = () => { + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + getPinScript(pin, function(content) { + if (content) registerCompPropsFromScript(content); + }); + }); + }); + }); + }; + + // ========================================================================= + // Scripting Engine — Trigger Map + // ========================================================================= + + // triggerMap: attributeName → [{ pinId, pageId }] + var triggerMap = {}; + + /** + * Parse a script's conditional blocks to find referenced attributes for auto-triggering. + * Looks for @(target.gl_*) and @(viewer.*) inside {& if} blocks. + */ + const parseTriggersFromScript = (content) => { + var triggers = []; + // Strip HTML for parsing + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + // Find content inside {& if ...} blocks (simple regex — catches most cases) + var ifRx = /\{&\s*if\s+(.+?)\}/gi; + var match; + while ((match = ifRx.exec(text)) !== null) { + var condition = match[1]; + // Find @(target.*) and @(viewer.*) references in the condition + var refRx = /@\((?:target|viewer)\.([^)]+)\)/g; + var refMatch; + while ((refMatch = refRx.exec(condition)) !== null) { + var field = refMatch[1]; + if (triggers.indexOf(field) === -1) triggers.push(field); + } + } + return triggers; + }; + + /** + * Build the trigger map for all active script pins. + * Called on split, and when handouts change. + */ + const buildTriggerMap = () => { + triggerMap = {}; + var s = state[SCRIPT_NAME]; + + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + parsePinConfig(pin, function(config) { + if (!config) return; + var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); + var manualOnly = config.triggers.some(function(t) { return t === 'manual only'; }); + + if (manualOnly) return; + + if (explicitTriggers.length > 0) { + explicitTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + } else { + getPinScript(pin, function(content) { + if (!content) return; + var autoTriggers = parseTriggersFromScript(content); + var ignored = config.triggers.filter(function(t) { return t.startsWith('ignore '); }).map(function(t) { return t.slice(7).trim(); }); + autoTriggers = autoTriggers.filter(function(t) { return ignored.indexOf(t) === -1; }); + + autoTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + }); + } + }); + }); + }); + }); + }; + + /** + * Handle attribute changes — check trigger map and re-evaluate affected pins. + */ + const onAttributeChanged = (obj) => { + var attrName = obj.get('name'); + var entries = triggerMap[attrName]; + if (!entries || entries.length === 0) return; + + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }; + + /** + * Handle token property changes — check trigger map for graphic properties. + */ + const onGraphicPropChanged = (obj, prev) => { + var changed = Object.keys(prev).filter(function(k) { return !k.startsWith('_') && prev[k] !== obj.get(k) && k !== 'gmnotes'; }); + if (changed.length === 0) return; + + var triggered = false; + changed.forEach(function(prop) { + var entries = triggerMap[prop]; + if (!entries || entries.length === 0) return; + if (triggered) return; // only evaluate once per change event + triggered = true; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }); + }; + const onGmNotesChanged = (obj, prev) => { + if (!prev || !prev.gmnotes) return; + var oldNotes = prev.gmnotes || ''; + var newNotes = obj.get('gmnotes') || ''; + try { oldNotes = decodeURIComponent(oldNotes); } catch(e) {} + try { newNotes = decodeURIComponent(newNotes); } catch(e) {} + + // Parse gl_ fields from old and new + var glRx = /gl_([a-zA-Z0-9_]+)\s*[=:]\s*(.+)/g; + var oldFields = {}; + var newFields = {}; + var m; + while ((m = glRx.exec(oldNotes)) !== null) oldFields['gl_' + m[1]] = m[2].trim(); + glRx.lastIndex = 0; + while ((m = glRx.exec(newNotes)) !== null) newFields['gl_' + m[1]] = m[2].trim(); + + // Find changed fields + var changedFields = Object.keys(newFields).filter(function(k) { return oldFields[k] !== newFields[k]; }); + // Also check removed fields + Object.keys(oldFields).forEach(function(k) { if (!(k in newFields) && changedFields.indexOf(k) === -1) changedFields.push(k); }); + + changedFields.forEach(function(field) { + var entries = triggerMap[field]; + if (!entries || entries.length === 0) return; + // Find the master page counterpart of this token + var tokenId = obj.get('id'); + var masterTokenId = null; + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(active) { + // Check if this token is linked; find the master copy + var allLinked = active.linkedTokens[tokenId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked = allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i; }); + allLinked.forEach(function(id) { + var t = getObj('graphic', id); + if (t && t.get('_pageid') === active.masterPageId) masterTokenId = id; + }); + // If the token itself is on master + if (obj.get('_pageid') === active.masterPageId) masterTokenId = tokenId; + }); + + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false, masterTokenId, obj.get('_pageid')); + }); + }); + }; + + // ========================================================================= + // Scripting Engine + // ========================================================================= + + /** + * Read a handout's notes content (async → callback pattern). + * Returns content via callback since Roll20 requires it for notes/gmnotes. + */ + const getHandoutContent = (handoutId, callback) => { + var handout = getObj('handout', handoutId); + if (!handout) { callback(null); return; } + handout.get('notes', function(notes) { + if (!notes) { callback(''); return; } + var text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + callback(text); + }); + }; + + /** + * Find pins on a page that are gaslight script pins. + * A pin is a script pin if: + * - It links to a handout (script in handout notes, config in handout gmNotes or pin gmNotes) + * - OR it has ---GASLIGHT-SCRIPT--- in its own gmNotes (self-contained) + */ + const findScriptPins = (pageId) => { + var pins = findObjs({ _type: 'pin', _pageid: pageId }); + return pins.filter(function(pin) { + if (pin.get('link') && pin.get('linkType') === 'handout') return true; + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf('---GASLIGHT-SCRIPT---') !== -1; + }); + }; + + /** + * Parse pin configuration. Checks pin gmNotes first, falls back to linked handout gmNotes. + */ + const parsePinConfig = (pin, callback) => { + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + + // If pin has its own config, use it + if (notes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(notes)); + return; + } + + // Fall back to linked handout's gmNotes + var handoutId = pin.get('link'); + if (handoutId) { + var handout = getObj('handout', handoutId); + if (handout) { + handout.get('gmnotes', function(gmnotes) { + gmnotes = gmnotes || ''; + try { gmnotes = decodeURIComponent(gmnotes); } catch(e) {} + if (gmnotes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(gmnotes)); + } else { + // No config found, use defaults + callback({ scope: 'token', filter: 'all', triggers: [] }); + } + }); + return; + } + } + callback(null); + }; + + /** + * Parse config text into structured object. + */ + const parseConfigText = (text) => { + var config = { scope: 'token', filter: 'all', triggers: [] }; + // Strip HTML and normalize line breaks + text = text.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + text.split('\n').forEach(function(line) { + line = line.trim(); + if (line.startsWith('scope:')) config.scope = line.slice(6).trim(); + else if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); + else if (line.startsWith('trigger:')) config.triggers.push(line.slice(8).trim()); + }); + return config; + }; + + /** + * Get the script content for a pin. + * Linked pin: from handout notes. Self-contained: from pin notes. + */ + const getPinScript = (pin, callback) => { + var handoutId = pin.get('link'); + if (handoutId) { + getHandoutContent(handoutId, callback); + } else { + // Self-contained: script in pin notes + var notes = pin.get('notes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + callback(notes); + } + }; + + /** + * Get target tokens for evaluation based on pin config filter. + */ + const getTargetTokens = (pageId, config, activeGroups) => { + var tokens = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var filter = config.filter.toLowerCase(); + if (filter === 'all') return tokens; + if (filter === 'npc') { + return tokens.filter(function(t) { + var charId = t.get('represents'); + if (!charId) return false; + var character = getObj('character', charId); + if (!character) return false; + var cb = character.get('controlledby') || ''; + return !cb || cb === ''; + }); + } + if (filter.startsWith('has ')) { + var field = filter.slice(4).trim(); + return tokens.filter(function(t) { + // Check gmnotes + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1) return true; + // Check character attribute + var charId = t.get('represents'); + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: field })[0]; + if (attr) return true; + } + return false; + }); + } + return tokens; + }; + + /** + * Find the linked counterpart of a token on a specific page. + */ + const findLinkedTokenOnPage = (sourceToken, targetPageId) => { + var s = state[SCRIPT_NAME]; + var sourceId = sourceToken.get('id'); + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = active.linkedTokens[sourceId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(sourceId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i && id !== sourceId; }).forEach(function(id) { + linkedIds.push(id); + }); + }); + for (var i = 0; i < linkedIds.length; i++) { + var obj = getObj('graphic', linkedIds[i]); + if (obj && obj.get('_pageid') === targetPageId) return obj; + } + return null; + }; + + /** + * Evaluate a script for a specific target token and viewer. + * Resolves target to the linked copy on the viewer's page. + */ + // ─── Viewer Aggregation ──────────────────────────────────────────────────── + + const OPS = ['>=', '<=', '!=', '!~', '=', '~', '>', '<']; + + /** + * Find `search` in `str` starting at `startIdx`, skipping quoted regions. + * Returns index or -1. + */ + const findUnquoted = (str, search, startIdx) => { + var inQuote = null; + for (var i = startIdx || 0; i <= str.length - search.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (str.slice(i, i + search.length) === search) return i; + } + return -1; + }; + + /** + * Find the matching close paren for an open paren at `start`. + * Skips quoted strings. Returns index of closing paren or -1. + */ + const findCloseParen = (str, start) => { + var depth = 0; + var inQuote = null; + for (var i = start; i < str.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (ch === '(') depth++; + else if (ch === ')') { depth--; if (depth === 0) return i; } + } + return -1; + }; + + /** + * Extract operator and RHS starting at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced ), ||, &&, or }. + * Returns { op, rhs, end } or null. + */ + const extractOpRhs = (str, pos) => { + var rest = str.slice(pos).replace(/^\s*/, ''); + var offset = pos + (str.slice(pos).length - rest.length); + for (var i = 0; i < OPS.length; i++) { + if (rest.startsWith(OPS[i])) { + var afterOp = rest.slice(OPS[i].length).replace(/^\s*/, ''); + var opEnd = offset + OPS[i].length + (rest.slice(OPS[i].length).length - afterOp.length); + var rhs = ''; + var depth = 0; + var inQ = null; + var j = 0; + for (; j < afterOp.length; j++) { + var c = afterOp[j]; + if (inQ) { if (c === inQ) inQ = null; rhs += c; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; rhs += c; continue; } + if (c === '(') { depth++; rhs += c; continue; } + if (c === ')') { if (depth === 0) break; depth--; rhs += c; continue; } + if (depth === 0 && j + 1 < afterOp.length && (afterOp.slice(j, j + 2) === '||' || afterOp.slice(j, j + 2) === '&&')) break; + if (c === '}') break; + rhs += c; + } + return { op: OPS[i], rhs: rhs.trim(), end: opEnd + j }; + } + } + return null; + }; + + /** + * Extract operator and LHS ending at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced (, ||, &&, or {&. + * Returns { op, lhs, start } or null. + */ + const extractOpLhs = (str, pos) => { + var before = str.slice(0, pos).replace(/\s*$/, ''); + for (var i = 0; i < OPS.length; i++) { + if (before.endsWith(OPS[i])) { + var beforeOp = before.slice(0, -OPS[i].length).replace(/\s*$/, ''); + var lhs = ''; + var depth = 0; + var inQ = null; + var j = beforeOp.length - 1; + for (; j >= 0; j--) { + var c = beforeOp[j]; + if (inQ) { if (c === inQ) inQ = null; lhs = c + lhs; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; lhs = c + lhs; continue; } + if (c === ')') { depth++; lhs = c + lhs; continue; } + if (c === '(') { if (depth === 0) break; depth--; lhs = c + lhs; continue; } + if (j > 0 && (beforeOp.slice(j - 1, j + 1) === '||' || beforeOp.slice(j - 1, j + 1) === '&&')) { break; } + lhs = c + lhs; + } + return { op: OPS[i], lhs: lhs.trim(), start: j + 1 }; + } + } + return null; + }; + + /** + * Expand any()/all()/max()/min() viewer aggregates. + * Sweep 1: any (LHS then RHS) + * Sweep 2: all (LHS then RHS) + * Sweep 3: max/min (resolve to literal) + */ + const expandAggregates = (content, ids, namespace) => { + if (ids.length === 0) return content; + content = expandAggregate(content, 'any', '||', ids, namespace); + content = expandAggregate(content, 'all', '&&', ids, namespace); + content = resolveMaxMin(content, ids, namespace); + content = resolveJoin(content, ids, namespace); + return content; + }; + + const expandAggregate = (content, funcName, joiner, ids, namespace) => { + var search = funcName + '('; + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + funcName.length); + if (closeIdx === -1) break; + + var inner = content.slice(idx + funcName.length + 1, closeIdx); + // Only expand if this aggregate contains our namespace + if (inner.indexOf('@(' + namespace + '.') === -1) { idx = findUnquoted(content, search, idx + 1); continue; } + + var beforeAgg = content.slice(0, idx); + var afterAgg = content.slice(closeIdx + 1); + + var opRhs = extractOpRhs(afterAgg, 0); + if (opRhs) { + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.') + ' ' + opRhs.op + ' ' + opRhs.rhs; + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg.slice(opRhs.end); + } else { + var opLhs = extractOpLhs(beforeAgg, beforeAgg.length); + if (opLhs) { + var expanded = '(' + ids.map(function(id) { + return opLhs.lhs + ' ' + opLhs.op + ' ' + inner.replace(nsRx, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg.slice(0, opLhs.start) + expanded + afterAgg; + } else { + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg; + } + } + + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + + const resolveMaxMin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; + ['max', 'min'].forEach(function(fn) { + var search = fn + '('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + fn.length); + if (closeIdx === -1) break; + var inner = content.slice(idx + fn.length + 1, closeIdx); + if (inner.indexOf(nsCheck) !== -1) { + var expanded = '{& math ' + fn + '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); + }).join(', ') + ')}'; + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + }); + return content; + }; + + const resolveJoin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; + var search = 'join('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + 4); + if (closeIdx === -1) break; + var inner = content.slice(idx + 5, closeIdx); + if (inner.indexOf(nsCheck) !== -1) { + // Check for optional delimiter: join(@(viewer.field), ",") + var parts = inner.split(','); + var field = parts[0].trim(); + var delim = ' '; + if (parts.length > 1) { + var rawDelim = parts.slice(1).join(',').trim(); + delim = rawDelim.replace(/^['"`]|['"`]$/g, ''); + } + var expanded = ids.map(function(id) { + return field.replace(nsRx, '@(' + id + '.'); + }).join(delim); + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, viewerPageId, config, msg, dryRun) => { + // Find the linked token on the viewer's page + var viewerTarget = findLinkedTokenOnPage(targetToken, viewerPageId); + if (!viewerTarget) return; + + // Set evaluation context for Fetch compProp resolution + evaluationContext.scope = config.scope || 'token'; + evaluationContext.targetId = viewerTarget.get('id'); + evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page + + var content = scriptContent; + // Resolve @(target.gl_*) ourselves since Fetch compProps don't fire for sendChat messages + content = content.replace(/@\(target\.(gl_[a-zA-Z0-9_]+)\)/g, function(match, field) { + var val = ''; + if (config.scope === 'token') { + val = readGlField(viewerTarget.get('gmnotes'), field); + } + if (!val) { + var charId = viewerTarget.get('represents'); + val = charId ? (getAttrByName(charId, field) || '') : ''; + } + return val; + }); + // Replace remaining @(target.*) with token ID — Fetch resolves native props + content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); + // Resolve viewer tokens for aggregation + var viewerTokens = findObjs({ _type: 'graphic', _pageid: viewerPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return cb === 'all' || cb.split(',').indexOf(viewerPlayerId) !== -1; + }); + var viewerIds = viewerTokens.map(function(t) { return t.get('id'); }); + + // Resolve GM tokens on master page for gm.* aggregation + var masterPageId = targetToken.get('_pageid'); + var gmTokens = findObjs({ _type: 'graphic', _pageid: masterPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return !cb || cb.split(',').every(function(id) { return id.trim() === '' || playerIsGM(id.trim()); }); + }); + var gmIds = gmTokens.map(function(t) { return t.get('id'); }); + + // Expand any()/all()/max()/min() aggregates for viewer.* and gm.* + content = expandAggregates(content, viewerIds, 'viewer'); + content = expandAggregates(content, gmIds, 'gm'); + + // Error check: bare @(viewer.*) or @(gm.*) without aggregate + if (content.indexOf('@(viewer.') !== -1) { + whisper('⚠️ Script error: @(viewer.*) must be inside any(), all(), max(), or min()'); + return; + } + if (content.indexOf('@(gm.') !== -1) { + whisper('⚠️ Script error: @(gm.*) must be inside any(), all(), max(), or min()'); + return; + } + + var lines = content.split('\n').map(function(l) { + var ci = l.indexOf('//'); + return (ci !== -1 ? l.slice(0, ci) : l).trim(); + }).filter(function(l) { + return l && (l.startsWith('!') || l.startsWith('{&')); + }); + + if (dryRun) { + lines.forEach(function(l) { + sendChat('player|' + msg.playerid, CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' + l); + }); + } else { + var fullCmd = lines.join('\n'); + if (fullCmd) { + var senderId = msg.playerid; + if (senderId === 'API') { + var gmPlayer = findObjs({ _type: 'player' }).find(function(p) { return playerIsGM(p.get('_id')); }); + if (gmPlayer) senderId = gmPlayer.get('_id'); + } + sendChat('', CMD + ' --script-lock', null, { noarchive: true }); + sendChat(getPlayerName(senderId), fullCmd); + sendChat('', CMD + ' --script-unlock', null, { noarchive: true }); + } + } + }; + + /** + * Evaluate all scripts on pins for a given page. + */ + const evaluatePins = (pins, msg, dryRun, targetTokenId, sourcePageId) => { + var s = state[SCRIPT_NAME]; + pins.forEach(function(pin) { + var pageId = pin.get('_pageid'); + + // Find the active group for this page + var activeEntry = Object.entries(s.activeGroups).find(function(e) { + return e[1].masterPageId === pageId || Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); + }); + if (!activeEntry) return; + + var groupInfo = activeEntry[1]; + + // Determine which viewers to evaluate for based on pin placement + var viewers; + if (pageId === groupInfo.masterPageId) { + viewers = Object.entries(groupInfo.playerPages); + } else { + var playerEntry = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === pageId; }); + viewers = playerEntry ? [playerEntry] : []; + } + // If triggered from a specific player page, narrow to that viewer only + if (sourcePageId && sourcePageId !== groupInfo.masterPageId) { + var sourceViewer = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === sourcePageId; }); + if (sourceViewer) viewers = [sourceViewer]; + } + if (viewers.length === 0) return; + + // Get targets from master page (source of truth for token list) + var targets = getTargetTokens(groupInfo.masterPageId, { filter: 'all' }, s.activeGroups); + + parsePinConfig(pin, function(config) { + if (!config) return; + // Re-filter targets based on config + targets = getTargetTokens(groupInfo.masterPageId, config, s.activeGroups); + // If triggered by a specific token, only evaluate that one + if (targetTokenId) { + targets = targets.filter(function(t) { return t.get('id') === targetTokenId; }); + if (targets.length === 0) return; + } + + getPinScript(pin, function(content) { + if (!content) return; + // Strip HTML tags from content + content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + if (dryRun) { + var handout = pin.get('link') ? getObj('handout', pin.get('link')) : null; + var pinTitle = stripGlsTag(pin.get('title') || (handout && handout.get('name')) || pin.get('_id')); + sendChat('player|' + msg.playerid, CMD + ' --echo-header ' + pinTitle); + } + + // Evaluate for each viewer + target combination + viewers.forEach(function(entry) { + var viewerPlayerId = entry[0]; + var viewerPageId = entry[1].pageId; + targets.forEach(function(target) { + evaluateScript(content, target, viewerPlayerId, viewerPageId, config, msg, dryRun); + }); + }); + }); + }); + }); + }; + + /** + * !gaslight eval [--dry] [--all | ] + * With pins selected: evaluate those pins. + * With --all: evaluate all active pins. + * With handout name: evaluate all pins linked to that handout. + */ + const doEval = (msg, args) => { + var dryRun = args.indexOf('--dry-run') !== -1; + args = args.filter(function(a) { return a !== '--dry-run'; }); + + var pins = []; + + if (args.indexOf('--all') !== -1) { + // All active gaslit pages + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + pins = pins.concat(findScriptPins(pid)); + }); + }); + } else if (args.length > 0) { + // By handout name + var handoutName = args.join(' '); + var handout = findObjs({ _type: 'handout', name: handoutName })[0]; + if (!handout) { reply(msg, 'Error', 'Handout "' + handoutName + '" not found.'); return; } + var allPins = findObjs({ _type: 'pin' }); + pins = allPins.filter(function(p) { return p.get('link') === handout.get('_id'); }); + } else if (msg.selected && msg.selected.length > 0) { + // Selected pins + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (obj && obj.get('_type') === 'pin') pins.push(obj); + }); + } + + if (pins.length === 0) { reply(msg, 'Error', 'No pins found. Select pins, provide a handout name, or use --all.'); return; } + + reply(msg, 'Eval', 'Evaluating ' + pins.length + ' pin(s)' + (dryRun ? ' (dry run)' : '') + '...'); + evaluatePins(pins, msg, dryRun); + }; + // ========================================================================= // Command Router // ========================================================================= @@ -1678,27 +2415,286 @@ var Gaslight = Gaslight || (() => { case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; - case 'test-relay': { - // Temporary: test sendChat with {& select} - var testId = args[0] || ''; - if (!testId) { reply(msg, 'Error', 'Provide a token ID'); break; } - var testCmd = '!token-mod --set bar1_value|42 {& select ' + testId + '}'; - log(SCRIPT_NAME + ': test-relay sending: ' + testCmd); - sendChat(getPlayerName(msg.playerid), testCmd); + case 'eval': doEval(msg, args); break; + case 'status': doStatus(msg); break; + case '--script-lock': scripting = true; return; + case '--script-unlock': scripting = false; return; + case '--assign-capture': { + // Format: --assign-capture ... + var acRollName = args[0]; + var acCharId = args[1]; + var acCaptures = {}; + args.slice(2).forEach(function(a) { + var eq = a.indexOf('='); + if (eq > 0) acCaptures[a.slice(0, eq)] = a.slice(eq + 1); + }); + var acTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === acCharId; + }); + if (acTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + acTokens.forEach(function(t) { writeCapturesToToken(t, acRollName, acCaptures); }); + reply(msg, 'Capture', 'Assigned ' + acRollName + ' to ' + acTokens.length + ' token(s).'); + return; + } + case '--clear-capture': { + // Format: --clear-capture + var ccRollName = args[0]; + var ccCharId = args[1]; + var ccTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === ccCharId; + }); + if (ccTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + ccTokens.forEach(function(t) { + var gmnotes = decodeURIComponent(t.get('gmnotes') || ''); + gmnotes = gmnotes.replace(new RegExp('(^|\\n)gl_' + ccRollName + '_[^=]+=([^\\n]*)', 'g'), ''); + t.set('gmnotes', gmnotes.trim()); + }); + reply(msg, 'Capture', 'Cleared ' + ccRollName + ' overrides from ' + ccTokens.length + ' token(s).'); + return; + } + case '--echo': { + // Internal: dry-run echo. Format: !gaslight --echo + var echoRaw = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); + var [echoViewerId, echoTargetId] = echoRaw.split(' '); + var echoCmd = echoRaw.slice(echoViewerId.length + 1 + echoTargetId.length + 1); + var echoViewer = getObj('player', echoViewerId); + var echoTarget = getObj('graphic', echoTargetId); + var viewerName = echoViewer ? echoViewer.get('_displayname') : echoViewerId; + var echoTargetName = echoTarget ? echoTarget.get('name') : ''; + var targetDisplay = echoTargetName ? echoTargetName + ' ' + echoTargetId + '' : '' + echoTargetId + ''; + reply(msg, 'Eval', 'Dry run
Target: ' + targetDisplay + '
Viewer: ' + viewerName + ' ' + echoViewerId + '
' + echoCmd + ''); + break; + } + case '--echo-header': { + // Internal: dry-run pin header + var headerContent = msg.content.slice(msg.content.indexOf('--echo-header') + 13).trim(); + reply(msg, 'Eval', 'Pin: ' + headerContent); + break; + } + case '--dump-html': { + // Debug: dump raw content to console for selected pins/tokens or named character + if (args.length > 0) { + var charName = args.join(' '); + var charObj = findObjs({ _type: 'character', name: charName })[0]; + if (charObj) { + charObj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char "' + charName + '" bio]: ' + JSON.stringify(bio)); }); + charObj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char "' + charName + '" gmnotes]: ' + JSON.stringify(gn)); }); + } else { + reply(msg, 'Error', 'Character "' + charName + '" not found.'); + } + break; + } + var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + sel.forEach(function(obj) { + var type = obj.get('_type') || obj.get('type'); + if (type === 'pin') { + var handoutId = obj.get('link'); + if (handoutId) { + var ho = getObj('handout', handoutId); + if (ho) { + ho.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [handout gmnotes]: ' + JSON.stringify(gn)); }); + ho.get('notes', function(n) { log(SCRIPT_NAME + ' [handout notes]: ' + JSON.stringify(n)); }); + } + } else { + log(SCRIPT_NAME + ' [pin gmNotes]: ' + JSON.stringify(obj.get('gmNotes'))); + log(SCRIPT_NAME + ' [pin notes]: ' + JSON.stringify(obj.get('notes'))); + } + } else if (type === 'graphic') { + log(SCRIPT_NAME + ' [token ' + (obj.get('name') || obj.get('id')) + ' gmnotes]: ' + JSON.stringify(obj.get('gmnotes'))); + } else if (type === 'character') { + obj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' bio]: ' + JSON.stringify(bio)); }); + obj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' gmnotes]: ' + JSON.stringify(gn)); }); + } + }); break; } - case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; } }; + // ========================================================================= + // RollCapture Integration + // ========================================================================= + + const registerWithRollCapture = () => { + if (typeof RollCapture === 'undefined' || !RollCapture.onCapture) return; + RollCapture.onCapture(SCRIPT_NAME, onCaptureReceived); + }; + + const onCaptureReceived = (event) => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + + var { charName, charId, rollName, captures, playerId, msg } = event; + var selected = (msg && msg.selected) || []; + + // Always write to character attribute + if (charId) { + Object.entries(captures).forEach(function(entry) { + var attrName = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var attr = findObjs({ type: 'attribute', _characterid: charId, name: attrName })[0]; + if (val === undefined) { + if (attr) attr.remove(); + } else { + if (attr) attr.set('current', String(val)); + else createObj('attribute', { _characterid: charId, name: attrName, current: String(val) }); + } + }); + } + + // Token assignment — only count tokens representing this character + var tokens = selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === charId; + }); + + // Fallback: if no selection, find tokens of this character on master pages + if (tokens.length === 0 && charId) { + var masterPageIds = Object.values(s.activeGroups).map(function(g) { return g.masterPageId; }); + tokens = findObjs({ _type: 'graphic', _subtype: 'token', represents: charId }).filter(function(t) { + return masterPageIds.indexOf(t.get('_pageid')) !== -1; + }); + } + + if (tokens.length === 1) { + writeCapturesToToken(tokens[0], rollName, captures); + } else { + // Only prompt if any captured field is referenced by an active script + var hasRelevantTrigger = Object.keys(captures).some(function(cap) { + return triggerMap['gl_' + rollName + '_' + cap]; + }); + if (hasRelevantTrigger) { + var captureArgs = Object.entries(captures).map(function(e) { return e[0] + '=' + e[1]; }).join(' '); + whisper('**' + charName + '** rolled **' + rollName + '**: ' + captureArgs + + '
[Assign to selected](' + CMD + ' --assign-capture ' + rollName + ' ' + charId + ' ' + captureArgs + ')' + + ' [Clear overrides](' + CMD + ' --clear-capture ' + rollName + ' ' + charId + ')'); + } + } + + // Manually trigger pin evaluation for changed capture fields + var fakeMsg = { playerid: playerId || 'API', who: 'API', type: 'api' }; + var pins = Object.keys(captures).reduce(function(acc, cap) { + var entries = triggerMap['gl_' + rollName + '_' + cap] || []; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (pin && acc.indexOf(pin) === -1) acc.push(pin); + }); + return acc; + }, []); + if (pins.length > 0) evaluatePins(pins, fakeMsg, false); + }; + + const writeCapturesToToken = (token, rollName, captures) => { + var gmnotes = decodeURIComponent(token.get('gmnotes') || ''); + Object.entries(captures).forEach(function(entry) { + var field = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var rx = new RegExp('(^|\\n)' + field + '=[^\\n]*'); + if (val === undefined) { + gmnotes = gmnotes.replace(rx, ''); + } else if (gmnotes.match(rx)) { + gmnotes = gmnotes.replace(rx, '$1' + field + '=' + val); + } else { + gmnotes = gmnotes.trim() + '\n' + field + '=' + val; + } + }); + token.set('gmnotes', gmnotes); + }; + // ========================================================================= // Initialization // ========================================================================= + const HANDOUT_NAME = 'Help: Gaslight'; + const HANDOUT_AVATAR = 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385'; + + const createHelpHandout = () => { + var existing = findObjs({ type: 'handout', name: HANDOUT_NAME }); + var h = existing.length > 0 ? existing[0] : createObj('handout', { name: HANDOUT_NAME, avatar: HANDOUT_AVATAR }); + if (HANDOUT_AVATAR) h.set('avatar', HANDOUT_AVATAR); + h.set('notes', [ + '

Gaslight v' + SCRIPT_VERSION + '

', + '

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

', + '

Quick Start

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

Commands

', + '

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

', + '

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

', + '

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

', + '

!gaslight test <group> — Dry-run linking

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

!gaslight status — Show state

', + '

Auto-Relay

', + '

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

', + '

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

', + '

Selective Relay

', + '

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

', + '

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

', + '

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

', + '

Token Linking

', + '

Tokens are linked across pages automatically by:

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

Sync Control

', + '

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

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

Staging

', + '

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

', + '

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

', + '

Scripting

', + '

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

', + '

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

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

Script syntax:

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

Variables:

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

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

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

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

', + '

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

', + '

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

', + ].join('')); + }; + const checkInstall = () => { ensureState(); + createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); }; @@ -1750,61 +2746,116 @@ var Gaslight = Gaslight || (() => { }; /** - * In any active view mode, intercept non-gaslight API commands and re-emit - * with linked player tokens as selection via SelectManager. - * Master view: relay to ALL player pages. - * Player view: relay to that player's page only. + * Universal relay interceptor. Automatically relays commands to linked tokens: + * - If selected tokens or IDs in command reference master-page linked tokens + * AND no player-page tokens are selected/referenced → relay to all player pages. + * - If player-page tokens are involved → only relay if command is in relayCommands. */ const viewInterceptor = (msg) => { if (msg.type !== 'api') return; + if (scripting) return; var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; - var firstWord = msg.content.split(' ')[0]; + var content = msg.content.trim(); + if (!content) return; + var firstWord = content.split(' ')[0]; if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; - if (!msg.selected || msg.selected.length === 0) return; - if (msg.content.indexOf('{& select') !== -1) return; - var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); - if (tokens.length === 0) return; + // Check relaying set to prevent loops + var selectedIds = (msg.selected || []).map(function(sel) { return sel._id; }); + var key = relayKey(content, 'player|' + msg.playerid, selectedIds); + if (relaying.delete(key)) return; + if (content.indexOf('{& select') !== -1) return; - var pageId = tokens[0].get('_pageid'); - var isGM = playerIsGM(msg.playerid); + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); - // Case 1: GM on master page — relay based on view - if (isGM) { - var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); - if (!activeEntry) return; + // Scan command for token IDs that belong to linked groups + var idRx = /-[A-Za-z0-9_-]{19}/g; + var idsInCommand = (content.match(idRx) || []).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); + + // Classify: which IDs/tokens are on master pages vs player pages? + var masterTokens = []; + var hasPlayerPageRef = false; + var activeEntry = null; + + // Check selected tokens + tokens.forEach(function(t) { + var pid = t.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + masterTokens.push(t); + if (!activeEntry) activeEntry = entry; + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + // Check IDs in command text + idsInCommand.forEach(function(id) { + // Skip IDs already accounted for by selection + if (tokens.some(function(t) { return t.get('id') === id; })) return; + var obj = getObj('graphic', id); + if (!obj) return; + var pid = obj.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + // Check if this token is actually linked + var linked = entry[1].linkedTokens[id] || []; + var isLinked = linked.length > 0 || Object.values(entry[1].linkedTokens).some(function(arr) { return arr.indexOf(id) !== -1; }); + if (isLinked) { + masterTokens.push(obj); + if (!activeEntry) activeEntry = entry; + } + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + if (masterTokens.length === 0 && !hasPlayerPageRef) return; + + // Universal relay: master-page refs, no player-page refs + if (masterTokens.length > 0 && !hasPlayerPageRef) { var viewPlayerId = s.view; var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); - executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); + executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); return; } - // Case 2: Player on their page — relay if command is in relay-commands list - if (s.config.relayCommands.indexOf(firstWord) === -1) return; - - // Find which group/player this page belongs to - var activeEntry = null; - var sourcePlayerId = null; - Object.entries(s.activeGroups).forEach(function(e) { - Object.entries(e[1].playerPages).forEach(function(pp) { - if (pp[1].pageId === pageId) { activeEntry = e; sourcePlayerId = pp[0]; } + // Player-page involved: only relay if relayCommands allows it + if (hasPlayerPageRef && s.config.relayCommands.indexOf(firstWord) !== -1) { + // Find source player page + var sourcePlayerId = null; + var entry = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + var srcToken = tokens.find(function(t) { return t.get('_pageid') === pp[1].pageId; }); + if (srcToken) { entry = e; sourcePlayerId = pp[0]; } + }); }); - }); - if (!activeEntry) return; - - // Relay to all OTHER player pages + master - var targetPlayerIds = Object.keys(activeEntry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); - executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, true); + if (!entry) return; + var targetPlayerIds = Object.keys(entry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, content, targetPlayerIds, true); + } }; const registerEventHandlers = () => { on('chat:message', handleInput); on('chat:message', viewInterceptor); + on('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!rollcapture-ready') registerWithRollCapture(); + }); + registerWithRollCapture(); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); - setInterval(pollRelayQueue, 500); + on('change:attribute', onAttributeChanged); + on('change:graphic', onGraphicPropChanged); + on('change:graphic:gmnotes', onGmNotesChanged); }; return { checkInstall, registerEventHandlers }; diff --git a/Gaslight/README.md b/Gaslight/README.md index f2886ce638..f4e291a980 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -8,6 +8,7 @@ Per-player map perception for Roll20. Split players onto individual copies of a - [Anchor](https://github.com/Roll20/roll20-api-scripts/tree/master/Anchor) (spatial sync) - [Mirror](https://github.com/Roll20/roll20-api-scripts/tree/master/Mirror) (property sync) - [SelectManager](https://github.com/Roll20/roll20-api-scripts/tree/master/SelectManager) (command relay) +- [RollCapture](https://github.com/Roll20/roll20-api-scripts/tree/master/RollCapture) (optional, roll value extraction for scripting) ## Use Cases @@ -65,11 +66,16 @@ Controlled by `gaslight_sync` character attribute: ## Command Relay -Commands run on master page auto-relay to player pages: -- **Path 1 (IDs in command)**: immediate cross-page via ID replacement -- **Path 2 (selection only)**: queued, fires when GM navigates to target page +Any API command that references master-page linked tokens (via selection or token IDs in the command) is automatically relayed to all player pages with token IDs replaced by their linked counterparts. This happens transparently — no configuration needed. -Configure player auto-relay: `!gaslight config relay-add !token-mod` +**Rules:** +- Master-page tokens selected or IDs in command → auto-relay to all player pages +- Player-page tokens involved → only relay if the command is in `relayCommands` list +- Commands already relayed are not re-relayed (loop prevention) + +**Manual relay:** `!gaslight relay ` — explicitly relay to specific views. + +**Player auto-relay:** `!gaslight config relay-add !token-mod` — allow player-page commands to relay to other pages. ## Staging @@ -86,6 +92,54 @@ group: mygroup player: GM ``` +## Scripting + +Reactive per-player automation. Scripts stored in handouts evaluate per-viewer per-target, firing API commands conditionally based on captured roll values or token properties. + +### Setup + +1. Create a handout with your script +2. Place a pin on the master page and link it to the handout +3. Add config to the pin's GM notes: +``` +---GASLIGHT-SCRIPT--- +scope: token +filter: has gl_stealth_result +``` + +### Script Example + +``` +// Hide NPC from players who can't beat its stealth +!token-mod --ids @(target.token_id) --set {& if (any(@(viewer.passive_wisdom)) >= @(target.gl_stealth_result))} layer|objects {& else} layer|gmlayer {& end} +``` + +### Variables + +- `@(target.*)` — the NPC token being evaluated (resolved per viewer page) +- `@(target.gl_*)` — captured values (token gmnotes override, character attribute fallback) + +### Aggregate Functions (required for viewer.*/gm.*) + +- `any(@(viewer.field)) op value` — true if any viewer token passes +- `all(@(viewer.field)) op value` — true if all pass +- `max(@(viewer.field))` — highest value (via MathOps) +- `min(@(viewer.field))` — lowest value (via MathOps) +- `join(@(viewer.token_id))` — space-separated IDs for `--ids` targeting + +### Triggers + +Scripts auto-detect triggers from `@(target.gl_*)` references. Override in pin GM notes: +- `trigger: on change gl_stealth_result` — explicit trigger +- `trigger: manual only` — only fires via `!gaslight eval` + +### Evaluation + +- `!gaslight eval` — evaluate selected pins +- `!gaslight eval --all` — all pins in active groups +- `!gaslight eval ` — all pins linked to that handout +- Add `--dry-run` to preview without executing + ## License MIT diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 2f90899c60..85a15a1b38 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -1,6 +1,6 @@ # Gaslight TODO -## Done (v1.0.0) +## Done (v1.0.0 — shipped in Gaslight branch PR) - [x] Pre-setup split with test-first behavior - [x] Merge (tear down Anchor + Mirror, unassign players) - [x] Anchor-mode sync (NPC + player tokens via chain-linking) @@ -38,6 +38,7 @@ - [ ] Focus-ping on split ## v2 Ideas +- [ ] `!gaslight relay all --except ` flag - [ ] Config handout (editable in-game, live reload) - [ ] Group/page-level relay-command overrides - [ ] Config visibility toggle (hide gaslight text in HTML comment) @@ -48,6 +49,4 @@ - [ ] On-demand page cloning (if TruePageCopy exposes API) ## Known Issues -- Relay Path 1 (selection-based) requires GM to navigate to target page -- Roll20 limitation: sendChat as player carries their UI selection state - linkedTokens accumulates duplicates on repeated splits (cosmetic, deduped at use) diff --git a/Gaslight/script.json b/Gaslight/script.json index bf0a45dfef..2e9ccf90eb 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -1,12 +1,12 @@ { "name": "Gaslight", "script": "Gaslight.js", - "version": "1.0.0", - "previousversions": [], + "version": "2.0.0", + "previousversions": ["1.0.0", "1.1.0"], "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", "Mirror", "SelectManager"], + "dependencies": ["Anchor", "Mirror", "SelectManager", "RollCapture"], "modifies": { "graphic": "read, write", "text": "read, write", diff --git a/RollCapture/0.1.0/RollCapture.js b/RollCapture/0.1.0/RollCapture.js new file mode 100644 index 0000000000..142c040f8c --- /dev/null +++ b/RollCapture/0.1.0/RollCapture.js @@ -0,0 +1,416 @@ +// RollCapture v0.1.0 — Generic roll result extraction/storage for Roll20 +// Detects rolls via chat:message, extracts values per configurable rules, +// and emits captured data to registered consumer callbacks. + +const RollCapture = (() => { // eslint-disable-line no-unused-vars + 'use strict'; + + const SCRIPT_NAME = 'RollCapture'; + const SCRIPT_VERSION = '0.1.0'; + const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'when:', 'default:']; + const CMD = '!rollcapture'; + + let rules = []; + let callbacks = new Map(); + let pendingChoices = {}; // id → { captures, resolve info } + + // ─── Rule Parser ──────────────────────────────────────────────────────────── + + const parseRules = (text) => { + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#')); + const rule = { templates: [], nameField: '', charFields: [], blocks: [] }; + let currentBlock = null; + + for (const line of lines) { + if (line.startsWith('template:')) { + rule.templates = line.slice(9).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('name_field:')) { + rule.nameField = line.slice(11).trim(); + } else if (line.startsWith('char_field:')) { + rule.charFields = line.slice(11).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('when:')) { + currentBlock = { condition: line.slice(5).trim(), captures: {} }; + rule.blocks.push(currentBlock); + } else if (line.startsWith('default:')) { + currentBlock = { condition: null, captures: {} }; + rule.blocks.push(currentBlock); + } else if (currentBlock) { + // capture line: name: formula + const sep = line.indexOf(':'); + if (sep > 0) { + const name = line.slice(0, sep).trim(); + const formula = line.slice(sep + 1).trim(); + if (!KEYWORDS.some(k => line.startsWith(k))) { + currentBlock.captures[name] = formula; // empty string = clear + } + } + } + } + return rule; + }; + + // ─── Handout Loading ──────────────────────────────────────────────────────── + + const loadRulesFromHandouts = () => { + rules = []; + const handouts = findObjs({ type: 'handout' }).filter(h => + h.get('name').includes('[RollCapture]') || h.get('name').includes('[RC]') + ); + let loaded = 0; + handouts.forEach(h => { + h.get('notes', (notes) => { + if (!notes) return; + const text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + const rule = parseRules(text); + if (rule.templates.length) { + rule.handoutId = h.get('id'); + rule.handoutName = h.get('name'); + rules.push(rule); + loaded++; + } + }); + }); + setTimeout(() => log(`RollCapture: loaded ${loaded} rule(s) from ${handouts.length} handout(s)`), 500); + }; + + // ─── Field Resolution ─────────────────────────────────────────────────────── + + const buildFieldMap = (content, inlinerolls) => { + // Map field names to their inline roll totals + // {{r1=$[[2]]}} → fieldMap.r1 = inlinerolls[2].results.total + const map = {}; + const re = /\{\{(\w[\w-]*)=\$\[\[(\d+)\]\]\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + const idx = parseInt(m[2], 10); + if (inlinerolls[idx] && inlinerolls[idx].results) { + map[m[1]] = inlinerolls[idx].results.total; + } + } + return map; + }; + + const buildFlagMap = (content) => { + // Map fields with non-roll values: {{normal=1}}, {{attack=1}}, etc. + const map = {}; + const re = /\{\{(\w[\w-]*)=([^$}][^}]*)\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + map[m[1]] = m[2]; + } + // Also match empty fields: {{range=}} + const reEmpty = /\{\{(\w[\w-]*)=\}\}/g; + while ((m = reEmpty.exec(content)) !== null) { + map[m[1]] = ''; + } + return map; + }; + + // ─── Formula Evaluation ───────────────────────────────────────────────────── + + const evalFormula = (formula, fieldMap) => { + if (!formula) return undefined; // empty = clear + + // Function call: max(...), min(...), sum(...), choose(...) + const funcMatch = formula.match(/^(max|min|sum|choose)\((.+)\)$/); + if (funcMatch) { + const fn = funcMatch[1]; + const args = funcMatch[2].split(',').map(a => a.trim()); + const resolved = args.map(a => fieldMap[a]).filter(v => v !== undefined); + + if (resolved.length === 0) return undefined; + + switch (fn) { + case 'max': return Math.max(...resolved); + case 'min': return Math.min(...resolved); + case 'sum': return resolved.reduce((a, b) => a + b, 0); + case 'choose': { + const unique = [...new Set(resolved)]; + if (unique.length === 1) return unique[0]; + return { __choose: true, options: args.filter(a => fieldMap[a] !== undefined).map(a => ({ name: a, value: fieldMap[a] })) }; + } + } + } + + // Direct field reference + return fieldMap[formula] !== undefined ? fieldMap[formula] : undefined; + }; + + // ─── Condition Matching ───────────────────────────────────────────────────── + + const matchCondition = (condition, content) => { + // condition is something like "{{advantage=1}}" + return content.includes(condition); + }; + + // ─── Name Cleaning ────────────────────────────────────────────────────────── + + const cleanName = (raw) => { + if (!raw) return ''; + return raw + .replace(/^\^{/, '').replace(/}$/, '') // strip ^{ } + .replace(/-u$/, '') // strip -u suffix + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); // sanitize + }; + + // ─── Choose Prompt ────────────────────────────────────────────────────────── + + const promptChoose = (context, captureName, options) => { + const id = generateUUID(); + pendingChoices[id] = context; + const buttons = options.map(o => + `[${o.name}: ${o.value}](${CMD} --choose ${id} ${captureName} ${o.value})` + ).join(' '); + whisper(`**${context.charName} — ${context.rollName}** (${captureName}): ${buttons}`); + }; + + // ─── Core Processing ──────────────────────────────────────────────────────── + + const processMessage = (msg) => { + if (!msg.rolltemplate || !msg.inlinerolls) return; + + const template = msg.rolltemplate; + const content = msg.content; + const inlinerolls = msg.inlinerolls; + + for (const rule of rules) { + if (!rule.templates.includes(template)) continue; + + const fieldMap = buildFieldMap(content, inlinerolls); + const flagMap = buildFlagMap(content); + + // Resolve roll name + const rollName = flagMap[rule.nameField] || fieldMap[rule.nameField] || ''; + + // Resolve character name + let charName = ''; + for (const cf of rule.charFields) { + if (flagMap[cf]) { charName = flagMap[cf]; break; } + } + // Also check bare charname= at end of content + if (!charName) { + const bareMatch = content.match(/charname=(.+?)(?:\s*"|\s*$)/); + if (bareMatch) charName = bareMatch[1].replace(/\\"/g, '"').trim(); + } + + // Resolve character ID(s) + const chars = charName ? findObjs({ type: 'character', name: charName }) : []; + if (chars.length > 1) { + whisper(`⚠️ Multiple character sheets named ${charName} — capturing for all matches.`); + } + + // Find matching block + let activeBlock = null; + for (const block of rule.blocks) { + if (block.condition === null) continue; // skip default for now + if (matchCondition(block.condition, content)) { + activeBlock = block; + break; + } + } + // Fallback to default + if (!activeBlock) { + activeBlock = rule.blocks.find(b => b.condition === null); + } + if (!activeBlock) continue; + + // Process captures + const results = {}; + let hasChoose = false; + + for (const [captureName, formula] of Object.entries(activeBlock.captures)) { + const value = evalFormula(formula, fieldMap); + if (value && value.__choose) { + hasChoose = true; + const charId = chars.length > 0 ? chars[0].get('id') : null; + const context = { rule, rollName: cleanName(rollName), charName, charId, playerId: msg.playerid, results, msg, chars }; + promptChoose(context, captureName, value.options); + } else { + results[captureName] = value; + } + } + + if (!hasChoose) { + chars.forEach(c => { + emitCapture(charName, c.get('id'), cleanName(rollName), results, msg.playerid, msg); + }); + if (chars.length === 0) { + emitCapture(charName, null, cleanName(rollName), results, msg.playerid, msg); + } + } + } + }; + + // ─── Callback Registry ────────────────────────────────────────────────────── + + const emitCapture = (charName, charId, rollName, captures, playerId, msg) => { + const event = { charName, charId, rollName, captures, playerId, msg }; + for (const fn of callbacks.values()) { + fn(event); + } + fireAbility(charId, rollName, captures, playerId); + }; + + // ─── Ability Firing ───────────────────────────────────────────────────────── + + const fireAbility = (charId, rollName, captures, playerId) => { + if (!charId) return; + + const abilities = findObjs({ type: 'ability', _characterid: charId }); + const specificName = 'rc_' + rollName; + + const any_abils = abilities.filter(a => a.get('name') === 'rc_any'); + const match_abils = abilities.filter(a => a.get('name') === specificName); + const default_abils = match_abils.length === 0 ? abilities.filter(a => a.get('name') === 'rc_default') : []; + for (const a of [...any_abils, ...match_abils, ...default_abils]) { + runAbility(a, captures, rollName, playerId); + } + }; + + const runAbility = (ability, captures, rollName, playerId) => { + const action = ability.get('action'); + if (!action) return; + let cmd = action.replace(/\$\{rollname\}/gi, rollName); + for (const [varName, value] of Object.entries(captures)) { + const captureName = varName.split('_').pop(); + cmd = cmd.replace(new RegExp('\\$\\{' + captureName + '\\}', 'gi'), value !== undefined ? value : ''); + } + sendChat('player|' + playerId, cmd); + }; + + const onCapture = (sourceId, fn) => { + callbacks.set(sourceId, fn); + }; + + // ─── Command Handling ─────────────────────────────────────────────────────── + + const handleCommand = (msg) => { + const args = msg.content.split(/\s+/); + args.shift(); // remove !rollcapture + + if (args[0] === '--choose') { + const [, id, captureName, value] = args; + const ctx = pendingChoices[id]; + if (!ctx) return whisper('Choice expired or invalid.'); + ctx.results[captureName] = parseInt(value, 10) || 0; + delete pendingChoices[id]; + (ctx.chars || []).forEach(c => { + emitCapture(ctx.charName, c.get('id'), ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + }); + if (!ctx.chars || ctx.chars.length === 0) { + emitCapture(ctx.charName, ctx.charId, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + } + whisper(`Captured ${captureName} = ${value}`); + return; + } + + if (args[0] === 'reload') { + loadRulesFromHandouts(); + whisper('Rules reloaded.'); + return; + } + + if (args[0] === 'status') { + whisper(`**RollCapture v${SCRIPT_VERSION}**
Rules: ${rules.length}
Callbacks: ${callbacks.size}
Pending choices: ${Object.keys(pendingChoices).length}`); + return; + } + + if (args[0] === 'rules') { + if (!rules.length) return whisper('No rules loaded.'); + const list = rules.map((r, i) => `${i + 1}. ${stripTag(r.handoutName)}`).join('
'); + whisper(`**Loaded Rules:**
${list}`); + return; + } + + if (args[0] === 'rule') { + const name = args.slice(1).join(' '); + if (!name) return whisper('Usage: !rollcapture rule <name>'); + const tag = '[RC] ' + name; + let handout = findObjs({ type: 'handout', name: tag })[0] + || findObjs({ type: 'handout', name: '[RollCapture] ' + name })[0]; + let created = false; + if (!handout) { + handout = createObj('handout', { name: tag }); + handout.set('notes', `
# RollCapture Rule: ${name}
+# Lines starting with # are comments.
+#
+# template: which roll template(s) to match (comma-separated)
+# name_field: template field containing the roll name (e.g. skill name)
+# char_field: template field(s) for character identification
+# when: {{flag=value}} — condition block, captures follow
+# default: — captures when no "when" matches
+# Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]
+# Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)
+# Missing fields are dropped from functions (not set to 0).
+# Empty capture (name: ) clears that value.
+#
+# To react to captures, add abilities to the character sheet:
+#   rc_any — runs on every capture
+#   rc_<rollname> — runs for that specific roll (e.g. rc_stealth)
+#   rc_default — runs when no specific rc_<rollname> exists
+# Use \${rollname} and \${capturename} in ability actions.
+
+template: simple
+name_field: rname
+char_field: charname
+default:
+    result: r1
+
`); + created = true; + } + const label = created ? 'Created' : 'Found'; + whisper(`${label}: ${stripTag(handout.get('name'))}`); + return; + } + + whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:
` + + `!rollcapture status — Show status
` + + `!rollcapture rules — List loaded rules
` + + `!rollcapture rule <name> — Open or create a rule handout
` + + `!rollcapture reload — Reload rules from handouts`); + }; + + // ─── Utilities ────────────────────────────────────────────────────────────── + + const whisper = (msg) => sendChat('RollCapture', `/w gm ${msg}`); + + const stripTag = (name) => name.replace(/\[RollCapture\]\s*/i, '').replace(/\[RC\]\s*/i, '').trim(); + + const generateUUID = () => { + return 'rc_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); + }; + + // ─── Public API ───────────────────────────────────────────────────────────── + + const registerEventHandlers = () => { + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content.split(' ')[0] === CMD) { + handleCommand(msg); + } else if (msg.rolltemplate) { + processMessage(msg); + } + }); + }; + + on('ready', () => { + loadRulesFromHandouts(); + registerEventHandlers(); + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); + sendChat('', `!${SCRIPT_NAME.toLowerCase()}-ready`, null, { noarchive: true }); + }); + + return { + onCapture, + getCapturedValue: () => null, // placeholder — consumers store their own way + getLastCapture: () => null, // placeholder + registerRule: (ruleObj) => rules.push(ruleObj), + }; +})(); diff --git a/RollCapture/README.md b/RollCapture/README.md new file mode 100644 index 0000000000..037167b14c --- /dev/null +++ b/RollCapture/README.md @@ -0,0 +1,97 @@ +# RollCapture + +Generic roll result extraction for Roll20. Listens to roll template messages, extracts values per configurable rules, and fires character abilities with captured values. + +## Features + +- Configurable capture rules via handouts tagged `[RollCapture]` or `[RC]` +- Conditional extraction (`when:`/`default:` blocks) +- Formulas: direct value, `max()`, `min()`, `sum()`, `choose()` (GM prompt) +- Character ability firing: `rc_any`, `rc_`, `rc_default` +- `onCapture` API for programmatic consumers (e.g. Gaslight) +- `!rollcapture-ready` event for load-order-safe registration + +## Quick Start + +1. Install RollCapture +2. Run `!rollcapture rule D&D 5E Skills` to create a rule handout +3. Edit the handout with your capture rules +4. Run `!rollcapture reload` +5. Roll a skill check — captured values fire matching abilities + +## Rule Format + +Create a handout with `[RC]` or `[RollCapture]` in the name. Lines starting with `#` are comments. + +``` +template: npc, simple +name_field: rname +char_field: name, charname +when: {{advantage=1}} +result: max(r1, r2) +when: {{disadvantage=1}} +result: min(r1, r2) +when: {{always=1}} +result: choose(r1, r2) +default: +result: r1 +``` + +### Fields + +- `template:` — roll template name(s) to match (comma-separated) +- `name_field:` — template field containing the roll name +- `char_field:` — template field(s) for character identification +- `when: ` — condition block: if pattern found in content, use captures below +- `default:` — captures when no `when` condition matches +- Capture lines: `name: formula` — extract a value + +### Field Resolution + +Template fields like `{{r1=$[[2]]}}` map field name `r1` to `inlinerolls[2].results.total`. Formulas reference these field names directly. + +### Formulas + +- `fieldname` — direct value +- `max(a, b, ...)` — highest among present fields +- `min(a, b, ...)` — lowest among present fields +- `sum(a, b, ...)` — total of present fields +- `choose(a, b, ...)` — whisper GM with buttons to pick (auto-resolves if all equal) + +Missing fields are dropped from functions, not set to 0. + +## Character Abilities + +After a capture, RollCapture checks the rolling character for abilities: + +1. **`rc_any`** — fires on every capture +2. **`rc_`** — fires for that specific roll (e.g. `rc_stealth`) +3. **`rc_default`** — fires only when no specific `rc_` exists + +Use `${rollname}` and `${capturename}` (e.g. `${result}`) in ability actions for value substitution. + +## Commands + +| Command | Description | +|---------|-------------| +| `!rollcapture` | Show help | +| `!rollcapture status` | Show status | +| `!rollcapture rules` | List loaded rules with links | +| `!rollcapture rule ` | Open or create a rule handout | +| `!rollcapture reload` | Reload rules from handouts | + +## API (for other scripts) + +```javascript +// Register for capture events (dedupes by sourceId) +RollCapture.onCapture('MyScript', function(event) { + // event = { charName, rollName, captures, playerId, msg } + log(event.rollName + ': ' + JSON.stringify(event.captures)); +}); +``` + +RollCapture emits `!rollcapture-ready` on init for load-order-safe registration. + +## License + +MIT diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js new file mode 100644 index 0000000000..142c040f8c --- /dev/null +++ b/RollCapture/RollCapture.js @@ -0,0 +1,416 @@ +// RollCapture v0.1.0 — Generic roll result extraction/storage for Roll20 +// Detects rolls via chat:message, extracts values per configurable rules, +// and emits captured data to registered consumer callbacks. + +const RollCapture = (() => { // eslint-disable-line no-unused-vars + 'use strict'; + + const SCRIPT_NAME = 'RollCapture'; + const SCRIPT_VERSION = '0.1.0'; + const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'when:', 'default:']; + const CMD = '!rollcapture'; + + let rules = []; + let callbacks = new Map(); + let pendingChoices = {}; // id → { captures, resolve info } + + // ─── Rule Parser ──────────────────────────────────────────────────────────── + + const parseRules = (text) => { + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#')); + const rule = { templates: [], nameField: '', charFields: [], blocks: [] }; + let currentBlock = null; + + for (const line of lines) { + if (line.startsWith('template:')) { + rule.templates = line.slice(9).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('name_field:')) { + rule.nameField = line.slice(11).trim(); + } else if (line.startsWith('char_field:')) { + rule.charFields = line.slice(11).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('when:')) { + currentBlock = { condition: line.slice(5).trim(), captures: {} }; + rule.blocks.push(currentBlock); + } else if (line.startsWith('default:')) { + currentBlock = { condition: null, captures: {} }; + rule.blocks.push(currentBlock); + } else if (currentBlock) { + // capture line: name: formula + const sep = line.indexOf(':'); + if (sep > 0) { + const name = line.slice(0, sep).trim(); + const formula = line.slice(sep + 1).trim(); + if (!KEYWORDS.some(k => line.startsWith(k))) { + currentBlock.captures[name] = formula; // empty string = clear + } + } + } + } + return rule; + }; + + // ─── Handout Loading ──────────────────────────────────────────────────────── + + const loadRulesFromHandouts = () => { + rules = []; + const handouts = findObjs({ type: 'handout' }).filter(h => + h.get('name').includes('[RollCapture]') || h.get('name').includes('[RC]') + ); + let loaded = 0; + handouts.forEach(h => { + h.get('notes', (notes) => { + if (!notes) return; + const text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + const rule = parseRules(text); + if (rule.templates.length) { + rule.handoutId = h.get('id'); + rule.handoutName = h.get('name'); + rules.push(rule); + loaded++; + } + }); + }); + setTimeout(() => log(`RollCapture: loaded ${loaded} rule(s) from ${handouts.length} handout(s)`), 500); + }; + + // ─── Field Resolution ─────────────────────────────────────────────────────── + + const buildFieldMap = (content, inlinerolls) => { + // Map field names to their inline roll totals + // {{r1=$[[2]]}} → fieldMap.r1 = inlinerolls[2].results.total + const map = {}; + const re = /\{\{(\w[\w-]*)=\$\[\[(\d+)\]\]\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + const idx = parseInt(m[2], 10); + if (inlinerolls[idx] && inlinerolls[idx].results) { + map[m[1]] = inlinerolls[idx].results.total; + } + } + return map; + }; + + const buildFlagMap = (content) => { + // Map fields with non-roll values: {{normal=1}}, {{attack=1}}, etc. + const map = {}; + const re = /\{\{(\w[\w-]*)=([^$}][^}]*)\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + map[m[1]] = m[2]; + } + // Also match empty fields: {{range=}} + const reEmpty = /\{\{(\w[\w-]*)=\}\}/g; + while ((m = reEmpty.exec(content)) !== null) { + map[m[1]] = ''; + } + return map; + }; + + // ─── Formula Evaluation ───────────────────────────────────────────────────── + + const evalFormula = (formula, fieldMap) => { + if (!formula) return undefined; // empty = clear + + // Function call: max(...), min(...), sum(...), choose(...) + const funcMatch = formula.match(/^(max|min|sum|choose)\((.+)\)$/); + if (funcMatch) { + const fn = funcMatch[1]; + const args = funcMatch[2].split(',').map(a => a.trim()); + const resolved = args.map(a => fieldMap[a]).filter(v => v !== undefined); + + if (resolved.length === 0) return undefined; + + switch (fn) { + case 'max': return Math.max(...resolved); + case 'min': return Math.min(...resolved); + case 'sum': return resolved.reduce((a, b) => a + b, 0); + case 'choose': { + const unique = [...new Set(resolved)]; + if (unique.length === 1) return unique[0]; + return { __choose: true, options: args.filter(a => fieldMap[a] !== undefined).map(a => ({ name: a, value: fieldMap[a] })) }; + } + } + } + + // Direct field reference + return fieldMap[formula] !== undefined ? fieldMap[formula] : undefined; + }; + + // ─── Condition Matching ───────────────────────────────────────────────────── + + const matchCondition = (condition, content) => { + // condition is something like "{{advantage=1}}" + return content.includes(condition); + }; + + // ─── Name Cleaning ────────────────────────────────────────────────────────── + + const cleanName = (raw) => { + if (!raw) return ''; + return raw + .replace(/^\^{/, '').replace(/}$/, '') // strip ^{ } + .replace(/-u$/, '') // strip -u suffix + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); // sanitize + }; + + // ─── Choose Prompt ────────────────────────────────────────────────────────── + + const promptChoose = (context, captureName, options) => { + const id = generateUUID(); + pendingChoices[id] = context; + const buttons = options.map(o => + `[${o.name}: ${o.value}](${CMD} --choose ${id} ${captureName} ${o.value})` + ).join(' '); + whisper(`**${context.charName} — ${context.rollName}** (${captureName}): ${buttons}`); + }; + + // ─── Core Processing ──────────────────────────────────────────────────────── + + const processMessage = (msg) => { + if (!msg.rolltemplate || !msg.inlinerolls) return; + + const template = msg.rolltemplate; + const content = msg.content; + const inlinerolls = msg.inlinerolls; + + for (const rule of rules) { + if (!rule.templates.includes(template)) continue; + + const fieldMap = buildFieldMap(content, inlinerolls); + const flagMap = buildFlagMap(content); + + // Resolve roll name + const rollName = flagMap[rule.nameField] || fieldMap[rule.nameField] || ''; + + // Resolve character name + let charName = ''; + for (const cf of rule.charFields) { + if (flagMap[cf]) { charName = flagMap[cf]; break; } + } + // Also check bare charname= at end of content + if (!charName) { + const bareMatch = content.match(/charname=(.+?)(?:\s*"|\s*$)/); + if (bareMatch) charName = bareMatch[1].replace(/\\"/g, '"').trim(); + } + + // Resolve character ID(s) + const chars = charName ? findObjs({ type: 'character', name: charName }) : []; + if (chars.length > 1) { + whisper(`⚠️ Multiple character sheets named ${charName} — capturing for all matches.`); + } + + // Find matching block + let activeBlock = null; + for (const block of rule.blocks) { + if (block.condition === null) continue; // skip default for now + if (matchCondition(block.condition, content)) { + activeBlock = block; + break; + } + } + // Fallback to default + if (!activeBlock) { + activeBlock = rule.blocks.find(b => b.condition === null); + } + if (!activeBlock) continue; + + // Process captures + const results = {}; + let hasChoose = false; + + for (const [captureName, formula] of Object.entries(activeBlock.captures)) { + const value = evalFormula(formula, fieldMap); + if (value && value.__choose) { + hasChoose = true; + const charId = chars.length > 0 ? chars[0].get('id') : null; + const context = { rule, rollName: cleanName(rollName), charName, charId, playerId: msg.playerid, results, msg, chars }; + promptChoose(context, captureName, value.options); + } else { + results[captureName] = value; + } + } + + if (!hasChoose) { + chars.forEach(c => { + emitCapture(charName, c.get('id'), cleanName(rollName), results, msg.playerid, msg); + }); + if (chars.length === 0) { + emitCapture(charName, null, cleanName(rollName), results, msg.playerid, msg); + } + } + } + }; + + // ─── Callback Registry ────────────────────────────────────────────────────── + + const emitCapture = (charName, charId, rollName, captures, playerId, msg) => { + const event = { charName, charId, rollName, captures, playerId, msg }; + for (const fn of callbacks.values()) { + fn(event); + } + fireAbility(charId, rollName, captures, playerId); + }; + + // ─── Ability Firing ───────────────────────────────────────────────────────── + + const fireAbility = (charId, rollName, captures, playerId) => { + if (!charId) return; + + const abilities = findObjs({ type: 'ability', _characterid: charId }); + const specificName = 'rc_' + rollName; + + const any_abils = abilities.filter(a => a.get('name') === 'rc_any'); + const match_abils = abilities.filter(a => a.get('name') === specificName); + const default_abils = match_abils.length === 0 ? abilities.filter(a => a.get('name') === 'rc_default') : []; + for (const a of [...any_abils, ...match_abils, ...default_abils]) { + runAbility(a, captures, rollName, playerId); + } + }; + + const runAbility = (ability, captures, rollName, playerId) => { + const action = ability.get('action'); + if (!action) return; + let cmd = action.replace(/\$\{rollname\}/gi, rollName); + for (const [varName, value] of Object.entries(captures)) { + const captureName = varName.split('_').pop(); + cmd = cmd.replace(new RegExp('\\$\\{' + captureName + '\\}', 'gi'), value !== undefined ? value : ''); + } + sendChat('player|' + playerId, cmd); + }; + + const onCapture = (sourceId, fn) => { + callbacks.set(sourceId, fn); + }; + + // ─── Command Handling ─────────────────────────────────────────────────────── + + const handleCommand = (msg) => { + const args = msg.content.split(/\s+/); + args.shift(); // remove !rollcapture + + if (args[0] === '--choose') { + const [, id, captureName, value] = args; + const ctx = pendingChoices[id]; + if (!ctx) return whisper('Choice expired or invalid.'); + ctx.results[captureName] = parseInt(value, 10) || 0; + delete pendingChoices[id]; + (ctx.chars || []).forEach(c => { + emitCapture(ctx.charName, c.get('id'), ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + }); + if (!ctx.chars || ctx.chars.length === 0) { + emitCapture(ctx.charName, ctx.charId, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + } + whisper(`Captured ${captureName} = ${value}`); + return; + } + + if (args[0] === 'reload') { + loadRulesFromHandouts(); + whisper('Rules reloaded.'); + return; + } + + if (args[0] === 'status') { + whisper(`**RollCapture v${SCRIPT_VERSION}**
Rules: ${rules.length}
Callbacks: ${callbacks.size}
Pending choices: ${Object.keys(pendingChoices).length}`); + return; + } + + if (args[0] === 'rules') { + if (!rules.length) return whisper('No rules loaded.'); + const list = rules.map((r, i) => `${i + 1}. ${stripTag(r.handoutName)}`).join('
'); + whisper(`**Loaded Rules:**
${list}`); + return; + } + + if (args[0] === 'rule') { + const name = args.slice(1).join(' '); + if (!name) return whisper('Usage: !rollcapture rule <name>'); + const tag = '[RC] ' + name; + let handout = findObjs({ type: 'handout', name: tag })[0] + || findObjs({ type: 'handout', name: '[RollCapture] ' + name })[0]; + let created = false; + if (!handout) { + handout = createObj('handout', { name: tag }); + handout.set('notes', `
# RollCapture Rule: ${name}
+# Lines starting with # are comments.
+#
+# template: which roll template(s) to match (comma-separated)
+# name_field: template field containing the roll name (e.g. skill name)
+# char_field: template field(s) for character identification
+# when: {{flag=value}} — condition block, captures follow
+# default: — captures when no "when" matches
+# Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]
+# Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)
+# Missing fields are dropped from functions (not set to 0).
+# Empty capture (name: ) clears that value.
+#
+# To react to captures, add abilities to the character sheet:
+#   rc_any — runs on every capture
+#   rc_<rollname> — runs for that specific roll (e.g. rc_stealth)
+#   rc_default — runs when no specific rc_<rollname> exists
+# Use \${rollname} and \${capturename} in ability actions.
+
+template: simple
+name_field: rname
+char_field: charname
+default:
+    result: r1
+
`); + created = true; + } + const label = created ? 'Created' : 'Found'; + whisper(`${label}: ${stripTag(handout.get('name'))}`); + return; + } + + whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:
` + + `!rollcapture status — Show status
` + + `!rollcapture rules — List loaded rules
` + + `!rollcapture rule <name> — Open or create a rule handout
` + + `!rollcapture reload — Reload rules from handouts`); + }; + + // ─── Utilities ────────────────────────────────────────────────────────────── + + const whisper = (msg) => sendChat('RollCapture', `/w gm ${msg}`); + + const stripTag = (name) => name.replace(/\[RollCapture\]\s*/i, '').replace(/\[RC\]\s*/i, '').trim(); + + const generateUUID = () => { + return 'rc_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); + }; + + // ─── Public API ───────────────────────────────────────────────────────────── + + const registerEventHandlers = () => { + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content.split(' ')[0] === CMD) { + handleCommand(msg); + } else if (msg.rolltemplate) { + processMessage(msg); + } + }); + }; + + on('ready', () => { + loadRulesFromHandouts(); + registerEventHandlers(); + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); + sendChat('', `!${SCRIPT_NAME.toLowerCase()}-ready`, null, { noarchive: true }); + }); + + return { + onCapture, + getCapturedValue: () => null, // placeholder — consumers store their own way + getLastCapture: () => null, // placeholder + registerRule: (ruleObj) => rules.push(ruleObj), + }; +})(); diff --git a/RollCapture/TODO.md b/RollCapture/TODO.md new file mode 100644 index 0000000000..edff6e209e --- /dev/null +++ b/RollCapture/TODO.md @@ -0,0 +1,9 @@ +# RollCapture TODO + +## Future +- [ ] Hot-reload rules when handouts are edited (vs requiring `!rollcapture reload`) +- [ ] Handle sheets that don't use standard `{{field=value}}` format +- [ ] Capture history (last N rolls per character) +- [ ] Auto-detect character sheet and suggest appropriate rules +- [ ] `!rollcapture clear` — remove pending choices +- [ ] Help handout on init diff --git a/RollCapture/script.json b/RollCapture/script.json new file mode 100644 index 0000000000..bdb514f653 --- /dev/null +++ b/RollCapture/script.json @@ -0,0 +1,17 @@ +{ + "name": "RollCapture", + "script": "RollCapture.js", + "version": "0.1.0", + "previousversions": [], + "description": "Generic roll result extraction for Roll20. Listens to roll template messages, extracts values per configurable rules stored in handouts, and fires character abilities with captured values.\n\nFeatures:\n- Configurable capture rules via handouts tagged [RollCapture] or [RC]\n- Conditional extraction (when/default blocks)\n- Formulas: direct value, max(), min(), sum(), choose() (GM prompt)\n- Character ability firing: rc_any, rc_, rc_default\n- onCapture API for programmatic consumers\n\nCommands:\n- `!rollcapture` -- Show help\n- `!rollcapture status` -- Show status\n- `!rollcapture rules` -- List loaded rules with links\n- `!rollcapture rule ` -- Open or create a rule handout\n- `!rollcapture reload` -- Reload rules from handouts", + "authors": "Kenan Millet", + "roll20userid": "2614613", + "dependencies": [], + "modifies": { + "character": "read", + "ability": "read", + "handout": "read, write" + }, + "conflicts": [], + "useroptions": [] +}