From bb3083af92d4071e69d4b2403529eb71708a1762 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 17 Jul 2026 07:12:59 -0400 Subject: [PATCH 1/2] Mirror: add missing properties (sides, reactions, gm_only_tooltip, night_vision_tint, renderAsDarkness) + reactions group --- Mirror/Mirror.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 97afb1e88..a13d0d8a6 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -1,6 +1,6 @@ // ============================================================================= -// Mirror v1.1.1 -// Last Updated: 2026-07-05 +// Mirror v1.1.2 +// Last Updated: 2026-07-17 // Author: Kenan Millet // // Description: @@ -26,7 +26,7 @@ var Mirror = Mirror || (() => { 'use strict'; const SCRIPT_NAME = 'Mirror'; - const SCRIPT_VERSION = '1.1.1'; + const SCRIPT_VERSION = '1.1.2'; const CMD = '!mirror'; // All syncable graphic properties @@ -53,9 +53,9 @@ var Mirror = Mirror || (() => { 'playersedit_aura1', 'playersedit_aura2', // Appearance 'tint_color', 'statusmarkers', 'baseOpacity', - 'isdrawing', 'currentSide', + 'isdrawing', 'currentSide', 'sides', // Tooltip - 'tooltip', 'show_tooltip', + 'tooltip', 'show_tooltip', 'gm_only_tooltip', // UDL Light (emission) 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance', 'dim_light_opacity', @@ -64,7 +64,7 @@ var Mirror = Mirror || (() => { 'lightColor', // UDL Vision (sight) 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', - 'night_vision_effect', 'light_sensitivity_multiplier', + 'night_vision_effect', 'night_vision_tint', 'light_sensitivity_multiplier', 'has_limit_field_of_vision', 'limit_field_of_vision_center', 'limit_field_of_vision_total', 'has_limit_field_of_night_vision', 'limit_field_of_night_vision_center', 'limit_field_of_night_vision_total', // Legacy Dynamic Lighting @@ -74,7 +74,9 @@ var Mirror = Mirror || (() => { // Movement / interaction 'lockMovement', 'disableSnapping', 'disableTokenMenu', // Foreground layer - 'fadeOnOverlap', 'fadeOpacity', 'renderAsScenery' + 'fadeOnOverlap', 'fadeOpacity', 'renderAsScenery', 'renderAsDarkness', + // Reactions + 'interactionTriggered', 'interactionManualReset' ]; // Property groups @@ -103,13 +105,14 @@ var Mirror = Mirror || (() => { 'light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers', 'light_losangle', 'light_multiplier', 'adv_fow_view_distance'], sight: ['has_bright_light_vision', 'has_night_vision', 'night_vision_distance', - 'night_vision_effect', 'light_sensitivity_multiplier', + 'night_vision_effect', 'night_vision_tint', 'light_sensitivity_multiplier', 'light_hassight', 'has_limit_field_of_vision', 'limit_field_of_vision_center', 'limit_field_of_vision_total', 'has_limit_field_of_night_vision', 'limit_field_of_night_vision_center', 'limit_field_of_night_vision_total'], flip: ['flipv', 'fliph'], anchor: ['left', 'top', 'width', 'height', 'rotation', 'flipv', 'fliph', 'layer'], - tooltip: ['tooltip', 'show_tooltip'] + tooltip: ['tooltip', 'show_tooltip', 'gm_only_tooltip'], + reactions: ['interactionTriggered', 'interactionManualReset', 'fadeOnOverlap', 'fadeOpacity'] }; // ========================================================================= From 401a1fe7c4bd88f8fd368351c2300d9c87cff16a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 17 Jul 2026 13:38:00 -0400 Subject: [PATCH 2/2] Mirror v1.2.0: ScriptKit integration, new properties and groups --- Mirror/1.2.0/Mirror.js | 1400 ++++++++++++++++++++++++++++++++++++++++ Mirror/Mirror.js | 261 +++++--- Mirror/README.md | 15 +- Mirror/script.json | 7 +- 4 files changed, 1604 insertions(+), 79 deletions(-) create mode 100644 Mirror/1.2.0/Mirror.js diff --git a/Mirror/1.2.0/Mirror.js b/Mirror/1.2.0/Mirror.js new file mode 100644 index 000000000..2d05c6fc3 --- /dev/null +++ b/Mirror/1.2.0/Mirror.js @@ -0,0 +1,1400 @@ +// ============================================================================= +// Mirror v1.2.0 +// Last Updated: 2026-07-17 +// Author: Kenan Millet +// +// Description: +// Flat property syncing between tokens. No transforms, no offsets -- when a +// property changes on one token, the same value is copied to linked tokens. +// Supports unidirectional (link) and bidirectional ring (chain) modes. +// Unidirectional links hard-lock children by default (changes reverted). +// +// Dependencies: none +// +// Commands: +// !mirror link [--soft] [props/groups] [ids...] Unidirectional link +// !mirror unlink [props/groups] [ids...] Remove link or properties +// !mirror chain [props/groups] [ids...] Bidirectional ring link +// !mirror unchain [props/groups] [ids...] Remove chain or properties +// !mirror status Show links for selected +// !mirror --help Command reference +// ============================================================================= + +/* global on, sendChat, getObj, findObjs, playerIsGM, log, state */ + +var Mirror = Mirror || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Mirror'; + const SCRIPT_VERSION = '1.2.0'; + const CMD = '!mirror'; + + // All syncable graphic properties + const ALL_PROPS = [ + // Spatial + 'left', 'top', 'width', 'height', 'rotation', + 'flipv', 'fliph', 'layer', + // Bars + 'bar1_value', 'bar1_max', 'bar1_link', + 'bar2_value', 'bar2_max', 'bar2_link', + 'bar3_value', 'bar3_max', 'bar3_link', + 'bar4_value', 'bar4_max', 'bar4_link', + 'bar_location', 'compact_bar', + // Bar visibility + 'showplayers_bar1', 'showplayers_bar2', 'showplayers_bar3', 'showplayers_bar4', + 'playersedit_bar1', 'playersedit_bar2', 'playersedit_bar3', 'playersedit_bar4', + 'bar1_num_permission', 'bar2_num_permission', 'bar3_num_permission', 'bar4_num_permission', + // Name + 'name', 'showname', 'showplayers_name', 'playersedit_name', + // Auras + 'aura1_radius', 'aura1_color', 'aura1_square', 'aura1_options', + 'aura2_radius', 'aura2_color', 'aura2_square', 'aura2_options', + 'showplayers_aura1', 'showplayers_aura2', + 'playersedit_aura1', 'playersedit_aura2', + // Appearance + 'tint_color', 'statusmarkers', 'baseOpacity', + 'isdrawing', 'currentSide', 'sides', + // Tooltip + 'tooltip', 'show_tooltip', 'gm_only_tooltip', + // UDL Light (emission) + 'emits_bright_light', 'bright_light_distance', + 'emits_low_light', 'low_light_distance', 'dim_light_opacity', + 'has_directional_bright_light', 'directional_bright_light_center', 'directional_bright_light_total', + 'has_directional_dim_light', 'directional_dim_light_center', 'directional_dim_light_total', + 'lightColor', + // UDL Vision (sight) + 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', + 'night_vision_effect', 'night_vision_tint', 'light_sensitivity_multiplier', + 'has_limit_field_of_vision', 'limit_field_of_vision_center', 'limit_field_of_vision_total', + 'has_limit_field_of_night_vision', 'limit_field_of_night_vision_center', 'limit_field_of_night_vision_total', + // Legacy Dynamic Lighting + 'light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers', + 'light_hassight', 'light_losangle', 'light_multiplier', + 'adv_fow_view_distance', + // Movement / interaction + 'lockMovement', 'disableSnapping', 'disableTokenMenu', + // Foreground layer + 'fadeOnOverlap', 'fadeOpacity', 'renderAsScenery', 'renderAsDarkness', + // Reactions + 'interactionTriggered', 'interactionManualReset' + ]; + + // Property groups + const PROP_GROUPS = { + position: ['left', 'top'], + size: ['width', 'height'], + spatial: ['left', 'top', 'rotation', 'width', 'height'], + bars: ['bar1_value', 'bar1_max', 'bar1_link', + 'bar2_value', 'bar2_max', 'bar2_link', + 'bar3_value', 'bar3_max', 'bar3_link', + 'bar4_value', 'bar4_max', 'bar4_link', + 'bar_location', 'compact_bar', + 'showplayers_bar1', 'showplayers_bar2', 'showplayers_bar3', 'showplayers_bar4', + 'playersedit_bar1', 'playersedit_bar2', 'playersedit_bar3', 'playersedit_bar4', + 'bar1_num_permission', 'bar2_num_permission', 'bar3_num_permission', 'bar4_num_permission'], + name: ['name', 'showname', 'showplayers_name', 'playersedit_name'], + auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura1_options', + 'aura2_radius', 'aura2_color', 'aura2_square', 'aura2_options', + 'showplayers_aura1', 'showplayers_aura2', + 'playersedit_aura1', 'playersedit_aura2'], + light: ['emits_bright_light', 'bright_light_distance', + 'emits_low_light', 'low_light_distance', 'dim_light_opacity', + 'has_directional_bright_light', 'directional_bright_light_center', 'directional_bright_light_total', + 'has_directional_dim_light', 'directional_dim_light_center', 'directional_dim_light_total', + 'lightColor', + 'light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers', + 'light_losangle', 'light_multiplier', 'adv_fow_view_distance'], + sight: ['has_bright_light_vision', 'has_night_vision', 'night_vision_distance', + 'night_vision_effect', 'night_vision_tint', 'light_sensitivity_multiplier', + 'light_hassight', + 'has_limit_field_of_vision', 'limit_field_of_vision_center', 'limit_field_of_vision_total', + 'has_limit_field_of_night_vision', 'limit_field_of_night_vision_center', 'limit_field_of_night_vision_total'], + flip: ['flipv', 'fliph'], + anchor: ['left', 'top', 'width', 'height', 'rotation', 'flipv', 'fliph', 'layer'], + tooltip: ['tooltip', 'show_tooltip', 'gm_only_tooltip'], + reactions: ['interactionTriggered', 'interactionManualReset', 'fadeOnOverlap', 'fadeOpacity'] + }; + + // ========================================================================= + // 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 = () => Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + links: {}, + chainedIds: {}, + knownProps: {}, + configInitialized: false + }; + } + if (!state[SCRIPT_NAME].chainedIds) state[SCRIPT_NAME].chainedIds = {}; + if (!state[SCRIPT_NAME].knownProps) state[SCRIPT_NAME].knownProps = {}; + if (!state[SCRIPT_NAME].globalExcludes) state[SCRIPT_NAME].globalExcludes = []; + // Seed global excludes from useroptions on first run + if (!state[SCRIPT_NAME].configInitialized && typeof globalconfig !== 'undefined' && globalconfig[SCRIPT_NAME]) { + var gc = globalconfig[SCRIPT_NAME]; + if (gc['Global Excludes'] && gc['Global Excludes'].trim()) { + state[SCRIPT_NAME].globalExcludes = gc['Global Excludes'].split(',').map(function(s) { return s.trim(); }).filter(Boolean); + } + state[SCRIPT_NAME].configInitialized = true; + } + // Seed known props from ALL_PROPS + ALL_PROPS.forEach(function(p) { state[SCRIPT_NAME].knownProps[p] = true; }); + }; + + const hasGlobalConfig = () => { + return typeof globalconfig !== 'undefined' && globalconfig[SCRIPT_NAME] && 'Global Excludes' in globalconfig[SCRIPT_NAME]; + }; + + const getKnownProps = () => Object.keys(state[SCRIPT_NAME].knownProps); + + // ========================================================================= + // Property Resolution + // ========================================================================= + + const resolveProps = (args) => { + var props = []; + var remaining = []; + args.forEach(function(arg) { + if (arg === 'all') { + props = props.concat(getKnownProps()); + } else if (PROP_GROUPS[arg]) { + props = props.concat(PROP_GROUPS[arg]); + } else if (ALL_PROPS.indexOf(arg) !== -1) { + props.push(arg); + } else { + remaining.push(arg); + } + }); + // Deduplicate props + props = props.filter(function(p, i) { return props.indexOf(p) === i; }); + return { props: props, remaining: remaining }; + }; + + // ========================================================================= + // Link Management + // ========================================================================= + + const getSelectedIds = (msg) => { + return (msg.selected || []).map(function(s) { return s._id; }).filter(Boolean); + }; + + const parseCommand = (msg, args) => { + var soft = args.indexOf('--soft') !== -1; + var align = args.indexOf('--align') !== -1; + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--soft' && a !== '--align' && a !== '--ignore-selected'; }); + + // Parse --exclude + var excludes = []; + var exIdx = args.indexOf('--exclude'); + if (exIdx !== -1) { + var afterExclude = args.slice(exIdx + 1); + args = args.slice(0, exIdx); + var exResolved = resolveProps(afterExclude); + excludes = exResolved.props; + // Any remaining non-prop args after --exclude are IDs + args = args.concat(exResolved.remaining); + } + + var resolved = resolveProps(args); + var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); }); + if (!ignoreSelected) ids = ids.concat(getSelectedIds(msg)); + ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe + + // Determine if using 'all' or specific props + // null means "no props specified" (let the caller decide context-dependent behavior) + var props; + if (resolved.props.length === 0) { + props = null; // no props specified + } else if (resolved.props.length === getKnownProps().length) { + props = 'all'; // explicit 'all' group + } else { + // Explicit prop list: apply --exclude as immediate filter + props = excludes.length > 0 + ? resolved.props.filter(function(p) { return excludes.indexOf(p) === -1; }) + : resolved.props; + excludes = []; // already applied, don't store + } + + return { props: props, ids: ids, soft: soft, align: align, excludes: excludes }; + }; + + /** + * Align targets to source: copy specified props from first token to all others. + */ + const alignTokens = (ids, props) => { + if (ids.length < 2) return; + var source = getObj('graphic', ids[0]); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + for (var i = 1; i < ids.length; i++) { + var target = getObj('graphic', ids[i]); + if (target) target.set(updates); + } + }; + + const createLink = (mode, props, ids, soft, excludes) => { + var s = state[SCRIPT_NAME]; + + // Guard: children in a hard link can't have multiple hard parents or join chains + if (mode === 'link' && !soft) { + // Check that child IDs (ids[1:]) don't already have a hard parent + for (var i = 1; i < ids.length; i++) { + var existing = findLinksForToken(ids[i]); + var hasHardParent = existing.some(function(e) { + return e.link.mode === 'link' && !e.link.soft && e.link.ids[0] !== ids[i]; + }); + if (hasHardParent) { + log(SCRIPT_NAME + ': Cannot hard-link ' + ids[i] + ' — already has a hard parent.'); + return null; + } + if (s.chainedIds[ids[i]]) { + log(SCRIPT_NAME + ': Cannot hard-link ' + ids[i] + ' — token is in a chain.'); + return null; + } + } + } + + if (mode === 'chain') { + // Check that none of the IDs have a hard parent link as a child + for (var i = 0; i < ids.length; i++) { + var existing = findLinksForToken(ids[i]); + var hasHardParent = existing.some(function(e) { + return e.link.mode === 'link' && !e.link.soft && e.link.ids[0] !== ids[i]; + }); + if (hasHardParent) { + log(SCRIPT_NAME + ': Cannot chain ' + ids[i] + ' — has a hard parent link.'); + return null; + } + } + ids.forEach(function(id) { s.chainedIds[id] = true; }); + } + + var linkId = genId(); + s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] }; + updatePlacementLocks(linkId); + return linkId; + }; + + /** + * Set/clear lockMovement for children in a link based on whether left+top are hard-linked. + */ + const updatePlacementLocks = (linkId) => { + var s = state[SCRIPT_NAME]; + var link = s.links[linkId]; + if (!link) return; + if (!s.placementLockedByMirror) s.placementLockedByMirror = {}; + + var effectiveProps = getEffectiveProps(link); + var hasLeftTop = effectiveProps.indexOf('left') !== -1 && effectiveProps.indexOf('top') !== -1; + var shouldLock = hasLeftTop && !link.soft; + + // Children are ids[1:] for links, all ids for chains + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { + var obj = getObj('graphic', id); + if (!obj) return; + if (shouldLock && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + s.placementLockedByMirror[id] = true; + } else if (!shouldLock && s.placementLockedByMirror[id]) { + obj.set('lockMovement', false); + delete s.placementLockedByMirror[id]; + } + }); + }; + + /** + * Clear lockMovement for a token if we set it. + */ + const clearPlacementLock = (tokenId) => { + var s = state[SCRIPT_NAME]; + if (!s.placementLockedByMirror || !s.placementLockedByMirror[tokenId]) return; + var obj = getObj('graphic', tokenId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByMirror[tokenId]; + }; + + /** + * Re-evaluate lockMovement for a token across all its links. + */ + const updatePlacementLocksForToken = (tokenId) => { + var links = findLinksForToken(tokenId); + var shouldLock = links.some(function(entry) { + var link = entry.link; + if (link.soft) return false; + var effectiveProps = getEffectiveProps(link); + if (effectiveProps.indexOf('left') === -1 || effectiveProps.indexOf('top') === -1) return false; + // Only lock children, not the parent + if (link.mode === 'link' && link.ids[0] === tokenId) return false; + return true; + }); + var s = state[SCRIPT_NAME]; + if (!s.placementLockedByMirror) s.placementLockedByMirror = {}; + var obj = getObj('graphic', tokenId); + if (!obj) return; + if (shouldLock && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + s.placementLockedByMirror[tokenId] = true; + } else if (!shouldLock && s.placementLockedByMirror[tokenId]) { + obj.set('lockMovement', false); + delete s.placementLockedByMirror[tokenId]; + } + }; + + /** + * Get the effective props for a link, accounting for 'all'/'api-all' and excludes. + */ + const getEffectiveProps = (link) => { + if (link.props === 'all') { + var excludes = (link.excludes || []).concat(getGlobalExcludes()); + return getKnownProps().filter(function(p) { return excludes.indexOf(p) === -1; }); + } + if (link.props === 'api-all') { + var excludes = link.excludes || []; + return getKnownProps().filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return link.props; + }; + + const getGlobalExcludes = () => { + return state[SCRIPT_NAME].globalExcludes || []; + }; + + const findLinksForToken = (tokenId) => { + var s = state[SCRIPT_NAME]; + var results = []; + Object.entries(s.links).forEach(function(entry) { + if (entry[1].ids.indexOf(tokenId) !== -1) results.push({ id: entry[0], link: entry[1] }); + }); + return results; + }; + + const removePropsFromLink = (linkId, propsToRemove) => { + var s = state[SCRIPT_NAME]; + var link = s.links[linkId]; + if (!link) return; + if (!propsToRemove || propsToRemove.length === 0) { + // Remove entire link — clear locks for children + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { clearPlacementLock(id); }); + if (link.mode === 'chain') { + link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); + } + delete s.links[linkId]; + } else { + link.props = link.props.filter(function(p) { return propsToRemove.indexOf(p) === -1; }); + if (link.props.length === 0) { + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { clearPlacementLock(id); }); + if (link.mode === 'chain') { + link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); + } + delete s.links[linkId]; + } else { + updatePlacementLocks(linkId); + } + } + }; + + const rebuildChainedIds = (tokenId, excludeLinkId) => { + var s = state[SCRIPT_NAME]; + // Check if token is still in any other chain + var stillChained = Object.entries(s.links).some(function(entry) { + return entry[0] !== excludeLinkId && entry[1].mode === 'chain' && entry[1].ids.indexOf(tokenId) !== -1; + }); + if (!stillChained) delete s.chainedIds[tokenId]; + }; + + // ========================================================================= + // Sync Engine + // ========================================================================= + + var syncing = false; + + /** + * Recursively propagate updates to targets and their children. + * visited prevents infinite loops in circular link structures. + */ + const propagateUpdates = (tokenId, updates, visited) => { + var s = state[SCRIPT_NAME]; + Object.values(s.links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + + var effectiveProps = getEffectiveProps(link); + var relevantUpdates = {}; + Object.keys(updates).forEach(function(p) { + if (effectiveProps.indexOf(p) !== -1) relevantUpdates[p] = updates[p]; + }); + if (Object.keys(relevantUpdates).length === 0) return; + + if (link.mode === 'chain') { + link.ids.forEach(function(id) { + if (id === tokenId || visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(relevantUpdates); + propagateUpdates(id, relevantUpdates, visited); + } + }); + } else if (idx === 0) { + // Source: propagate down to children + link.ids.slice(1).forEach(function(id) { + if (visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(relevantUpdates); + propagateUpdates(id, relevantUpdates, visited); + } + }); + } + }); + }; + + const onGraphicChanged = (obj, prev) => { + if (syncing) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find changed properties dynamically from prev keys + var changed = Object.keys(prev).filter(function(k) { + return !k.startsWith('_') && prev[k] !== obj.get(k); + }); + if (changed.length === 0) return; + + // Grow known props set with any discovered properties + changed.forEach(function(p) { s.knownProps[p] = true; }); + + syncing = true; + var visited = new Set([tokenId]); + + Object.values(s.links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + + var effectiveProps = getEffectiveProps(link); + var relevantProps = changed.filter(function(p) { return effectiveProps.indexOf(p) !== -1; }); + if (relevantProps.length === 0) return; + + var updates = {}; + relevantProps.forEach(function(p) { updates[p] = obj.get(p); }); + + if (link.mode === 'chain') { + link.ids.forEach(function(id) { + if (visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(updates); + propagateUpdates(id, updates, visited); + } + }); + } else if (idx === 0) { + // Source: propagate to children recursively + link.ids.slice(1).forEach(function(id) { + if (visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(updates); + propagateUpdates(id, updates, visited); + } + }); + } else if (!link.soft) { + // Hard lock: revert child to source value + var source = getObj('graphic', link.ids[0]); + if (source) { + var revert = {}; + relevantProps.forEach(function(p) { revert[p] = source.get(p); }); + obj.set(revert); + } + } + }); + + syncing = false; + }; + + // ========================================================================= + // Commands + // ========================================================================= + + const doLink = (msg, args) => { + var up = args.indexOf('--up') !== -1; + var down = args.indexOf('--down') !== -1; + args = args.filter(function(a) { return a !== '--up' && a !== '--down'; }); + var parsed = parseCommand(msg, args); + var linkProps = parsed.props || 'all'; + + // Check if token already has an existing link + var existingLink = null; + if (parsed.ids.length >= 1) { + var links = findLinksForToken(parsed.ids[0]); + var asParent = links.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]; }); + var asChild = links.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; }); + + if (parsed.ids.length === 1) { + if (asParent.length > 0 && asChild.length > 0 && !up && !down) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify parent link) or --down (modify child link).'); + return; + } + if (up && asChild.length > 0) existingLink = asChild[0]; + else if (down && asParent.length > 0) existingLink = asParent[0]; + else if (asParent.length > 0) existingLink = asParent[0]; + else if (asChild.length > 0) existingLink = asChild[0]; + } else { + // Multi-token: check if source has existing link as parent + if (asParent.length > 0) existingLink = asParent[0]; + } + } + + if (parsed.ids.length < 2 && !existingLink) { + reply(msg, 'Error', 'Link requires at least 2 tokens (or 1 token already in a link).'); + return; + } + + if (existingLink && Array.isArray(linkProps)) { + var link = existingLink.link; + if (link.props === 'all' || link.props === 'api-all') { + link.excludes = (link.excludes || []).filter(function(p) { + return linkProps.indexOf(p) === -1; + }); + reply(msg, 'Link', 'Re-included ' + linkProps.length + ' prop(s) in existing link.'); + } else { + linkProps.forEach(function(p) { + if (link.props.indexOf(p) === -1) link.props.push(p); + }); + reply(msg, 'Link', 'Added ' + linkProps.length + ' prop(s) to existing link (' + link.props.length + ' total).'); + } + } else { + var result = createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes); + if (!result) { reply(msg, 'Error', 'Cannot create link — a child token already has a hard parent or is in a chain.'); return; } + if (parsed.align) { + var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps; + alignTokens(parsed.ids, alignProps); + } + var propCount = linkProps === 'all' ? 'all' : linkProps.length; + reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').'); + } + parsed.ids.forEach(function(id) { updatePlacementLocksForToken(id); }); + }; + + const doUnlink = (msg, args) => { + var parsed = parseCommand(msg, args); + if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + var hasSpecificProps = parsed.props !== null && parsed.props !== 'all'; + var processed = 0; + var errors = []; + parsed.ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode === 'chain') { + if (hasSpecificProps) { + errors.push((getObj('graphic', id) || {get:function(){return id;}}).get('name') || id); + return; + } + // Remove this token from the chain + var link = entry.link; + link.ids = link.ids.filter(function(tid) { return tid !== id; }); + // Rebuild chainedIds for removed token + rebuildChainedIds(id, entry.id); + // If chain has fewer than 2 members, destroy it + if (link.ids.length < 2) { + link.ids.forEach(function(tid) { rebuildChainedIds(tid, entry.id); }); + delete state[SCRIPT_NAME].links[entry.id]; + } + processed++; + } else { + // Non-chain: existing behavior + if (!hasSpecificProps) { + removePropsFromLink(entry.id, null); + } else if (entry.link.props === 'all' || entry.link.props === 'api-all') { + parsed.props.forEach(function(p) { + if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); + }); + } else { + removePropsFromLink(entry.id, parsed.props); + } + processed++; + } + }); + }); + var out = 'Processed ' + processed + ' link(s).'; + if (errors.length > 0) out += '
Cannot unlink specific props from chain members: ' + errors.join(', ') + '. Use !mirror unchain [props] instead.'; + reply(msg, 'Unlink', out); + parsed.ids.forEach(function(id) { updatePlacementLocksForToken(id); }); + }; + + const doChain = (msg, args) => { + var parsed = parseCommand(msg, args); + var linkProps = parsed.props; // null = no props specified, 'all' = explicit all, [...] = specific + + if (parsed.ids.length < 1) { reply(msg, 'Error', 'Select or specify at least one token.'); return; } + + // Find which selected tokens are already in chains + var chainMap = {}; // linkId → entry + var unchainedIds = []; + parsed.ids.forEach(function(id) { + var links = findLinksForToken(id); + var inChain = links.find(function(e) { return e.link.mode === 'chain'; }); + if (inChain) chainMap[inChain.id] = inChain; + else unchainedIds.push(id); + }); + var existingChains = Object.values(chainMap); + + if (linkProps === null) { + // No props specified: add unchained tokens to chain, or create new chain + if (existingChains.length > 1) { + reply(msg, 'Error', 'Selected tokens belong to multiple chains. Cannot merge.'); + return; + } + if (existingChains.length === 1) { + // Add unchained tokens to the existing chain + if (unchainedIds.length === 0) { + reply(msg, 'Error', 'No unchained tokens to add. Use !mirror chain all to set all props, or specify props to add.'); + return; + } + var chain = existingChains[0].link; + unchainedIds.forEach(function(id) { + if (chain.ids.indexOf(id) === -1) { + chain.ids.push(id); + state[SCRIPT_NAME].chainedIds[id] = true; + } + }); + reply(msg, 'Chain', 'Added ' + unchainedIds.length + ' token(s) to existing chain (' + chain.ids.length + ' total).'); + } else { + // No existing chains: create new chain with 'all' + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + var result = createLink('chain', 'all', parsed.ids, true, parsed.excludes); + if (!result) { reply(msg, 'Error', 'Cannot create chain — a token has a hard parent link.'); return; } + if (parsed.align) alignTokens(parsed.ids, getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; })); + reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (all props' + (parsed.align ? ', aligned' : '') + ').'); + } + } else { + // Props specified: modify existing chains or create new one + if (existingChains.length > 0) { + existingChains.forEach(function(entry) { + var link = entry.link; + var propsToApply = linkProps === 'all' ? null : linkProps; + if (propsToApply && (link.props === 'all' || link.props === 'api-all')) { + link.excludes = (link.excludes || []).filter(function(p) { return propsToApply.indexOf(p) === -1; }); + } else if (propsToApply && Array.isArray(link.props)) { + propsToApply.forEach(function(p) { if (link.props.indexOf(p) === -1) link.props.push(p); }); + } + }); + var propCount = linkProps === 'all' ? 'all' : linkProps.length; + var msg2 = 'Updated ' + existingChains.length + ' chain(s) (' + propCount + ' props).'; + if (unchainedIds.length > 0) msg2 += '
' + unchainedIds.length + ' unchained token(s) ignored (use no props to add them).'; + reply(msg, 'Chain', msg2); + } else { + // No existing chains: create new + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + var result = createLink('chain', linkProps, parsed.ids, true, parsed.excludes); + if (!result) { reply(msg, 'Error', 'Cannot create chain — a token has a hard parent link.'); return; } + if (parsed.align) { + var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps; + alignTokens(parsed.ids, alignProps); + } + var propCount = linkProps === 'all' ? 'all' : linkProps.length; + reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').'); + } + } + }; + + const doUnchain = (msg, args) => { + var parsed = parseCommand(msg, args); + if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + var hasSpecificProps = parsed.props !== null && parsed.props !== 'all'; + var processed = 0; + parsed.ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode !== 'chain') return; + if (!hasSpecificProps) { + // No props specified: remove entire chain + removePropsFromLink(entry.id, null); + } else if (entry.link.props === 'all') { + // Link uses 'all': add props to excludes + var propsToExclude = parsed.props; + propsToExclude.forEach(function(p) { + if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); + }); + } else { + // Link uses specific props: remove them + removePropsFromLink(entry.id, parsed.props); + } + processed++; + }); + }); + reply(msg, 'Unchain', 'Processed ' + processed + ' chain(s).'); + }; + + const doConfig = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)')); + return; + } + var sub = args.shift(); + if (sub === 'exclude') { + var resolved = resolveProps(args); + if (resolved.props.length === 0) { reply(msg, 'Error', 'Specify properties to exclude.'); return; } + resolved.props.forEach(function(p) { + if (s.globalExcludes.indexOf(p) === -1) s.globalExcludes.push(p); + }); + reply(msg, 'Config', 'Global excludes: ' + s.globalExcludes.join(', ')); + } else if (sub === 'include') { + var resolved = resolveProps(args); + if (resolved.props.length === 0) { reply(msg, 'Error', 'Specify properties to include.'); return; } + s.globalExcludes = s.globalExcludes.filter(function(p) { return resolved.props.indexOf(p) === -1; }); + reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)')); + } else if (sub === 'reset') { + s.globalExcludes = []; + reply(msg, 'Config', 'Global excludes cleared.'); + } else { + reply(msg, 'Error', 'Usage: !mirror config [exclude|include|reset] [props]'); + } + }; + + const doAlign = (msg, args) => { + var linked = args.indexOf('--linked') !== -1; + var unlinked = args.indexOf('--unlinked') !== -1; + var up = args.indexOf('--up') !== -1; + var down = args.indexOf('--down') !== -1; + var ifLinked = args.indexOf('--if-linked') !== -1; + args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain' && a !== '--if-linked'; }); + if (!linked && !unlinked) linked = true; + // --up takes precedence; --up --down is same as --up + if (up) down = false; + + var parsed = parseCommand(msg, args); + + // Single-token align + if (parsed.ids.length === 1 && linked) { + var singleLinks = findLinksForToken(parsed.ids[0]); + if (singleLinks.length > 0) { + var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; }); + var isParentOrChain = singleLinks.some(function(e) { return e.link.mode === 'chain' || (e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]); }); + var isChild = asChild.length > 0; + + // Ambiguous: both parent/chain AND child, no flags + if (isParentOrChain && isChild && !up && !down) { + reply(msg, 'Error', 'Token is both parent/chain and child. Use --up (align to parent then cascade) or --down (cascade from current value).'); + return; + } + + var source = getObj('graphic', parsed.ids[0]); + if (!source) { reply(msg, 'Error', 'Token not found.'); return; } + var aligned = 0; + + // Step 1: If --up (or unambiguous child), align self to parent + var doUp = up || (!down && isChild && !isParentOrChain); + if (doUp && asChild.length > 0) { + var parentLink = asChild[0].link; + var props = parsed.props === null ? getEffectiveProps(parentLink) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + var parent = getObj('graphic', parentLink.ids[0]); + if (parent) { + var updates = {}; + props.forEach(function(p) { updates[p] = parent.get(p); }); + source.set(updates); + aligned++; + } + } + + // Step 2: Cascade from self to chain + children recursively + var cascadeVisited = new Set([parsed.ids[0]]); + var cascadeFrom = function(tokenId) { + var tokenObj = getObj('graphic', tokenId); + if (!tokenObj) return; + var s = state[SCRIPT_NAME]; + Object.values(s.links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + var requestedProps = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + // --if-linked: intersect with link's effective props + var linkProps = ifLinked ? requestedProps.filter(function(p) { return getEffectiveProps(link).indexOf(p) !== -1; }) : requestedProps; + var updates = {}; + linkProps.forEach(function(p) { updates[p] = tokenObj.get(p); }); + + if (link.mode === 'chain') { + link.ids.forEach(function(tid) { + if (cascadeVisited.has(tid)) return; + cascadeVisited.add(tid); + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; cascadeFrom(tid); } + }); + } else if (idx === 0) { + link.ids.slice(1).forEach(function(tid) { + if (cascadeVisited.has(tid)) return; + cascadeVisited.add(tid); + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; cascadeFrom(tid); } + }); + } + }); + }; + cascadeFrom(parsed.ids[0]); + + reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).'); + return; + } + } + + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens (or 1 token in a link/chain).'); return; } + + var s = state[SCRIPT_NAME]; + var aligned = 0; + var ignored = []; + + if (linked) { + parsed.ids.forEach(function(id) { + var links = findLinksForToken(id); + links.forEach(function(entry) { + var link = entry.link; + // null = use link's scope; 'all' or array = explicit + var props = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + if (link.mode === 'chain') { + // Align to the first selected/passed id that is in this chain + var sourceId = parsed.ids.find(function(pid) { return link.ids.indexOf(pid) !== -1; }); + if (!sourceId) return; + var source = getObj('graphic', sourceId); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + link.ids.forEach(function(tid) { + if (tid === sourceId) return; + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; } + }); + } else { + // One-way: parent aligns children, or child aligns to parent + var sourceIdx = link.ids.indexOf(id); + if (sourceIdx === 0) { + // This is the parent — align children to it + var source = getObj('graphic', id); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + link.ids.slice(1).forEach(function(tid) { + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; } + }); + } else { + // This is a child — align to parent + var source = getObj('graphic', link.ids[0]); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + var target = getObj('graphic', id); + if (target) { target.set(updates); aligned++; } + } + } + }); + if (links.length === 0) ignored.push(id); + }); + } + + if (unlinked) { + // Align unlinked tokens to first id in selection + var sourceId = parsed.ids[0]; + var source = getObj('graphic', sourceId); + if (source) { + var alignProps = parsed.props === null || parsed.props === 'all' ? getKnownProps() : parsed.props; + var updates = {}; + alignProps.forEach(function(p) { updates[p] = source.get(p); }); + parsed.ids.slice(1).forEach(function(id) { + var links = findLinksForToken(id); + if (links.length === 0) { + var t = getObj('graphic', id); + if (t) { t.set(updates); aligned++; } + } + }); + } + } + + var out = 'Aligned ' + aligned + ' token(s).'; + if (ignored.length > 0 && linked && !unlinked) { + out += '
' + ignored.length + ' token(s) ignored (not linked).'; + } + reply(msg, 'Align', out); + }; + + const doStatus = (msg) => { + var ids = getSelectedIds(msg); + if (ids.length === 0) { reply(msg, 'Error', 'Select token(s).'); return; } + var out = ''; + ids.forEach(function(id) { + var obj = getObj('graphic', id); + var name = obj ? (obj.get('name') || '(unnamed)') : '?'; + var links = findLinksForToken(id); + out += '' + name + ' (' + id + '): '; + if (links.length === 0) { out += 'no mirror links
'; return; } + links.forEach(function(entry) { + var role = entry.link.mode === 'chain' ? 'chain' : (entry.link.ids[0] === id ? 'source' : 'target'); + out += role + ' (' + entry.link.props.length + ' props, ' + entry.link.ids.length + ' tokens' + (entry.link.soft ? ', soft' : '') + ')
'; + }); + }); + reply(msg, out); + }; + + // ========================================================================= + // 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; + + // ScriptKit handles help, man, examples, whatsnew, gen-docs, gen-dev-docs + if (typeof ScriptKit !== 'undefined' && ScriptKit.handleInput(msg)) return; + + const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const sub = (args.shift() || '').toLowerCase(); + + switch (sub) { + case 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'chain': doChain(msg, args); break; + case 'unchain': doUnchain(msg, args); break; + case 'align': doAlign(msg, args); break; + case 'config': doConfig(msg, args); break; + case 'status': doStatus(msg); break; + default: reply(msg, 'Unknown command. Try `!mirror help`'); break; + } + }; + + // ========================================================================= + // Public API + // ========================================================================= + + /** Create a unidirectional link. props: array or 'api-all'. soft: bool. */ + const apiLink = (ids, props, soft, excludes) => { + if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; } + return createLink('link', props || 'api-all', ids, !!soft, excludes); + }; + + /** Create a bidirectional chain link. */ + const apiChainLink = (ids, props, excludes) => { + if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; } + return createLink('chain', props || 'api-all', ids, true, excludes); + }; + + /** Remove links for given token IDs. props: array to remove specific, null to remove all. */ + const apiUnlink = (ids, props) => { + ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode === 'chain') { + // Remove token from chain + entry.link.ids = entry.link.ids.filter(function(tid) { return tid !== id; }); + rebuildChainedIds(id, entry.id); + if (entry.link.ids.length < 2) { + entry.link.ids.forEach(function(tid) { rebuildChainedIds(tid, entry.id); }); + delete state[SCRIPT_NAME].links[entry.id]; + } + } else { + if (!props || props.length === 0) removePropsFromLink(entry.id, null); + else if (entry.link.props === 'all' || entry.link.props === 'api-all') { + props.forEach(function(p) { if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); }); + } else { + removePropsFromLink(entry.id, props); + } + } + }); + }); + }; + + /** Remove chain for given token IDs. props: add to excludes. null: destroy chain. */ + const apiUnchain = (ids, props) => { + ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode !== 'chain') return; + if (!props || props.length === 0) { + removePropsFromLink(entry.id, null); + } else if (entry.link.props === 'all' || entry.link.props === 'api-all') { + props.forEach(function(p) { if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); }); + } else { + removePropsFromLink(entry.id, props); + } + }); + }); + }; + + /** Add tokens to an existing chain. existingId: any ID in the chain. newIds: IDs to add. */ + const apiAddToChain = (existingId, newIds) => { + var links = findLinksForToken(existingId); + var chainEntry = links.find(function(e) { return e.link.mode === 'chain'; }); + if (!chainEntry) { log(SCRIPT_NAME + ': addToChain — token is not in a chain.'); return; } + newIds.forEach(function(id) { + if (chainEntry.link.ids.indexOf(id) === -1) { + chainEntry.link.ids.push(id); + state[SCRIPT_NAME].chainedIds[id] = true; + } + }); + }; + + /** Remove a token from its chain without destroying it. */ + const apiRemoveFromChain = (tokenId) => { + var links = findLinksForToken(tokenId); + links.forEach(function(entry) { + if (entry.link.mode !== 'chain') return; + entry.link.ids = entry.link.ids.filter(function(id) { return id !== tokenId; }); + rebuildChainedIds(tokenId, entry.id); + if (entry.link.ids.length < 2) { + entry.link.ids.forEach(function(id) { rebuildChainedIds(id, entry.id); }); + delete state[SCRIPT_NAME].links[entry.id]; + } + }); + }; + + /** Align tokens. sourceId's values cascade to chain/children. options: { up, ifLinked, props } */ + const apiAlign = (sourceId, options) => { + options = options || {}; + var source = getObj('graphic', sourceId); + if (!source) { log(SCRIPT_NAME + ': align — source not found.'); return; } + var singleLinks = findLinksForToken(sourceId); + if (singleLinks.length === 0) return; + + // If up: align to parent first + if (options.up) { + var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== sourceId; }); + if (asChild.length > 0) { + var parentLink = asChild[0].link; + var props = options.props || getEffectiveProps(parentLink); + var parent = getObj('graphic', parentLink.ids[0]); + if (parent) { + var updates = {}; + props.forEach(function(p) { updates[p] = parent.get(p); }); + source.set(updates); + } + } + } + + // Cascade from source + var visited = new Set([sourceId]); + var cascadeFrom = function(tokenId) { + var tokenObj = getObj('graphic', tokenId); + if (!tokenObj) return; + Object.values(state[SCRIPT_NAME].links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + var requestedProps = options.props || getEffectiveProps(link); + var linkProps = options.ifLinked ? requestedProps.filter(function(p) { return getEffectiveProps(link).indexOf(p) !== -1; }) : requestedProps; + if (linkProps.length === 0) return; + var updates = {}; + linkProps.forEach(function(p) { updates[p] = tokenObj.get(p); }); + if (link.mode === 'chain') { + link.ids.forEach(function(tid) { + if (visited.has(tid)) return; + visited.add(tid); + var t = getObj('graphic', tid); + if (t) { t.set(updates); cascadeFrom(tid); } + }); + } else if (idx === 0) { + link.ids.slice(1).forEach(function(tid) { + if (visited.has(tid)) return; + visited.add(tid); + var t = getObj('graphic', tid); + if (t) { t.set(updates); cascadeFrom(tid); } + }); + } + }); + }; + cascadeFrom(sourceId); + }; + + /** Query links for a token. Returns array of { id, link } objects. */ + const apiGetLinks = (tokenId) => findLinksForToken(tokenId); + + /** Get the parent (source) token ID for a one-way link, or null. */ + const apiGetParent = (childId) => { + var links = findLinksForToken(childId); + var asChild = links.find(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== childId; }); + return asChild ? asChild.link.ids[0] : null; + }; + + /** Get child token IDs for one-way links where tokenId is the parent. */ + const apiGetChildren = (parentId) => { + var children = []; + findLinksForToken(parentId).forEach(function(e) { + if (e.link.mode === 'link' && e.link.ids[0] === parentId) { + children = children.concat(e.link.ids.slice(1)); + } + }); + return children; + }; + + /** Get all token IDs in the same chain as tokenId, or empty array. */ + const apiGetChainMembers = (tokenId) => { + var links = findLinksForToken(tokenId); + var chain = links.find(function(e) { return e.link.mode === 'chain'; }); + return chain ? chain.link.ids.slice() : []; + }; + + /** Get/set global excludes. */ + const apiGetGlobalExcludes = () => (state[SCRIPT_NAME].globalExcludes || []).slice(); + const apiSetGlobalExcludes = (excludes) => { state[SCRIPT_NAME].globalExcludes = excludes; }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkConfigDrift(); + }; + + const checkConfigDrift = () => { + if (!hasGlobalConfig()) return; + var gc = globalconfig[SCRIPT_NAME]; + var gcExcludes = (gc['Global Excludes'] || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var stateExcludes = state[SCRIPT_NAME].globalExcludes || []; + + // Compare + var gcSorted = gcExcludes.slice().sort().join(','); + var stateSorted = stateExcludes.slice().sort().join(','); + if (gcSorted !== stateSorted) { + sendChat(SCRIPT_NAME, '/w gm ⚠️ Mirror config drift: runtime global excludes (' + + (stateExcludes.length > 0 ? stateExcludes.join(', ') : 'none') + + ') differ from API Scripts page settings (' + + (gcExcludes.length > 0 ? gcExcludes.join(', ') : 'none') + + '). Use !mirror config to view/change, or update the API Scripts page to match.'); + } + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('change:graphic', onGraphicChanged); + }; + + const registerWithScriptKit = () => { + if (typeof ScriptKit === 'undefined') return; + ScriptKit.register(SCRIPT_NAME, { + version: SCRIPT_VERSION, + command: CMD, + handout: 'auto', + devHandout: 'update', + help: { + description: 'Flat property syncing between tokens. No transforms, no offsets — when a property changes on one token, the same value is copied to linked tokens.', + quickStart: [ + 'Select 2+ tokens and run `!mirror link` to create a unidirectional link (first selected = source)', + 'Use `!mirror chain` for bidirectional syncing (any change propagates to all members)', + 'Specify property groups: `!mirror link spatial bars` to sync only those', + 'Use `!mirror status` on a selected token to see its links', + 'Run `!mirror unlink` or `!mirror unchain` to remove syncing', + ], + commands: [ + { + group: 'Linking', + commands: [ + { + syntax: 'link [--soft] [--align] [--exclude props] [props] [ids...]', + description: 'Create a unidirectional link (hard-lock by default)', + version: '1.0.0', + items: [ + { name: '--soft', description: 'Children can diverge — changes still propagate but are not reverted', version: '1.0.0' }, + { name: '--align', description: 'Immediately copy source props to children on creation', version: '1.1.0' }, + { name: '--exclude', description: 'Exclude specific props/groups from an `all` link', version: '1.1.0' }, + { name: 'props', description: 'Property names or group names to sync (default: all)', version: '1.0.0' }, + { name: 'ids', description: 'Token IDs (or use selected tokens — first = source)', version: '1.0.0' }, + ], + }, + { + syntax: 'unlink [props] [ids...]', + description: 'Remove a link or add properties to its exclude list', + version: '1.0.0', + }, + { + syntax: 'chain [--align] [--exclude props] [props] [ids...]', + description: 'Create a bidirectional ring link (all members sync equally)', + version: '1.0.0', + items: [ + { name: '--align', description: 'Align all members to the first selected on creation', version: '1.1.0' }, + { name: '--exclude', description: 'Exclude specific props/groups', version: '1.1.0' }, + ], + }, + { + syntax: 'unchain [props] [ids...]', + description: 'Remove a chain or add excludes', + version: '1.0.0', + }, + ], + }, + { + group: 'Alignment', + commands: [ + { + syntax: 'align [--up|--down] [--linked|--unlinked] [--if-linked] [props] [ids...]', + description: 'One-time copy of property values between linked tokens', + version: '1.1.0', + items: [ + { name: '--up', description: 'Align to parent (pull values from source)', version: '1.1.0' }, + { name: '--down', description: 'Cascade from selected token to children', version: '1.1.0' }, + { name: '--linked', description: 'Only align tokens that share a link', version: '1.1.0' }, + { name: '--unlinked', description: 'Only align tokens that do not share a link', version: '1.1.0' }, + { name: '--if-linked', description: 'Only align props that are actually linked', version: '1.1.0' }, + ], + }, + ], + }, + { + group: 'Configuration', + commands: [ + { + syntax: 'config [exclude|include|reset] [props]', + description: 'Manage global property excludes', + version: '1.1.0', + items: [ + { name: 'exclude', description: 'Add props to the global exclude list', version: '1.1.0' }, + { name: 'include', description: 'Remove props from the global exclude list', version: '1.1.0' }, + { name: 'reset', description: 'Clear all global excludes', version: '1.1.0' }, + ], + }, + ], + }, + { + group: 'Info', + commands: [ + { syntax: 'status', description: 'Show links for selected tokens', version: '1.0.0' }, + ], + }, + ], + topics: { + propertyGroups: { + title: 'Property Groups', + description: 'Named collections of properties for convenience', + version: '1.0.0', + body: 'Instead of listing individual properties, use group names. Multiple groups can be combined.', + items: [ + { name: 'all', description: 'All known properties (dynamic — includes future additions)', version: '1.0.0' }, + { name: 'spatial', description: 'left, top, rotation, width, height', version: '1.0.0' }, + { name: 'position', description: 'left, top', version: '1.0.0' }, + { name: 'size', description: 'width, height', version: '1.0.0' }, + { name: 'bars', description: 'All bar values, links, visibility, and permissions', version: '1.0.0' }, + { name: 'name', description: 'name, showname, showplayers_name, playersedit_name', version: '1.0.0' }, + { name: 'auras', description: 'Both aura radii, colors, square, options, and visibility', version: '1.0.0' }, + { name: 'light', description: 'All UDL emission and legacy dynamic lighting props', version: '1.0.0' }, + { name: 'sight', description: 'Vision properties — bright, night, field of vision limits', version: '1.0.0' }, + { name: 'flip', description: 'flipv, fliph', version: '1.0.0' }, + { name: 'anchor', description: 'position + size + rotation + flip + layer (for Anchor compat)', version: '1.1.0' }, + { name: 'tooltip', description: 'tooltip, show_tooltip, gm_only_tooltip', version: '1.2.0' }, + { name: 'reactions', description: 'interactionTriggered, interactionManualReset, fadeOnOverlap, fadeOpacity', version: '1.2.0' }, + ], + }, + hardVsSoft: { + title: 'Hard vs Soft Links', + description: 'Understanding lock behavior', + version: '1.0.0', + body: 'By default, links are **hard** — children are locked and any manual change is immediately reverted to match the source. A child can only have one hard parent.\n\nWith `--soft`, children can diverge from the source. Changes on the source still propagate, but manual edits on children are not reverted. Soft links do not restrict other links.', + }, + anchorCompat: { + title: 'Using with Anchor', + description: 'Avoiding conflicts between Mirror and Anchor spatial sync', + version: '1.1.0', + body: 'If tokens are managed by both Mirror and Anchor, exclude the spatial properties that Anchor handles:\n\n`!mirror chain --exclude anchor`\n\nThis syncs everything except left, top, width, height, rotation, flipv, fliph, and layer — which Anchor manages.', + }, + scriptingApi: { + title: 'Scripting API', + description: 'Using Mirror from other scripts', + handouts: 'dev', + version: '1.0.0', + body: 'Access via `Mirror.*` after `on("ready")`.', + items: [ + { name: 'Mirror.link(ids, props, soft, excludes)', description: 'Create unidirectional link', version: '1.0.0' }, + { name: 'Mirror.chainLink(ids, props, excludes)', description: 'Create bidirectional chain', version: '1.0.0' }, + { name: 'Mirror.unlink(ids, props)', description: 'Remove link or add excludes', version: '1.0.0' }, + { name: 'Mirror.unchain(ids, props)', description: 'Remove chain or add excludes', version: '1.0.0' }, + { name: 'Mirror.addToChain(existingId, newIds)', description: 'Add tokens to an existing chain', version: '1.1.0' }, + { name: 'Mirror.removeFromChain(tokenId)', description: 'Remove a token from its chain', version: '1.1.0' }, + { name: 'Mirror.align(sourceId, options)', description: 'Align tokens: { up, ifLinked, props }', version: '1.1.0' }, + { name: 'Mirror.getLinks(tokenId)', description: '→ [{ id, link }]', version: '1.0.0' }, + { name: 'Mirror.getParent(childId)', description: '→ parentId or null', version: '1.0.0' }, + { name: 'Mirror.getChildren(parentId)', description: '→ [childIds]', version: '1.0.0' }, + { name: 'Mirror.getChainMembers(tokenId)', description: '→ [ids in same chain]', version: '1.1.0' }, + { name: 'Mirror.getGlobalExcludes()', description: '→ [excluded prop names]', version: '1.1.0' }, + { name: 'Mirror.setGlobalExcludes(arr)', description: 'Set global excludes array', version: '1.1.0' }, + { name: 'Mirror.ALL_PROPS', description: 'Hardcoded property list', version: '1.0.0' }, + { name: 'Mirror.PROP_GROUPS', description: 'Named group definitions', version: '1.0.0' }, + { name: 'Mirror.getKnownProps()', description: '→ all known prop names (dynamic)', version: '1.1.0' }, + ], + }, + }, + changelog: [ + { version: '1.2.0', changes: [ + 'Added properties: sides, interactionTriggered, interactionManualReset, renderAsDarkness, gm_only_tooltip, night_vision_tint', + 'Added property groups: tooltip, reactions', + 'ScriptKit integration: help, man, handout generation', + ]}, + { version: '1.1.0', changes: [ + 'Added --align flag for link/chain creation', + 'Added --exclude flag for prop filtering', + 'Added align command with --up, --down, --linked, --unlinked, --if-linked', + 'Added config command for global excludes', + 'Added anchor property group for Anchor compatibility', + 'Added addToChain and removeFromChain API methods', + ]}, + { version: '1.0.0', changes: [ + 'Initial release: link, unlink, chain, unchain, status', + 'Hard/soft link modes', + 'Property groups: all, spatial, position, size, bars, light, auras, flip', + 'Scripting API for inter-script linking', + ]}, + ], + }, + }); + }; + + return { + checkInstall, + registerEventHandlers, + registerWithScriptKit, + link: apiLink, + chainLink: apiChainLink, + unlink: apiUnlink, + unchain: apiUnchain, + addToChain: apiAddToChain, + removeFromChain: apiRemoveFromChain, + align: apiAlign, + getLinks: apiGetLinks, + getParent: apiGetParent, + getChildren: apiGetChildren, + getChainMembers: apiGetChainMembers, + getGlobalExcludes: apiGetGlobalExcludes, + setGlobalExcludes: apiSetGlobalExcludes, + ALL_PROPS: ALL_PROPS, + PROP_GROUPS: PROP_GROUPS, + getKnownProps: getKnownProps + }; +})(); + +on('ready', () => { + 'use strict'; + Mirror.checkInstall(); + Mirror.registerEventHandlers(); + Mirror.registerWithScriptKit(); + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content === '!scriptkit-ready') Mirror.registerWithScriptKit(); + }); +}); diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index a13d0d8a6..2d05c6fc3 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -1,5 +1,5 @@ // ============================================================================= -// Mirror v1.1.2 +// Mirror v1.2.0 // Last Updated: 2026-07-17 // Author: Kenan Millet // @@ -26,7 +26,7 @@ var Mirror = Mirror || (() => { 'use strict'; const SCRIPT_NAME = 'Mirror'; - const SCRIPT_VERSION = '1.1.2'; + const SCRIPT_VERSION = '1.2.0'; const CMD = '!mirror'; // All syncable graphic properties @@ -958,19 +958,6 @@ var Mirror = Mirror || (() => { reply(msg, out); }; - const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' - + '' + CMD + ' link [--soft] [--align] [--exclude props] [props] [ids...] -- Unidirectional link (hard-lock default)
' - + '' + CMD + ' unlink [props] [ids...] -- Remove link or add excludes
' - + '' + CMD + ' chain [--align] [--exclude props] [props] [ids...] -- Bidirectional chain
' - + '' + CMD + ' unchain [props] [ids...] -- Remove chain or add excludes
' - + '' + CMD + ' align [--up|--down] [--linked|--unlinked] [--if-linked] [props] [ids...] -- Align tokens
' - + '' + CMD + ' config [exclude|include|reset] [props] -- Global excludes
' - + '' + CMD + ' status -- Show links for selected
' - + '' + CMD + ' --help -- This help
' - + '
Groups: all, spatial, position, size, bars, light, auras, flip' - + '
Flags: --soft, --align, --exclude, --up, --down, --linked, --unlinked, --if-linked' - + '
Props: ' + ALL_PROPS.join(', '); - // ========================================================================= // Command Router // ========================================================================= @@ -980,6 +967,9 @@ var Mirror = Mirror || (() => { if (msg.content.split(' ')[0] !== CMD) return; if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; + // ScriptKit handles help, man, examples, whatsnew, gen-docs, gen-dev-docs + if (typeof ScriptKit !== 'undefined' && ScriptKit.handleInput(msg)) return; + const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); const sub = (args.shift() || '').toLowerCase(); @@ -991,9 +981,7 @@ var Mirror = Mirror || (() => { case 'align': doAlign(msg, args); break; case 'config': doConfig(msg, args); break; case 'status': doStatus(msg); break; - case 'gen-dev-docs': generateDevDocs(msg); break; - case '--help': reply(msg, HELP_TEXT); break; - default: reply(msg, HELP_TEXT); break; + default: reply(msg, 'Unknown command. Try `!mirror help`'); break; } }; @@ -1176,7 +1164,6 @@ var Mirror = Mirror || (() => { ensureState(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkConfigDrift(); - generateHelpHandout(); }; const checkConfigDrift = () => { @@ -1197,68 +1184,192 @@ var Mirror = Mirror || (() => { } }; - const generateHelpHandout = () => { - var name = 'Help: ' + SCRIPT_NAME; - var hh = findObjs({ type: 'handout', name: name })[0]; - if (!hh) hh = createObj('handout', { name: name, archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); - var html = '

' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

'; - html += '

Flat property syncing between tokens. No transforms — values are copied directly.

'; - html += '

Commands

'; - html += '

Property Groups

'; - html += '

all (dynamic), spatial (left,top,rotation,width,height), position (left,top), size (width,height), bars, light, auras, flip

'; - html += '

Flags

'; - html += '

--soft: children can diverge (no hard lock)
'; - html += '--align: align on link/chain creation
'; - html += '--exclude: exclude props from all group
'; - html += '--up: align to parent first
'; - html += '--down: cascade from current value
'; - html += '--if-linked: only align props that are actually linked

'; - html += '

Using with Anchor

'; - html += '

Use --exclude anchor on tokens that also use Anchor for spatial sync. This prevents overlap.

'; - html += '

!mirror chain --exclude anchor — sync everything except Anchor-managed props.

'; - hh.set('notes', html); - }; - - const generateDevDocs = (msg) => { - var name = 'Help: ' + SCRIPT_NAME + '/Scripting API'; - var hh = findObjs({ type: 'handout', name: name })[0]; - if (!hh) hh = createObj('handout', { name: name, archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); - var html = '

' + SCRIPT_NAME + ' — Scripting API

'; - html += '

Access via Mirror.* after on("ready").

'; - html += '

Linking

'; - html += '
Mirror.link(ids, props, soft, excludes)  // unidirectional\nMirror.chainLink(ids, props, excludes)   // bidirectional chain
'; - html += '

Unlinking

'; - html += '
Mirror.unlink(ids, props)         // remove link or add excludes\nMirror.unchain(ids, props)        // remove chain or add excludes\nMirror.removeFromChain(tokenId)   // remove one token from chain\nMirror.addToChain(existingId, newIds)  // add tokens to chain
'; - html += '

Alignment

'; - html += '
Mirror.align(sourceId, { up, ifLinked, props })
'; - html += '

Mirror.align(id, { ifLinked: true }) is the equivalent of Anchor.updateObj() — pushes linked props to all dependents.

'; - html += '

Queries

'; - html += '
Mirror.getLinks(tokenId)       // → [{ id, link }]\nMirror.getParent(childId)      // → parentId or null\nMirror.getChildren(parentId)   // → [childIds]\nMirror.getChainMembers(tokenId) // → [ids]
'; - html += '

Configuration

'; - html += '
Mirror.getGlobalExcludes()     // → [props]\nMirror.setGlobalExcludes(arr)\nMirror.getKnownProps()         // → [all known prop names]
'; - html += '

Constants

'; - html += '
Mirror.ALL_PROPS    // hardcoded prop list\nMirror.PROP_GROUPS  // { spatial, position, size, bars, light, auras, flip }
'; - hh.set('notes', html); - reply(msg, 'Generated ' + name + ' — check your journal.'); - }; - const registerEventHandlers = () => { on('chat:message', handleInput); on('change:graphic', onGraphicChanged); }; + const registerWithScriptKit = () => { + if (typeof ScriptKit === 'undefined') return; + ScriptKit.register(SCRIPT_NAME, { + version: SCRIPT_VERSION, + command: CMD, + handout: 'auto', + devHandout: 'update', + help: { + description: 'Flat property syncing between tokens. No transforms, no offsets — when a property changes on one token, the same value is copied to linked tokens.', + quickStart: [ + 'Select 2+ tokens and run `!mirror link` to create a unidirectional link (first selected = source)', + 'Use `!mirror chain` for bidirectional syncing (any change propagates to all members)', + 'Specify property groups: `!mirror link spatial bars` to sync only those', + 'Use `!mirror status` on a selected token to see its links', + 'Run `!mirror unlink` or `!mirror unchain` to remove syncing', + ], + commands: [ + { + group: 'Linking', + commands: [ + { + syntax: 'link [--soft] [--align] [--exclude props] [props] [ids...]', + description: 'Create a unidirectional link (hard-lock by default)', + version: '1.0.0', + items: [ + { name: '--soft', description: 'Children can diverge — changes still propagate but are not reverted', version: '1.0.0' }, + { name: '--align', description: 'Immediately copy source props to children on creation', version: '1.1.0' }, + { name: '--exclude', description: 'Exclude specific props/groups from an `all` link', version: '1.1.0' }, + { name: 'props', description: 'Property names or group names to sync (default: all)', version: '1.0.0' }, + { name: 'ids', description: 'Token IDs (or use selected tokens — first = source)', version: '1.0.0' }, + ], + }, + { + syntax: 'unlink [props] [ids...]', + description: 'Remove a link or add properties to its exclude list', + version: '1.0.0', + }, + { + syntax: 'chain [--align] [--exclude props] [props] [ids...]', + description: 'Create a bidirectional ring link (all members sync equally)', + version: '1.0.0', + items: [ + { name: '--align', description: 'Align all members to the first selected on creation', version: '1.1.0' }, + { name: '--exclude', description: 'Exclude specific props/groups', version: '1.1.0' }, + ], + }, + { + syntax: 'unchain [props] [ids...]', + description: 'Remove a chain or add excludes', + version: '1.0.0', + }, + ], + }, + { + group: 'Alignment', + commands: [ + { + syntax: 'align [--up|--down] [--linked|--unlinked] [--if-linked] [props] [ids...]', + description: 'One-time copy of property values between linked tokens', + version: '1.1.0', + items: [ + { name: '--up', description: 'Align to parent (pull values from source)', version: '1.1.0' }, + { name: '--down', description: 'Cascade from selected token to children', version: '1.1.0' }, + { name: '--linked', description: 'Only align tokens that share a link', version: '1.1.0' }, + { name: '--unlinked', description: 'Only align tokens that do not share a link', version: '1.1.0' }, + { name: '--if-linked', description: 'Only align props that are actually linked', version: '1.1.0' }, + ], + }, + ], + }, + { + group: 'Configuration', + commands: [ + { + syntax: 'config [exclude|include|reset] [props]', + description: 'Manage global property excludes', + version: '1.1.0', + items: [ + { name: 'exclude', description: 'Add props to the global exclude list', version: '1.1.0' }, + { name: 'include', description: 'Remove props from the global exclude list', version: '1.1.0' }, + { name: 'reset', description: 'Clear all global excludes', version: '1.1.0' }, + ], + }, + ], + }, + { + group: 'Info', + commands: [ + { syntax: 'status', description: 'Show links for selected tokens', version: '1.0.0' }, + ], + }, + ], + topics: { + propertyGroups: { + title: 'Property Groups', + description: 'Named collections of properties for convenience', + version: '1.0.0', + body: 'Instead of listing individual properties, use group names. Multiple groups can be combined.', + items: [ + { name: 'all', description: 'All known properties (dynamic — includes future additions)', version: '1.0.0' }, + { name: 'spatial', description: 'left, top, rotation, width, height', version: '1.0.0' }, + { name: 'position', description: 'left, top', version: '1.0.0' }, + { name: 'size', description: 'width, height', version: '1.0.0' }, + { name: 'bars', description: 'All bar values, links, visibility, and permissions', version: '1.0.0' }, + { name: 'name', description: 'name, showname, showplayers_name, playersedit_name', version: '1.0.0' }, + { name: 'auras', description: 'Both aura radii, colors, square, options, and visibility', version: '1.0.0' }, + { name: 'light', description: 'All UDL emission and legacy dynamic lighting props', version: '1.0.0' }, + { name: 'sight', description: 'Vision properties — bright, night, field of vision limits', version: '1.0.0' }, + { name: 'flip', description: 'flipv, fliph', version: '1.0.0' }, + { name: 'anchor', description: 'position + size + rotation + flip + layer (for Anchor compat)', version: '1.1.0' }, + { name: 'tooltip', description: 'tooltip, show_tooltip, gm_only_tooltip', version: '1.2.0' }, + { name: 'reactions', description: 'interactionTriggered, interactionManualReset, fadeOnOverlap, fadeOpacity', version: '1.2.0' }, + ], + }, + hardVsSoft: { + title: 'Hard vs Soft Links', + description: 'Understanding lock behavior', + version: '1.0.0', + body: 'By default, links are **hard** — children are locked and any manual change is immediately reverted to match the source. A child can only have one hard parent.\n\nWith `--soft`, children can diverge from the source. Changes on the source still propagate, but manual edits on children are not reverted. Soft links do not restrict other links.', + }, + anchorCompat: { + title: 'Using with Anchor', + description: 'Avoiding conflicts between Mirror and Anchor spatial sync', + version: '1.1.0', + body: 'If tokens are managed by both Mirror and Anchor, exclude the spatial properties that Anchor handles:\n\n`!mirror chain --exclude anchor`\n\nThis syncs everything except left, top, width, height, rotation, flipv, fliph, and layer — which Anchor manages.', + }, + scriptingApi: { + title: 'Scripting API', + description: 'Using Mirror from other scripts', + handouts: 'dev', + version: '1.0.0', + body: 'Access via `Mirror.*` after `on("ready")`.', + items: [ + { name: 'Mirror.link(ids, props, soft, excludes)', description: 'Create unidirectional link', version: '1.0.0' }, + { name: 'Mirror.chainLink(ids, props, excludes)', description: 'Create bidirectional chain', version: '1.0.0' }, + { name: 'Mirror.unlink(ids, props)', description: 'Remove link or add excludes', version: '1.0.0' }, + { name: 'Mirror.unchain(ids, props)', description: 'Remove chain or add excludes', version: '1.0.0' }, + { name: 'Mirror.addToChain(existingId, newIds)', description: 'Add tokens to an existing chain', version: '1.1.0' }, + { name: 'Mirror.removeFromChain(tokenId)', description: 'Remove a token from its chain', version: '1.1.0' }, + { name: 'Mirror.align(sourceId, options)', description: 'Align tokens: { up, ifLinked, props }', version: '1.1.0' }, + { name: 'Mirror.getLinks(tokenId)', description: '→ [{ id, link }]', version: '1.0.0' }, + { name: 'Mirror.getParent(childId)', description: '→ parentId or null', version: '1.0.0' }, + { name: 'Mirror.getChildren(parentId)', description: '→ [childIds]', version: '1.0.0' }, + { name: 'Mirror.getChainMembers(tokenId)', description: '→ [ids in same chain]', version: '1.1.0' }, + { name: 'Mirror.getGlobalExcludes()', description: '→ [excluded prop names]', version: '1.1.0' }, + { name: 'Mirror.setGlobalExcludes(arr)', description: 'Set global excludes array', version: '1.1.0' }, + { name: 'Mirror.ALL_PROPS', description: 'Hardcoded property list', version: '1.0.0' }, + { name: 'Mirror.PROP_GROUPS', description: 'Named group definitions', version: '1.0.0' }, + { name: 'Mirror.getKnownProps()', description: '→ all known prop names (dynamic)', version: '1.1.0' }, + ], + }, + }, + changelog: [ + { version: '1.2.0', changes: [ + 'Added properties: sides, interactionTriggered, interactionManualReset, renderAsDarkness, gm_only_tooltip, night_vision_tint', + 'Added property groups: tooltip, reactions', + 'ScriptKit integration: help, man, handout generation', + ]}, + { version: '1.1.0', changes: [ + 'Added --align flag for link/chain creation', + 'Added --exclude flag for prop filtering', + 'Added align command with --up, --down, --linked, --unlinked, --if-linked', + 'Added config command for global excludes', + 'Added anchor property group for Anchor compatibility', + 'Added addToChain and removeFromChain API methods', + ]}, + { version: '1.0.0', changes: [ + 'Initial release: link, unlink, chain, unchain, status', + 'Hard/soft link modes', + 'Property groups: all, spatial, position, size, bars, light, auras, flip', + 'Scripting API for inter-script linking', + ]}, + ], + }, + }); + }; + return { checkInstall, registerEventHandlers, + registerWithScriptKit, link: apiLink, chainLink: apiChainLink, unlink: apiUnlink, @@ -1282,4 +1393,8 @@ on('ready', () => { 'use strict'; Mirror.checkInstall(); Mirror.registerEventHandlers(); + Mirror.registerWithScriptKit(); + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content === '!scriptkit-ready') Mirror.registerWithScriptKit(); + }); }); diff --git a/Mirror/README.md b/Mirror/README.md index f951c2e4f..b2c6c8082 100644 --- a/Mirror/README.md +++ b/Mirror/README.md @@ -28,8 +28,9 @@ Flat property syncing between Roll20 tokens. No transforms, no offsets — when | `!mirror align [--up\|--down] [--linked\|--unlinked] [--if-linked] [props]` | Align tokens | | `!mirror config [exclude\|include\|reset] [props]` | Global excludes | | `!mirror status` | Show links for selected tokens | -| `!mirror gen-dev-docs` | Generate scripting API handout | -| `!mirror --help` | Command reference | +| `!mirror help` | Command reference (via ScriptKit) | +| `!mirror man ` | Search help topics (via ScriptKit) | +| `!mirror gen-dev-docs` | Generate developer API handout (via ScriptKit) | ## Property Groups @@ -44,7 +45,8 @@ Flat property syncing between Roll20 tokens. No transforms, no offsets — when | `light` | UDL emission (bright/low/directional/color) + legacy (radius, dimradius, angle, etc.) | | `sight` | UDL vision (bright_light_vision, night_vision, field of vision limits) | | `auras` | aura1-2 radius, color, square, options, showplayers, playersedit | -| `tooltip` | tooltip, show_tooltip | +| `tooltip` | tooltip, show_tooltip, gm_only_tooltip | +| `reactions` | interactionTriggered, interactionManualReset, fadeOnOverlap, fadeOpacity | | `flip` | flipv, fliph | | `anchor` | spatial + flip + layer (for `--exclude` when using Anchor) | @@ -101,6 +103,13 @@ Mirror.align(tokenId, { ifLinked: true }) // push linked props to dependents ## Changelog +### v1.2.0 +- ScriptKit integration: `help`, `man`, `whatsnew`, `gen-dev-docs` commands via ScriptKit +- Auto-generated user help handout +- Added properties: `sides`, `interactionTriggered`, `interactionManualReset`, `renderAsDarkness`, `gm_only_tooltip`, `night_vision_tint` +- New property groups: `tooltip` (expanded with `gm_only_tooltip`), `reactions` +- Removed legacy `--help` handler (now handled by ScriptKit alongside `help`) + ### v1.1.1 - Comprehensive `ALL_PROPS` update: bar4, bar links, visibility/edit permissions, tooltip, UDL directional light, UDL vision limits, foreground layer props, lockMovement, isdrawing, aura options - Split `light` group into `light` (emission only) and `sight` (vision only) diff --git a/Mirror/script.json b/Mirror/script.json index f5c7c272d..04593c986 100644 --- a/Mirror/script.json +++ b/Mirror/script.json @@ -1,15 +1,16 @@ { "name": "Mirror", "script": "Mirror.js", - "version": "1.1.1", + "version": "1.2.0", "previousversions": [ + "1.1.1", "1.1.0", "1.0.0" ], - "description": "[Wiki Page](https://wiki.roll20.net/Script:Mirror)\n\nFlat property syncing between tokens. No transforms, no offsets -- when a property changes on one token, the same value is copied to linked tokens.\n\nSupports unidirectional (link) and bidirectional ring (chain) modes. Hard-lock by default for one-way links (child changes revert). Property groups for easy configuration.\n\nCommands:\n- `!mirror link [--soft] [--align] [--exclude props] [props] [ids...]` -- Unidirectional link\n- `!mirror unlink [props] [ids...]` -- Remove link\n- `!mirror chain [--align] [--exclude props] [props] [ids...]` -- Bidirectional chain\n- `!mirror unchain [props] [ids...]` -- Remove chain\n- `!mirror align [--up|--down] [--linked|--unlinked] [--if-linked] [props]` -- Align tokens\n- `!mirror config [exclude|include|reset] [props]` -- Global excludes\n- `!mirror status` -- Show links\n- `!mirror --help` -- Command reference", + "description": "[Wiki Page](https://wiki.roll20.net/Script:Mirror)\n\nFlat property syncing between tokens. No transforms, no offsets -- when a property changes on one token, the same value is copied to linked tokens.\n\nSupports unidirectional (link) and bidirectional ring (chain) modes. Hard-lock by default for one-way links (child changes revert). Property groups for easy configuration.\n\nCommands:\n- `!mirror link [--soft] [--align] [--exclude props] [props] [ids...]` -- Unidirectional link\n- `!mirror unlink [props] [ids...]` -- Remove link\n- `!mirror chain [--align] [--exclude props] [props] [ids...]` -- Bidirectional chain\n- `!mirror unchain [props] [ids...]` -- Remove chain\n- `!mirror align [--up|--down] [--linked|--unlinked] [--if-linked] [props]` -- Align tokens\n- `!mirror config [exclude|include|reset] [props]` -- Global excludes\n- `!mirror status` -- Show links\n- `!mirror help` -- Command reference (via ScriptKit)", "authors": "Kenan Millet", "roll20userid": "2614613", - "dependencies": [], + "dependencies": ["ScriptKit"], "modifies": { "graphic": "read, write" },