From 616332caa399c378035c1cc95029635bca3283a4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 07:59:40 -0400 Subject: [PATCH 01/28] Mirror v1.0.0: flat property syncing between tokens --- Mirror/Mirror.js | 320 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 Mirror/Mirror.js diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js new file mode 100644 index 0000000000..649f4e8094 --- /dev/null +++ b/Mirror/Mirror.js @@ -0,0 +1,320 @@ +// ============================================================================= +// Mirror v1.0.0 +// Last Updated: 2026-06-15 +// 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. +// +// Dependencies: none +// +// Commands: +// !mirror link [props] [ids...] Link selected/listed tokens (unidirectional) +// !mirror unlink [props] [ids...] Remove link (or specific properties) +// !mirror chain [props] [ids...] Bidirectional ring link +// !mirror unchain [props] [ids...] Remove chain (or specific properties) +// !mirror status Show mirror state for selected tokens +// !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.0.0'; + const CMD = '!mirror'; + + // All syncable graphic properties + const ALL_PROPS = [ + 'left', 'top', 'width', 'height', 'rotation', + 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'aura1_radius', 'aura1_color', 'aura1_square', + 'aura2_radius', 'aura2_color', 'aura2_square', + 'tint_color', 'statusmarkers', 'name', 'showname', + 'light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers', + 'light_hassight', 'light_losangle', 'light_multiplier', + 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', + 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance', + 'baseOpacity', 'currentSide' + ]; + + // ========================================================================= + // 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] = { + // linkId → { props: [...], ids: [...], mode: 'link'|'chain' } + links: {} + }; + } + }; + + // ========================================================================= + // Link Management + // ========================================================================= + + const getSelectedIds = (msg) => { + return (msg.selected || []).map(function(s) { return s._id; }).filter(Boolean); + }; + + const parseArgs = (args) => { + var props = []; + var ids = []; + args.forEach(function(arg) { + if (ALL_PROPS.indexOf(arg) !== -1) props.push(arg); + else if (arg.startsWith('-')) ids.push(arg); // Roll20 IDs start with - + }); + return { props: props, ids: ids }; + }; + + const createLink = (mode, props, ids) => { + var s = state[SCRIPT_NAME]; + var linkId = genId(); + s.links[linkId] = { props: props, ids: ids, mode: mode }; + return linkId; + }; + + 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) { + delete s.links[linkId]; + } else { + link.props = link.props.filter(function(p) { return propsToRemove.indexOf(p) === -1; }); + if (link.props.length === 0) delete s.links[linkId]; + } + }; + + // ========================================================================= + // Sync Engine + // ========================================================================= + + var syncing = false; + + const onGraphicChanged = (obj) => { + if (syncing) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find all links this token participates in + Object.values(s.links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + + // Determine which tokens to update + var targets; + if (link.mode === 'chain') { + // Bidirectional: update all others in the link + targets = link.ids.filter(function(id) { return id !== tokenId; }); + } else { + // Unidirectional: only propagate if this is the source (first id) + if (idx !== 0) return; + targets = link.ids.slice(1); + } + + // Build updates from changed properties that are in this link's prop list + var updates = {}; + link.props.forEach(function(prop) { + updates[prop] = obj.get(prop); + }); + + syncing = true; + targets.forEach(function(targetId) { + var target = getObj('graphic', targetId); + if (target) target.set(updates); + }); + syncing = false; + }); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + const doLink = (msg, args) => { + var parsed = parseArgs(args); + var ids = parsed.ids.concat(getSelectedIds(msg)); + // Deduplicate + ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); + + if (ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } + var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice(); + var linkId = createLink('link', props, ids); + reply(msg, 'Link', 'Linked ' + ids.length + ' tokens (' + props.length + ' properties). Source: first selected/listed.'); + }; + + const doUnlink = (msg, args) => { + var parsed = parseArgs(args); + var ids = parsed.ids.concat(getSelectedIds(msg)); + ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); + + if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + + var removed = 0; + ids.forEach(function(id) { + var links = findLinksForToken(id); + links.forEach(function(entry) { + if (entry.link.mode === 'link') { + removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null); + removed++; + } + }); + }); + reply(msg, 'Unlink', 'Removed ' + removed + ' link(s).'); + }; + + const doChain = (msg, args) => { + var parsed = parseArgs(args); + var ids = parsed.ids.concat(getSelectedIds(msg)); + ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); + + if (ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice(); + var linkId = createLink('chain', props, ids); + reply(msg, 'Chain', 'Chain-linked ' + ids.length + ' tokens (' + props.length + ' properties).'); + }; + + const doUnchain = (msg, args) => { + var parsed = parseArgs(args); + var ids = parsed.ids.concat(getSelectedIds(msg)); + ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); + + if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + + var removed = 0; + ids.forEach(function(id) { + var links = findLinksForToken(id); + links.forEach(function(entry) { + if (entry.link.mode === 'chain') { + removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null); + removed++; + } + }); + }); + reply(msg, 'Unchain', 'Removed ' + removed + ' chain(s).'); + }; + + 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') || id) : id; + var links = findLinksForToken(id); + out += '' + name + ': '; + if (links.length === 0) { out += 'no mirror links
'; return; } + links.forEach(function(entry) { + out += entry.link.mode + ' (' + entry.link.props.length + ' props, ' + entry.link.ids.length + ' tokens)
'; + }); + }); + reply(msg, out); + }; + + const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' + + '' + CMD + ' link [props] [ids...] -- Unidirectional link
' + + '' + CMD + ' unlink [props] [ids...] -- Remove link
' + + '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
' + + '' + CMD + ' unchain [props] [ids...] -- Remove chain
' + + '' + CMD + ' status -- Show links for selected
' + + '' + CMD + ' --help -- This help
' + + '
Properties: ' + ALL_PROPS.join(', '); + + // ========================================================================= + // 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 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'chain': doChain(msg, args); break; + case 'unchain': doUnchain(msg, args); break; + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + // Listen to all property changes we care about + on('change:graphic:left', onGraphicChanged); + on('change:graphic:top', onGraphicChanged); + on('change:graphic:rotation', onGraphicChanged); + on('change:graphic:width', onGraphicChanged); + on('change:graphic:height', onGraphicChanged); + on('change:graphic:layer', onGraphicChanged); + on('change:graphic:flipv', onGraphicChanged); + on('change:graphic:fliph', onGraphicChanged); + on('change:graphic:bar1_value', onGraphicChanged); + on('change:graphic:bar1_max', onGraphicChanged); + on('change:graphic:bar2_value', onGraphicChanged); + on('change:graphic:bar2_max', onGraphicChanged); + on('change:graphic:bar3_value', onGraphicChanged); + on('change:graphic:bar3_max', onGraphicChanged); + on('change:graphic:statusmarkers', onGraphicChanged); + on('change:graphic:name', onGraphicChanged); + on('change:graphic:tint_color', onGraphicChanged); + on('change:graphic:light_radius', onGraphicChanged); + on('change:graphic:light_dimradius', onGraphicChanged); + on('change:graphic:currentSide', onGraphicChanged); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Mirror.checkInstall(); + Mirror.registerEventHandlers(); +}); From cc212d3b3f8a8de3ca8d94dcc6bea6cd3d178764 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 08:19:20 -0400 Subject: [PATCH 02/28] Mirror: rewrite with single change event, property groups, API exposure, hard-lock for links --- Mirror/Mirror.js | 300 +++++++++++++++++++++++++++++------------------ 1 file changed, 188 insertions(+), 112 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 649f4e8094..22a5b55ba9 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -7,16 +7,17 @@ // 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 [props] [ids...] Link selected/listed tokens (unidirectional) -// !mirror unlink [props] [ids...] Remove link (or specific properties) -// !mirror chain [props] [ids...] Bidirectional ring link -// !mirror unchain [props] [ids...] Remove chain (or specific properties) -// !mirror status Show mirror state for selected tokens -// !mirror --help Command reference +// !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 */ @@ -43,6 +44,21 @@ var Mirror = Mirror || (() => { 'baseOpacity', 'currentSide' ]; + // Property groups + const PROP_GROUPS = { + position: ['left', 'top'], + size: ['width', 'height'], + spatial: ['left', 'top', 'rotation', 'width', 'height'], + bars: ['bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max'], + light: ['light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers', + 'light_hassight', 'light_losangle', 'light_multiplier', + 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', + 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance'], + auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square'], + flip: ['flipv', 'fliph'], + all: ALL_PROPS.slice() + }; + // ========================================================================= // Helpers // ========================================================================= @@ -65,10 +81,34 @@ var Mirror = Mirror || (() => { const ensureState = () => { if (!state[SCRIPT_NAME]) { state[SCRIPT_NAME] = { - // linkId → { props: [...], ids: [...], mode: 'link'|'chain' } - links: {} + // linkId → { props: [...], ids: [...], mode: 'link'|'chain', soft: bool } + links: {}, + // Set of token IDs that are part of any chain (for fast lookup) + chainedIds: {} }; } + if (!state[SCRIPT_NAME].chainedIds) state[SCRIPT_NAME].chainedIds = {}; + }; + + // ========================================================================= + // Property Resolution + // ========================================================================= + + const resolveProps = (args) => { + var props = []; + var remaining = []; + args.forEach(function(arg) { + 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 }; }; // ========================================================================= @@ -79,20 +119,24 @@ var Mirror = Mirror || (() => { return (msg.selected || []).map(function(s) { return s._id; }).filter(Boolean); }; - const parseArgs = (args) => { - var props = []; - var ids = []; - args.forEach(function(arg) { - if (ALL_PROPS.indexOf(arg) !== -1) props.push(arg); - else if (arg.startsWith('-')) ids.push(arg); // Roll20 IDs start with - - }); - return { props: props, ids: ids }; + const parseCommand = (msg, args) => { + var soft = args.indexOf('--soft') !== -1; + args = args.filter(function(a) { return a !== '--soft'; }); + var resolved = resolveProps(args); + var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); }); + ids = ids.concat(getSelectedIds(msg)); + ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe + var props = resolved.props.length > 0 ? resolved.props : ALL_PROPS.slice(); + return { props: props, ids: ids, soft: soft }; }; - const createLink = (mode, props, ids) => { + const createLink = (mode, props, ids, soft) => { var s = state[SCRIPT_NAME]; var linkId = genId(); - s.links[linkId] = { props: props, ids: ids, mode: mode }; + s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft }; + if (mode === 'chain') { + ids.forEach(function(id) { s.chainedIds[id] = true; }); + } return linkId; }; @@ -110,52 +154,94 @@ var Mirror = Mirror || (() => { var link = s.links[linkId]; if (!link) return; if (!propsToRemove || propsToRemove.length === 0) { + // Remove entire link + 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) delete s.links[linkId]; + if (link.props.length === 0) { + if (link.mode === 'chain') { + link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); + } + delete s.links[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; - const onGraphicChanged = (obj) => { + const onGraphicChanged = (obj, prev) => { if (syncing) return; var s = state[SCRIPT_NAME]; var tokenId = obj.get('id'); - // Find all links this token participates in + // Find changed properties + var changed = []; + ALL_PROPS.forEach(function(prop) { + if (prev[prop] !== undefined && prev[prop] !== obj.get(prop)) { + changed.push(prop); + } + }); + if (changed.length === 0) return; + Object.values(s.links).forEach(function(link) { var idx = link.ids.indexOf(tokenId); if (idx === -1) return; - // Determine which tokens to update - var targets; + // Determine relevant changed props for this link + var relevantProps = changed.filter(function(p) { return link.props.indexOf(p) !== -1; }); + if (relevantProps.length === 0) return; + if (link.mode === 'chain') { - // Bidirectional: update all others in the link - targets = link.ids.filter(function(id) { return id !== tokenId; }); + // Bidirectional: propagate to all others + var updates = {}; + relevantProps.forEach(function(p) { updates[p] = obj.get(p); }); + syncing = true; + link.ids.forEach(function(id) { + if (id === tokenId) return; + var target = getObj('graphic', id); + if (target) target.set(updates); + }); + syncing = false; } else { - // Unidirectional: only propagate if this is the source (first id) - if (idx !== 0) return; - targets = link.ids.slice(1); + // Unidirectional + if (idx === 0) { + // Source changed: propagate to targets + var updates = {}; + relevantProps.forEach(function(p) { updates[p] = obj.get(p); }); + syncing = true; + link.ids.slice(1).forEach(function(id) { + var target = getObj('graphic', id); + if (target) target.set(updates); + }); + syncing = false; + } 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); }); + syncing = true; + obj.set(revert); + syncing = false; + } + } } - - // Build updates from changed properties that are in this link's prop list - var updates = {}; - link.props.forEach(function(prop) { - updates[prop] = obj.get(prop); - }); - - syncing = true; - targets.forEach(function(targetId) { - var target = getObj('graphic', targetId); - if (target) target.set(updates); - }); - syncing = false; }); }; @@ -164,72 +250,48 @@ var Mirror = Mirror || (() => { // ========================================================================= const doLink = (msg, args) => { - var parsed = parseArgs(args); - var ids = parsed.ids.concat(getSelectedIds(msg)); - // Deduplicate - ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); - - if (ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } - var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice(); - var linkId = createLink('link', props, ids); - reply(msg, 'Link', 'Linked ' + ids.length + ' tokens (' + props.length + ' properties). Source: first selected/listed.'); + var parsed = parseCommand(msg, args); + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } + createLink('link', parsed.props, parsed.ids, parsed.soft); + reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + '). Source: first selected.'); }; const doUnlink = (msg, args) => { - var parsed = parseArgs(args); - var ids = parsed.ids.concat(getSelectedIds(msg)); - ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); - - if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } - + var parsed = parseCommand(msg, args); + if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + var propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null; var removed = 0; - ids.forEach(function(id) { - var links = findLinksForToken(id); - links.forEach(function(entry) { - if (entry.link.mode === 'link') { - removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null); - removed++; - } + parsed.ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode === 'link') { removePropsFromLink(entry.id, propsToRemove); removed++; } }); }); - reply(msg, 'Unlink', 'Removed ' + removed + ' link(s).'); + reply(msg, 'Unlink', 'Processed ' + removed + ' link(s).'); }; const doChain = (msg, args) => { - var parsed = parseArgs(args); - var ids = parsed.ids.concat(getSelectedIds(msg)); - ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); - - if (ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } - var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice(); - var linkId = createLink('chain', props, ids); - reply(msg, 'Chain', 'Chain-linked ' + ids.length + ' tokens (' + props.length + ' properties).'); + var parsed = parseCommand(msg, args); + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + createLink('chain', parsed.props, parsed.ids, true); + reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props).'); }; const doUnchain = (msg, args) => { - var parsed = parseArgs(args); - var ids = parsed.ids.concat(getSelectedIds(msg)); - ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); - - if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } - + var parsed = parseCommand(msg, args); + if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + var propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null; var removed = 0; - ids.forEach(function(id) { - var links = findLinksForToken(id); - links.forEach(function(entry) { - if (entry.link.mode === 'chain') { - removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null); - removed++; - } + parsed.ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode === 'chain') { removePropsFromLink(entry.id, propsToRemove); removed++; } }); }); - reply(msg, 'Unchain', 'Removed ' + removed + ' chain(s).'); + reply(msg, 'Unchain', 'Processed ' + removed + ' chain(s).'); }; 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); @@ -238,20 +300,22 @@ var Mirror = Mirror || (() => { out += '' + name + ': '; if (links.length === 0) { out += 'no mirror links
'; return; } links.forEach(function(entry) { - out += entry.link.mode + ' (' + entry.link.props.length + ' props, ' + entry.link.ids.length + ' tokens)
'; + 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); }; const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' - + '' + CMD + ' link [props] [ids...] -- Unidirectional link
' + + '' + CMD + ' link [--soft] [props] [ids...] -- Unidirectional (hard-lock by default)
' + '' + CMD + ' unlink [props] [ids...] -- Remove link
' + '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
' + '' + CMD + ' unchain [props] [ids...] -- Remove chain
' + '' + CMD + ' status -- Show links for selected
' + '' + CMD + ' --help -- This help
' - + '
Properties: ' + ALL_PROPS.join(', '); + + '
Groups: all, spatial, position, size, bars, light, auras, flip
' + + '
Props: ' + ALL_PROPS.join(', '); // ========================================================================= // Command Router @@ -276,6 +340,30 @@ var Mirror = Mirror || (() => { } }; + // ========================================================================= + // Public API + // ========================================================================= + + const link = (ids, props, soft) => { + if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; } + return createLink('link', props || ALL_PROPS.slice(), ids, !!soft); + }; + + const chainLink = (ids, props) => { + if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; } + return createLink('chain', props || ALL_PROPS.slice(), ids, true); + }; + + const unlink = (ids, props) => { + var s = state[SCRIPT_NAME]; + var propsToRemove = (props && props.length > 0) ? props : null; + ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + removePropsFromLink(entry.id, propsToRemove); + }); + }); + }; + // ========================================================================= // Initialization // ========================================================================= @@ -287,30 +375,18 @@ var Mirror = Mirror || (() => { const registerEventHandlers = () => { on('chat:message', handleInput); - // Listen to all property changes we care about - on('change:graphic:left', onGraphicChanged); - on('change:graphic:top', onGraphicChanged); - on('change:graphic:rotation', onGraphicChanged); - on('change:graphic:width', onGraphicChanged); - on('change:graphic:height', onGraphicChanged); - on('change:graphic:layer', onGraphicChanged); - on('change:graphic:flipv', onGraphicChanged); - on('change:graphic:fliph', onGraphicChanged); - on('change:graphic:bar1_value', onGraphicChanged); - on('change:graphic:bar1_max', onGraphicChanged); - on('change:graphic:bar2_value', onGraphicChanged); - on('change:graphic:bar2_max', onGraphicChanged); - on('change:graphic:bar3_value', onGraphicChanged); - on('change:graphic:bar3_max', onGraphicChanged); - on('change:graphic:statusmarkers', onGraphicChanged); - on('change:graphic:name', onGraphicChanged); - on('change:graphic:tint_color', onGraphicChanged); - on('change:graphic:light_radius', onGraphicChanged); - on('change:graphic:light_dimradius', onGraphicChanged); - on('change:graphic:currentSide', onGraphicChanged); + on('change:graphic', onGraphicChanged); }; - return { checkInstall, registerEventHandlers }; + return { + checkInstall, + registerEventHandlers, + link: link, + chainLink: chainLink, + unlink: unlink, + ALL_PROPS: ALL_PROPS, + PROP_GROUPS: PROP_GROUPS + }; })(); on('ready', () => { From bb5db30279d376f03c276ffc7750312ed1411cb2 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 08:44:02 -0400 Subject: [PATCH 03/28] Mirror: add --align flag, standalone align command --- Mirror/Mirror.js | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 22a5b55ba9..84f9d94fb3 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -121,13 +121,29 @@ var Mirror = Mirror || (() => { const parseCommand = (msg, args) => { var soft = args.indexOf('--soft') !== -1; - args = args.filter(function(a) { return a !== '--soft'; }); + var align = args.indexOf('--align') !== -1; + args = args.filter(function(a) { return a !== '--soft' && a !== '--align'; }); var resolved = resolveProps(args); var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); }); ids = ids.concat(getSelectedIds(msg)); ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe var props = resolved.props.length > 0 ? resolved.props : ALL_PROPS.slice(); - return { props: props, ids: ids, soft: soft }; + return { props: props, ids: ids, soft: soft, align: align }; + }; + + /** + * 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) => { @@ -253,7 +269,8 @@ var Mirror = Mirror || (() => { var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } createLink('link', parsed.props, parsed.ids, parsed.soft); - reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + '). Source: first selected.'); + if (parsed.align) alignTokens(parsed.ids, parsed.props); + reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.align ? ', aligned' : '') + '). Source: first selected.'); }; const doUnlink = (msg, args) => { @@ -273,7 +290,8 @@ var Mirror = Mirror || (() => { var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } createLink('chain', parsed.props, parsed.ids, true); - reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props).'); + if (parsed.align) alignTokens(parsed.ids, parsed.props); + reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.align ? ', aligned' : '') + ').'); }; const doUnchain = (msg, args) => { @@ -289,6 +307,13 @@ var Mirror = Mirror || (() => { reply(msg, 'Unchain', 'Processed ' + removed + ' chain(s).'); }; + const doAlign = (msg, args) => { + var parsed = parseCommand(msg, args); + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens.'); return; } + alignTokens(parsed.ids, parsed.props); + reply(msg, 'Align', 'Aligned ' + (parsed.ids.length - 1) + ' token(s) to source (' + parsed.props.length + ' props).'); + }; + const doStatus = (msg) => { var ids = getSelectedIds(msg); if (ids.length === 0) { reply(msg, 'Error', 'Select token(s).'); return; } @@ -312,6 +337,7 @@ var Mirror = Mirror || (() => { + '' + CMD + ' unlink [props] [ids...] -- Remove link
' + '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
' + '' + CMD + ' unchain [props] [ids...] -- Remove chain
' + + '' + CMD + ' align [props] [ids...] -- Copy props from first to others (one-shot)
' + '' + CMD + ' status -- Show links for selected
' + '' + CMD + ' --help -- This help
' + '
Groups: all, spatial, position, size, bars, light, auras, flip
' @@ -334,6 +360,7 @@ var Mirror = Mirror || (() => { 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 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; From 183fe25da957cd3ca3576f60ded526d01d60ae53 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 08:54:17 -0400 Subject: [PATCH 04/28] Mirror: dynamic knownProps set, Object.keys(prev) for future-proof sync, 'all' meta-group --- Mirror/Mirror.js | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 84f9d94fb3..9ddce68ff2 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -55,8 +55,7 @@ var Mirror = Mirror || (() => { 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance'], auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square'], - flip: ['flipv', 'fliph'], - all: ALL_PROPS.slice() + flip: ['flipv', 'fliph'] }; // ========================================================================= @@ -81,15 +80,19 @@ var Mirror = Mirror || (() => { const ensureState = () => { if (!state[SCRIPT_NAME]) { state[SCRIPT_NAME] = { - // linkId → { props: [...], ids: [...], mode: 'link'|'chain', soft: bool } links: {}, - // Set of token IDs that are part of any chain (for fast lookup) - chainedIds: {} + chainedIds: {}, + knownProps: {} }; } if (!state[SCRIPT_NAME].chainedIds) state[SCRIPT_NAME].chainedIds = {}; + if (!state[SCRIPT_NAME].knownProps) state[SCRIPT_NAME].knownProps = {}; + // Seed known props from ALL_PROPS + ALL_PROPS.forEach(function(p) { state[SCRIPT_NAME].knownProps[p] = true; }); }; + const getKnownProps = () => Object.keys(state[SCRIPT_NAME].knownProps); + // ========================================================================= // Property Resolution // ========================================================================= @@ -98,7 +101,9 @@ var Mirror = Mirror || (() => { var props = []; var remaining = []; args.forEach(function(arg) { - if (PROP_GROUPS[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); @@ -127,7 +132,7 @@ var Mirror = Mirror || (() => { var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); }); ids = ids.concat(getSelectedIds(msg)); ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe - var props = resolved.props.length > 0 ? resolved.props : ALL_PROPS.slice(); + var props = resolved.props.length > 0 ? resolved.props : getKnownProps(); return { props: props, ids: ids, soft: soft, align: align }; }; @@ -206,15 +211,15 @@ var Mirror = Mirror || (() => { var s = state[SCRIPT_NAME]; var tokenId = obj.get('id'); - // Find changed properties - var changed = []; - ALL_PROPS.forEach(function(prop) { - if (prev[prop] !== undefined && prev[prop] !== obj.get(prop)) { - changed.push(prop); - } + // 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; }); + Object.values(s.links).forEach(function(link) { var idx = link.ids.indexOf(tokenId); if (idx === -1) return; @@ -373,12 +378,12 @@ var Mirror = Mirror || (() => { const link = (ids, props, soft) => { if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; } - return createLink('link', props || ALL_PROPS.slice(), ids, !!soft); + return createLink('link', props || getKnownProps(), ids, !!soft); }; const chainLink = (ids, props) => { if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; } - return createLink('chain', props || ALL_PROPS.slice(), ids, true); + return createLink('chain', props || getKnownProps(), ids, true); }; const unlink = (ids, props) => { From da9df0755ebf50420f5cddd7a2b3291bf6f3067c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 09:10:32 -0400 Subject: [PATCH 05/28] Mirror: rewrite align with --linked/--unlinked semantics, respect link direction --- Mirror/Mirror.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 9ddce68ff2..38d24c27cf 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -313,10 +313,88 @@ var Mirror = Mirror || (() => { }; const doAlign = (msg, args) => { + var linked = args.indexOf('--linked') !== -1; + var unlinked = args.indexOf('--unlinked') !== -1; + args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked'; }); + // Default: --linked only + if (!linked && !unlinked) linked = true; + var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens.'); return; } - alignTokens(parsed.ids, parsed.props); - reply(msg, 'Align', 'Aligned ' + (parsed.ids.length - 1) + ' token(s) to source (' + parsed.props.length + ' props).'); + + 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; + var props = 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 updates = {}; + parsed.props.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) => { From 5206fce220f6fb5ffdfdb5ffa0f67fb566a593a8 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 09:12:55 -0400 Subject: [PATCH 06/28] Mirror: show token IDs in status output --- Mirror/Mirror.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 38d24c27cf..382237f929 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -403,9 +403,9 @@ var Mirror = Mirror || (() => { var out = ''; ids.forEach(function(id) { var obj = getObj('graphic', id); - var name = obj ? (obj.get('name') || id) : id; + var name = obj ? (obj.get('name') || '(unnamed)') : '?'; var links = findLinksForToken(id); - out += '' + name + ': '; + 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'); From 8d49cffa0c2489e53d723409b9a9360e4f8ba6a6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 09:28:23 -0400 Subject: [PATCH 07/28] Mirror: add --exclude, global excludes config, smart unchain/unlink (add to excludes for 'all' links) --- Mirror/Mirror.js | 158 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 20 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 382237f929..853b86019c 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -87,6 +87,7 @@ var Mirror = Mirror || (() => { } 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 known props from ALL_PROPS ALL_PROPS.forEach(function(p) { state[SCRIPT_NAME].knownProps[p] = true; }); }; @@ -128,12 +129,35 @@ var Mirror = Mirror || (() => { var soft = args.indexOf('--soft') !== -1; var align = args.indexOf('--align') !== -1; args = args.filter(function(a) { return a !== '--soft' && a !== '--align'; }); + + // 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('-'); }); ids = ids.concat(getSelectedIds(msg)); ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe - var props = resolved.props.length > 0 ? resolved.props : getKnownProps(); - return { props: props, ids: ids, soft: soft, align: align }; + + // Determine if using 'all' or specific props + var props; + if (resolved.props.length === 0) { + props = 'all'; // default: all + } else if (resolved.props.length === getKnownProps().length) { + props = 'all'; // explicit 'all' group resolved to full list + } else { + props = resolved.props; + } + + return { props: props, ids: ids, soft: soft, align: align, excludes: excludes }; }; /** @@ -151,16 +175,31 @@ var Mirror = Mirror || (() => { } }; - const createLink = (mode, props, ids, soft) => { + const createLink = (mode, props, ids, soft, excludes) => { var s = state[SCRIPT_NAME]; var linkId = genId(); - s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft }; + s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] }; if (mode === 'chain') { ids.forEach(function(id) { s.chainedIds[id] = true; }); } return linkId; }; + /** + * Get the effective props for a link, accounting for '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; }); + } + return link.props; + }; + + const getGlobalExcludes = () => { + return state[SCRIPT_NAME].globalExcludes || []; + }; + const findLinksForToken = (tokenId) => { var s = state[SCRIPT_NAME]; var results = []; @@ -225,7 +264,8 @@ var Mirror = Mirror || (() => { if (idx === -1) return; // Determine relevant changed props for this link - var relevantProps = changed.filter(function(p) { return link.props.indexOf(p) !== -1; }); + var effectiveProps = getEffectiveProps(link); + var relevantProps = changed.filter(function(p) { return effectiveProps.indexOf(p) !== -1; }); if (relevantProps.length === 0) return; if (link.mode === 'chain') { @@ -273,43 +313,119 @@ var Mirror = Mirror || (() => { const doLink = (msg, args) => { var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } - createLink('link', parsed.props, parsed.ids, parsed.soft); - if (parsed.align) alignTokens(parsed.ids, parsed.props); - reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.align ? ', aligned' : '') + '). Source: first selected.'); + createLink('link', parsed.props, parsed.ids, parsed.soft, parsed.excludes); + if (parsed.align) alignTokens(parsed.ids, parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props); + var propCount = parsed.props === 'all' ? 'all' : parsed.props.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' : '') + ').'); }; const doUnlink = (msg, args) => { var parsed = parseCommand(msg, args); if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } - var propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null; - var removed = 0; + var hasSpecificProps = parsed.props !== 'all'; + var processed = 0; parsed.ids.forEach(function(id) { findLinksForToken(id).forEach(function(entry) { - if (entry.link.mode === 'link') { removePropsFromLink(entry.id, propsToRemove); removed++; } + if (entry.link.mode !== 'link') return; + if (!hasSpecificProps) { + // No props specified: remove entire link + removePropsFromLink(entry.id, null); + } else if (entry.link.props === 'all') { + // Link uses 'all': add props to excludes + parsed.props.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, 'Unlink', 'Processed ' + removed + ' link(s).'); + reply(msg, 'Unlink', 'Processed ' + processed + ' link(s).'); }; const doChain = (msg, args) => { var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } - createLink('chain', parsed.props, parsed.ids, true); - if (parsed.align) alignTokens(parsed.ids, parsed.props); - reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.align ? ', aligned' : '') + ').'); + + // Check if tokens are already in an existing chain — if so, re-include props + var existingChain = null; + var links = findLinksForToken(parsed.ids[0]); + for (var i = 0; i < links.length; i++) { + if (links[i].link.mode === 'chain') { existingChain = links[i]; break; } + } + + if (existingChain && parsed.props !== 'all') { + // Re-include: remove specified props from excludes + existingChain.link.excludes = (existingChain.link.excludes || []).filter(function(p) { + return parsed.props.indexOf(p) === -1; + }); + reply(msg, 'Chain', 'Re-included ' + parsed.props.length + ' prop(s) in existing chain.'); + } else { + createLink('chain', parsed.props, parsed.ids, true, parsed.excludes); + if (parsed.align) { + var alignProps = parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props; + alignTokens(parsed.ids, alignProps); + } + var propCount = parsed.props === 'all' ? 'all' : parsed.props.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 propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null; - var removed = 0; + var hasSpecificProps = parsed.props !== 'all'; + var processed = 0; parsed.ids.forEach(function(id) { findLinksForToken(id).forEach(function(entry) { - if (entry.link.mode === 'chain') { removePropsFromLink(entry.id, propsToRemove); removed++; } + 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 ' + removed + ' chain(s).'); + reply(msg, 'Unchain', 'Processed ' + processed + ' chain(s).'); + }; + + const doConfig = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + // Show current config + 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) => { @@ -420,7 +536,8 @@ var Mirror = Mirror || (() => { + '' + CMD + ' unlink [props] [ids...] -- Remove link
' + '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
' + '' + CMD + ' unchain [props] [ids...] -- Remove chain
' - + '' + CMD + ' align [props] [ids...] -- Copy props from first to others (one-shot)
' + + '' + CMD + ' align [--linked|--unlinked] [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
' @@ -444,6 +561,7 @@ var Mirror = Mirror || (() => { 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; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; From 45f79b33f15fc4a3f9001f6320de5644583d0f0b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 09:39:43 -0400 Subject: [PATCH 08/28] Mirror: use 'api-all' for API calls (bypasses global excludes), 'all' for chat commands --- Mirror/Mirror.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 853b86019c..a1a89865f4 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -186,13 +186,17 @@ var Mirror = Mirror || (() => { }; /** - * Get the effective props for a link, accounting for 'all' and excludes. + * 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; }; @@ -574,12 +578,12 @@ var Mirror = Mirror || (() => { const link = (ids, props, soft) => { if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; } - return createLink('link', props || getKnownProps(), ids, !!soft); + return createLink('link', props || 'api-all', ids, !!soft); }; const chainLink = (ids, props) => { if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; } - return createLink('chain', props || getKnownProps(), ids, true); + return createLink('chain', props || 'api-all', ids, true); }; const unlink = (ids, props) => { From f6133ea23722eb1d40c1316c58e4fc43b2837fb8 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 09:53:38 -0400 Subject: [PATCH 09/28] Mirror: startup config drift warning, remove inline reminder from config command --- Mirror/Mirror.js | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index a1a89865f4..5a6f187fcb 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -82,16 +82,29 @@ var Mirror = Mirror || (() => { state[SCRIPT_NAME] = { links: {}, chainedIds: {}, - knownProps: {} + 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); // ========================================================================= @@ -407,7 +420,6 @@ var Mirror = Mirror || (() => { const doConfig = (msg, args) => { var s = state[SCRIPT_NAME]; if (args.length === 0) { - // Show current config reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)')); return; } @@ -603,6 +615,25 @@ var Mirror = Mirror || (() => { 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 = () => { From b154d929479a02cf54546457bdfe8d847c6fc7ae Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 10:33:06 -0400 Subject: [PATCH 10/28] Mirror: null props = context-dependent (link scope for align, 'all' for link/chain) --- Mirror/Mirror.js | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 5a6f187fcb..d0139bfa3a 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -161,11 +161,12 @@ var Mirror = Mirror || (() => { 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 = 'all'; // default: all + props = null; // no props specified } else if (resolved.props.length === getKnownProps().length) { - props = 'all'; // explicit 'all' group resolved to full list + props = 'all'; // explicit 'all' group } else { props = resolved.props; } @@ -330,16 +331,20 @@ var Mirror = Mirror || (() => { const doLink = (msg, args) => { var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } - createLink('link', parsed.props, parsed.ids, parsed.soft, parsed.excludes); - if (parsed.align) alignTokens(parsed.ids, parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props); - var propCount = parsed.props === 'all' ? 'all' : parsed.props.length; + var linkProps = parsed.props || 'all'; // null = default to all + createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes); + 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' : '') + ').'); }; 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 !== 'all'; + var hasSpecificProps = parsed.props !== null && parsed.props !== 'all'; var processed = 0; parsed.ids.forEach(function(id) { findLinksForToken(id).forEach(function(entry) { @@ -365,6 +370,7 @@ var Mirror = Mirror || (() => { const doChain = (msg, args) => { var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + var linkProps = parsed.props || 'all'; // null = default to all // Check if tokens are already in an existing chain — if so, re-include props var existingChain = null; @@ -373,19 +379,19 @@ var Mirror = Mirror || (() => { if (links[i].link.mode === 'chain') { existingChain = links[i]; break; } } - if (existingChain && parsed.props !== 'all') { + if (existingChain && Array.isArray(linkProps)) { // Re-include: remove specified props from excludes existingChain.link.excludes = (existingChain.link.excludes || []).filter(function(p) { - return parsed.props.indexOf(p) === -1; + return linkProps.indexOf(p) === -1; }); - reply(msg, 'Chain', 'Re-included ' + parsed.props.length + ' prop(s) in existing chain.'); + reply(msg, 'Chain', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.'); } else { - createLink('chain', parsed.props, parsed.ids, true, parsed.excludes); + createLink('chain', linkProps, parsed.ids, true, parsed.excludes); if (parsed.align) { - var alignProps = parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props; + var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps; alignTokens(parsed.ids, alignProps); } - var propCount = parsed.props === 'all' ? 'all' : parsed.props.length; + 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' : '') + ').'); } }; @@ -393,7 +399,7 @@ var Mirror = Mirror || (() => { 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 !== 'all'; + var hasSpecificProps = parsed.props !== null && parsed.props !== 'all'; var processed = 0; parsed.ids.forEach(function(id) { findLinksForToken(id).forEach(function(entry) { @@ -463,7 +469,9 @@ var Mirror = Mirror || (() => { var links = findLinksForToken(id); links.forEach(function(entry) { var link = entry.link; - var props = parsed.props; + // 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; }); @@ -510,8 +518,9 @@ var Mirror = Mirror || (() => { 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 = {}; - parsed.props.forEach(function(p) { updates[p] = source.get(p); }); + alignProps.forEach(function(p) { updates[p] = source.get(p); }); parsed.ids.slice(1).forEach(function(id) { var links = findLinksForToken(id); if (links.length === 0) { From 9c27899367bb80d5496db63d153dea5ea65072f5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 10:49:00 -0400 Subject: [PATCH 11/28] Mirror: unlink removes tokens from chains, whispers error for prop-specific unlink on chains --- Mirror/Mirror.js | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index d0139bfa3a..a3cbcb0242 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -346,25 +346,43 @@ var Mirror = Mirror || (() => { 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 !== 'link') return; - if (!hasSpecificProps) { - // No props specified: remove entire link - removePropsFromLink(entry.id, null); - } else if (entry.link.props === 'all') { - // Link uses 'all': add props to excludes - parsed.props.forEach(function(p) { - if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); - }); + 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 { - // Link uses specific props: remove them - removePropsFromLink(entry.id, parsed.props); + // 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++; } - processed++; }); }); - reply(msg, 'Unlink', 'Processed ' + processed + ' link(s).'); + 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); }; const doChain = (msg, args) => { From 48de8ed5c121c13348f61c7ab9fa35e8dafbf272 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 10:53:30 -0400 Subject: [PATCH 12/28] Mirror: link/chain add props to existing links instead of creating new ones --- Mirror/Mirror.js | 54 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index a3cbcb0242..325d60d6d9 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -332,13 +332,36 @@ var Mirror = Mirror || (() => { var parsed = parseCommand(msg, args); if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } var linkProps = parsed.props || 'all'; // null = default to all - createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes); - if (parsed.align) { - var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps; - alignTokens(parsed.ids, alignProps); + + // Check if source token already has an existing link + var existingLink = null; + var links = findLinksForToken(parsed.ids[0]); + for (var i = 0; i < links.length; i++) { + if (links[i].link.mode === 'link' && links[i].link.ids[0] === parsed.ids[0]) { existingLink = links[i]; break; } + } + + 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 { + createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes); + 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' : '') + ').'); } - 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' : '') + ').'); }; const doUnlink = (msg, args) => { @@ -398,11 +421,20 @@ var Mirror = Mirror || (() => { } if (existingChain && Array.isArray(linkProps)) { - // Re-include: remove specified props from excludes - existingChain.link.excludes = (existingChain.link.excludes || []).filter(function(p) { - return linkProps.indexOf(p) === -1; - }); - reply(msg, 'Chain', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.'); + var link = existingChain.link; + if (link.props === 'all' || link.props === 'api-all') { + // Re-include: remove specified props from excludes + link.excludes = (link.excludes || []).filter(function(p) { + return linkProps.indexOf(p) === -1; + }); + reply(msg, 'Chain', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.'); + } else { + // Specific props list: add new props + linkProps.forEach(function(p) { + if (link.props.indexOf(p) === -1) link.props.push(p); + }); + reply(msg, 'Chain', 'Added ' + linkProps.length + ' prop(s) to existing chain (' + link.props.length + ' total).'); + } } else { createLink('chain', linkProps, parsed.ids, true, parsed.excludes); if (parsed.align) { From eb7c55c276741dd05760d8bf09ddaaa5ba8f43bb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 11:09:40 -0400 Subject: [PATCH 13/28] Mirror: single-token support for link/chain/align, --up/--down for ambiguous links --- Mirror/Mirror.js | 98 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 325d60d6d9..98722045fe 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -329,15 +329,37 @@ var Mirror = Mirror || (() => { // ========================================================================= 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); - if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; } - var linkProps = parsed.props || 'all'; // null = default to all + var linkProps = parsed.props || 'all'; - // Check if source token already has an existing link + // Check if token already has an existing link var existingLink = null; - var links = findLinksForToken(parsed.ids[0]); - for (var i = 0; i < links.length; i++) { - if (links[i].link.mode === 'link' && links[i].link.ids[0] === parsed.ids[0]) { existingLink = links[i]; break; } + 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)) { @@ -410,14 +432,20 @@ var Mirror = Mirror || (() => { const doChain = (msg, args) => { var parsed = parseCommand(msg, args); - if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } var linkProps = parsed.props || 'all'; // null = default to all - // Check if tokens are already in an existing chain — if so, re-include props + // If only 1 token, check if it's already in a chain var existingChain = null; - var links = findLinksForToken(parsed.ids[0]); - for (var i = 0; i < links.length; i++) { - if (links[i].link.mode === 'chain') { existingChain = links[i]; break; } + if (parsed.ids.length >= 1) { + var links = findLinksForToken(parsed.ids[0]); + for (var i = 0; i < links.length; i++) { + if (links[i].link.mode === 'chain') { existingChain = links[i]; break; } + } + } + + if (parsed.ids.length < 2 && !existingChain) { + reply(msg, 'Error', 'Chain requires at least 2 tokens (or 1 token already in a chain).'); + return; } if (existingChain && Array.isArray(linkProps)) { @@ -508,7 +536,53 @@ var Mirror = Mirror || (() => { if (!linked && !unlinked) linked = true; var parsed = parseCommand(msg, args); - if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens.'); return; } + + // Allow single token if it's in a link/chain + if (parsed.ids.length === 1 && linked) { + var singleLinks = findLinksForToken(parsed.ids[0]); + if (singleLinks.length > 0) { + // For chains: align entire chain to this token + // For links: parent aligns children, child aligns to parent + var aligned = 0; + singleLinks.forEach(function(entry) { + var link = entry.link; + var props = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + var source = getObj('graphic', parsed.ids[0]); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + + if (link.mode === 'chain') { + // Align entire chain to selected token + link.ids.forEach(function(tid) { + if (tid === parsed.ids[0]) return; + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; } + }); + } else if (link.ids[0] === parsed.ids[0]) { + // Parent selected: align children + link.ids.slice(1).forEach(function(tid) { + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; } + }); + } else { + // Child selected: align to parent + var parent = getObj('graphic', link.ids[0]); + if (parent) { + var parentUpdates = {}; + props.forEach(function(p) { parentUpdates[p] = parent.get(p); }); + source.set(parentUpdates); + aligned++; + } + } + }); + 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; From 24511265ce5c72a417309c7ea31ff623ad512f45 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 11:12:26 -0400 Subject: [PATCH 14/28] Mirror: add --up/--down to align for parent/child disambiguation --- Mirror/Mirror.js | 68 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 98722045fe..a852b852dd 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -531,7 +531,9 @@ var Mirror = Mirror || (() => { const doAlign = (msg, args) => { var linked = args.indexOf('--linked') !== -1; var unlinked = args.indexOf('--unlinked') !== -1; - args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked'; }); + var up = args.indexOf('--up') !== -1; + var down = args.indexOf('--down') !== -1; + args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down'; }); // Default: --linked only if (!linked && !unlinked) linked = true; @@ -541,42 +543,64 @@ var Mirror = Mirror || (() => { if (parsed.ids.length === 1 && linked) { var singleLinks = findLinksForToken(parsed.ids[0]); if (singleLinks.length > 0) { - // For chains: align entire chain to this token - // For links: parent aligns children, child aligns to parent + var asParent = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]; }); + var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; }); + var asChain = singleLinks.filter(function(e) { return e.link.mode === 'chain'; }); + + if (asParent.length > 0 && asChild.length > 0 && !up && !down) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (align to parent) or --down (align children to me).'); + return; + } + var aligned = 0; - singleLinks.forEach(function(entry) { + var source = getObj('graphic', parsed.ids[0]); + if (!source) { reply(msg, 'Error', 'Token not found.'); return; } + + // Handle chains + asChain.forEach(function(entry) { var link = entry.link; var props = parsed.props === null ? getEffectiveProps(link) : parsed.props === 'all' ? getKnownProps() : parsed.props; - var source = getObj('graphic', parsed.ids[0]); - if (!source) return; var updates = {}; props.forEach(function(p) { updates[p] = source.get(p); }); + link.ids.forEach(function(tid) { + if (tid === parsed.ids[0]) return; + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; } + }); + }); - if (link.mode === 'chain') { - // Align entire chain to selected token - link.ids.forEach(function(tid) { - if (tid === parsed.ids[0]) return; - var t = getObj('graphic', tid); - if (t) { t.set(updates); aligned++; } - }); - } else if (link.ids[0] === parsed.ids[0]) { - // Parent selected: align children + // Handle one-way links + if (down || (!up && asParent.length > 0 && asChild.length === 0)) { + // Align children to me + asParent.forEach(function(entry) { + var link = entry.link; + var props = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + 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 { - // Child selected: align to parent + }); + } + if (up || (!down && asChild.length > 0 && asParent.length === 0)) { + // Align me to parent + asChild.forEach(function(entry) { + var link = entry.link; + var props = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; var parent = getObj('graphic', link.ids[0]); if (parent) { - var parentUpdates = {}; - props.forEach(function(p) { parentUpdates[p] = parent.get(p); }); - source.set(parentUpdates); + var updates = {}; + props.forEach(function(p) { updates[p] = parent.get(p); }); + source.set(updates); aligned++; } - } - }); + }); + } + reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).'); return; } From 6c301e97845d9ae7a0c4406ca4d9f254ffd6084c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 11:27:18 -0400 Subject: [PATCH 15/28] Mirror: error on !mirror chain (no props) with existing chain and no unchained tokens --- Mirror/Mirror.js | 92 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index a852b852dd..ed2949ceec 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -432,45 +432,75 @@ var Mirror = Mirror || (() => { const doChain = (msg, args) => { var parsed = parseCommand(msg, args); - var linkProps = parsed.props || 'all'; // null = default to all + var linkProps = parsed.props; // null = no props specified, 'all' = explicit all, [...] = specific - // If only 1 token, check if it's already in a chain - var existingChain = null; - if (parsed.ids.length >= 1) { - var links = findLinksForToken(parsed.ids[0]); - for (var i = 0; i < links.length; i++) { - if (links[i].link.mode === 'chain') { existingChain = links[i]; break; } - } - } + if (parsed.ids.length < 1) { reply(msg, 'Error', 'Select or specify at least one token.'); return; } - if (parsed.ids.length < 2 && !existingChain) { - reply(msg, 'Error', 'Chain requires at least 2 tokens (or 1 token already in a chain).'); - 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 (existingChain && Array.isArray(linkProps)) { - var link = existingChain.link; - if (link.props === 'all' || link.props === 'api-all') { - // Re-include: remove specified props from excludes - link.excludes = (link.excludes || []).filter(function(p) { - return linkProps.indexOf(p) === -1; + 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', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.'); + reply(msg, 'Chain', 'Added ' + unchainedIds.length + ' token(s) to existing chain (' + chain.ids.length + ' total).'); } else { - // Specific props list: add new props - linkProps.forEach(function(p) { - if (link.props.indexOf(p) === -1) link.props.push(p); - }); - reply(msg, 'Chain', 'Added ' + linkProps.length + ' prop(s) to existing chain (' + link.props.length + ' total).'); + // No existing chains: create new chain with 'all' + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + createLink('chain', 'all', parsed.ids, true, parsed.excludes); + 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 { - createLink('chain', linkProps, parsed.ids, true, parsed.excludes); - if (parsed.align) { - var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps; - alignTokens(parsed.ids, alignProps); + // 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; } + createLink('chain', linkProps, parsed.ids, true, parsed.excludes); + 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' : '') + ').'); } - 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' : '') + ').'); } }; From 6c19083de1d099a69b05adc9cc098815492b24ed Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 11:38:11 -0400 Subject: [PATCH 16/28] =?UTF-8?q?Mirror:=20align=20application=20order=20f?= =?UTF-8?q?ixed=20to=20up=20=E2=86=92=20chain=20=E2=86=92=20down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mirror/Mirror.js | 77 ++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index ed2949ceec..a4e9691761 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -563,13 +563,13 @@ var Mirror = Mirror || (() => { var unlinked = args.indexOf('--unlinked') !== -1; var up = args.indexOf('--up') !== -1; var down = args.indexOf('--down') !== -1; - args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down'; }); - // Default: --linked only + var chainFlag = args.indexOf('--chain') !== -1; + args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain'; }); if (!linked && !unlinked) linked = true; var parsed = parseCommand(msg, args); - // Allow single token if it's in a link/chain + // Single-token align if (parsed.ids.length === 1 && linked) { var singleLinks = findLinksForToken(parsed.ids[0]); if (singleLinks.length > 0) { @@ -577,57 +577,70 @@ var Mirror = Mirror || (() => { var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; }); var asChain = singleLinks.filter(function(e) { return e.link.mode === 'chain'; }); - if (asParent.length > 0 && asChild.length > 0 && !up && !down) { - reply(msg, 'Error', 'Token is both parent and child. Use --up (align to parent) or --down (align children to me).'); + var types = (asChain.length > 0 ? 1 : 0) + (asParent.length > 0 ? 1 : 0) + (asChild.length > 0 ? 1 : 0); + var hasFlags = up || down || chainFlag; + + // If multiple relationship types and no flags, error + if (types > 1 && !hasFlags) { + var typeNames = []; + if (asChain.length > 0) typeNames.push('--chain'); + if (asChild.length > 0) typeNames.push('--up'); + if (asParent.length > 0) typeNames.push('--down'); + reply(msg, 'Error', 'Token has multiple relationship types. Specify: ' + typeNames.join(', ')); return; } + // Determine what to do + var doChainAlign = chainFlag || (!hasFlags && asChain.length > 0 && types === 1); + var doUp = up || (!hasFlags && asChild.length > 0 && types === 1); + var doDown = down || (!hasFlags && asParent.length > 0 && types === 1); + var aligned = 0; var source = getObj('graphic', parsed.ids[0]); if (!source) { reply(msg, 'Error', 'Token not found.'); return; } - // Handle chains - asChain.forEach(function(entry) { - var link = entry.link; - var props = parsed.props === null ? getEffectiveProps(link) : - parsed.props === 'all' ? getKnownProps() : parsed.props; - var updates = {}; - props.forEach(function(p) { updates[p] = source.get(p); }); - link.ids.forEach(function(tid) { - if (tid === parsed.ids[0]) return; - var t = getObj('graphic', tid); - if (t) { t.set(updates); aligned++; } + // Application order: up → chain → down + if (doUp) { + asChild.forEach(function(entry) { + var link = entry.link; + var props = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + var parent = getObj('graphic', link.ids[0]); + if (parent) { + var updates = {}; + props.forEach(function(p) { updates[p] = parent.get(p); }); + source.set(updates); + aligned++; + } }); - }); + } - // Handle one-way links - if (down || (!up && asParent.length > 0 && asChild.length === 0)) { - // Align children to me - asParent.forEach(function(entry) { + if (doChainAlign) { + asChain.forEach(function(entry) { var link = entry.link; var props = parsed.props === null ? getEffectiveProps(link) : parsed.props === 'all' ? getKnownProps() : parsed.props; var updates = {}; props.forEach(function(p) { updates[p] = source.get(p); }); - link.ids.slice(1).forEach(function(tid) { + link.ids.forEach(function(tid) { + if (tid === parsed.ids[0]) return; var t = getObj('graphic', tid); if (t) { t.set(updates); aligned++; } }); }); } - if (up || (!down && asChild.length > 0 && asParent.length === 0)) { - // Align me to parent - asChild.forEach(function(entry) { + + if (doDown) { + asParent.forEach(function(entry) { var link = entry.link; var props = parsed.props === null ? getEffectiveProps(link) : parsed.props === 'all' ? getKnownProps() : parsed.props; - var parent = getObj('graphic', link.ids[0]); - if (parent) { - var updates = {}; - props.forEach(function(p) { updates[p] = parent.get(p); }); - source.set(updates); - aligned++; - } + 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++; } + }); }); } From 598b7804de8cb49acef8df2a0cc79c1a3f32dea4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 11:44:35 -0400 Subject: [PATCH 17/28] Mirror: recursive propagation with visited set for both change events and align --down --- Mirror/Mirror.js | 131 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 93 insertions(+), 38 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index a4e9691761..fba3004e6c 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -263,6 +263,48 @@ var Mirror = Mirror || (() => { 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]; @@ -277,51 +319,53 @@ var Mirror = Mirror || (() => { // 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; - // Determine relevant changed props for this link 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') { - // Bidirectional: propagate to all others - var updates = {}; - relevantProps.forEach(function(p) { updates[p] = obj.get(p); }); - syncing = true; link.ids.forEach(function(id) { - if (id === tokenId) return; + if (visited.has(id)) return; + visited.add(id); var target = getObj('graphic', id); - if (target) target.set(updates); + if (target) { + target.set(updates); + propagateUpdates(id, updates, visited); + } }); - syncing = false; - } else { - // Unidirectional - if (idx === 0) { - // Source changed: propagate to targets - var updates = {}; - relevantProps.forEach(function(p) { updates[p] = obj.get(p); }); - syncing = true; - link.ids.slice(1).forEach(function(id) { - var target = getObj('graphic', id); - if (target) target.set(updates); - }); - syncing = false; - } 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); }); - syncing = true; - obj.set(revert); - syncing = false; + } 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; }; // ========================================================================= @@ -631,17 +675,28 @@ var Mirror = Mirror || (() => { } if (doDown) { - asParent.forEach(function(entry) { - var link = entry.link; - var props = parsed.props === null ? getEffectiveProps(link) : - parsed.props === 'all' ? getKnownProps() : parsed.props; + var downVisited = new Set([parsed.ids[0]]); + var alignDown = function(parentId, props) { + var s = state[SCRIPT_NAME]; + var parentObj = getObj('graphic', parentId); + if (!parentObj) 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++; } + props.forEach(function(p) { updates[p] = parentObj.get(p); }); + Object.values(s.links).forEach(function(link) { + if (link.mode !== 'link' || link.ids[0] !== parentId) return; + var linkProps = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + var linkUpdates = {}; + linkProps.forEach(function(p) { linkUpdates[p] = parentObj.get(p); }); + link.ids.slice(1).forEach(function(tid) { + if (downVisited.has(tid)) return; + downVisited.add(tid); + var t = getObj('graphic', tid); + if (t) { t.set(linkUpdates); aligned++; alignDown(tid, linkProps); } + }); }); - }); + }; + alignDown(parsed.ids[0], parsed.props === null ? getKnownProps() : (parsed.props === 'all' ? getKnownProps() : parsed.props)); } reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).'); From bccf0a8641e9c195f25b5e32a68dc04b004f32b3 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 11:55:31 -0400 Subject: [PATCH 18/28] Mirror: simplify align to up+cascade model, remove --chain flag, always cascade recursively --- Mirror/Mirror.js | 115 ++++++++++++++++++++--------------------------- 1 file changed, 48 insertions(+), 67 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index fba3004e6c..edd6e4dc38 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -607,9 +607,10 @@ var Mirror = Mirror || (() => { var unlinked = args.indexOf('--unlinked') !== -1; var up = args.indexOf('--up') !== -1; var down = args.indexOf('--down') !== -1; - var chainFlag = args.indexOf('--chain') !== -1; args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain'; }); if (!linked && !unlinked) linked = true; + // --up takes precedence; --up --down is same as --up + if (up) down = false; var parsed = parseCommand(msg, args); @@ -617,87 +618,67 @@ var Mirror = Mirror || (() => { if (parsed.ids.length === 1 && linked) { var singleLinks = findLinksForToken(parsed.ids[0]); if (singleLinks.length > 0) { - var asParent = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]; }); var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; }); - var asChain = singleLinks.filter(function(e) { return e.link.mode === 'chain'; }); - - var types = (asChain.length > 0 ? 1 : 0) + (asParent.length > 0 ? 1 : 0) + (asChild.length > 0 ? 1 : 0); - var hasFlags = up || down || chainFlag; - - // If multiple relationship types and no flags, error - if (types > 1 && !hasFlags) { - var typeNames = []; - if (asChain.length > 0) typeNames.push('--chain'); - if (asChild.length > 0) typeNames.push('--up'); - if (asParent.length > 0) typeNames.push('--down'); - reply(msg, 'Error', 'Token has multiple relationship types. Specify: ' + typeNames.join(', ')); + 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; } - // Determine what to do - var doChainAlign = chainFlag || (!hasFlags && asChain.length > 0 && types === 1); - var doUp = up || (!hasFlags && asChild.length > 0 && types === 1); - var doDown = down || (!hasFlags && asParent.length > 0 && types === 1); - - var aligned = 0; var source = getObj('graphic', parsed.ids[0]); if (!source) { reply(msg, 'Error', 'Token not found.'); return; } + var aligned = 0; - // Application order: up → chain → down - if (doUp) { - asChild.forEach(function(entry) { - var link = entry.link; - var props = parsed.props === null ? getEffectiveProps(link) : - parsed.props === 'all' ? getKnownProps() : parsed.props; - var parent = getObj('graphic', link.ids[0]); - if (parent) { - var updates = {}; - props.forEach(function(p) { updates[p] = parent.get(p); }); - source.set(updates); - aligned++; - } - }); + // 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++; + } } - if (doChainAlign) { - asChain.forEach(function(entry) { - var link = entry.link; - var props = parsed.props === null ? getEffectiveProps(link) : + // 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 linkProps = parsed.props === null ? getEffectiveProps(link) : parsed.props === 'all' ? getKnownProps() : parsed.props; var updates = {}; - props.forEach(function(p) { updates[p] = source.get(p); }); - link.ids.forEach(function(tid) { - if (tid === parsed.ids[0]) return; - var t = getObj('graphic', tid); - if (t) { t.set(updates); aligned++; } - }); - }); - } + linkProps.forEach(function(p) { updates[p] = tokenObj.get(p); }); - if (doDown) { - var downVisited = new Set([parsed.ids[0]]); - var alignDown = function(parentId, props) { - var s = state[SCRIPT_NAME]; - var parentObj = getObj('graphic', parentId); - if (!parentObj) return; - var updates = {}; - props.forEach(function(p) { updates[p] = parentObj.get(p); }); - Object.values(s.links).forEach(function(link) { - if (link.mode !== 'link' || link.ids[0] !== parentId) return; - var linkProps = parsed.props === null ? getEffectiveProps(link) : - parsed.props === 'all' ? getKnownProps() : parsed.props; - var linkUpdates = {}; - linkProps.forEach(function(p) { linkUpdates[p] = parentObj.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 (downVisited.has(tid)) return; - downVisited.add(tid); + if (cascadeVisited.has(tid)) return; + cascadeVisited.add(tid); var t = getObj('graphic', tid); - if (t) { t.set(linkUpdates); aligned++; alignDown(tid, linkProps); } + if (t) { t.set(updates); aligned++; cascadeFrom(tid); } }); - }); - }; - alignDown(parsed.ids[0], parsed.props === null ? getKnownProps() : (parsed.props === 'all' ? getKnownProps() : parsed.props)); - } + } + }); + }; + cascadeFrom(parsed.ids[0]); reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).'); return; From 89dcd4d35486f09fea0405951976b4280e7114b8 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 12:03:20 -0400 Subject: [PATCH 19/28] Mirror: add --if-linked flag to align (intersect requested props with link scope) --- Mirror/Mirror.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index edd6e4dc38..b410325993 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -607,7 +607,8 @@ var Mirror = Mirror || (() => { var unlinked = args.indexOf('--unlinked') !== -1; var up = args.indexOf('--up') !== -1; var down = args.indexOf('--down') !== -1; - args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain'; }); + 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; @@ -656,8 +657,10 @@ var Mirror = Mirror || (() => { Object.values(s.links).forEach(function(link) { var idx = link.ids.indexOf(tokenId); if (idx === -1) return; - var linkProps = parsed.props === null ? getEffectiveProps(link) : + 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); }); From 2a3d9a737e5bb4a4fe9fd4ac631084449affb603 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 12:07:13 -0400 Subject: [PATCH 20/28] =?UTF-8?q?Mirror:=20full=20public=20API=20=E2=80=94?= =?UTF-8?q?=20link,=20chainLink,=20unlink,=20unchain,=20addToChain,=20remo?= =?UTF-8?q?veFromChain,=20align,=20getLinks,=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mirror/Mirror.js | 154 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 12 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index b410325993..4e04656056 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -827,26 +827,148 @@ var Mirror = Mirror || (() => { // Public API // ========================================================================= - const link = (ids, props, soft) => { + /** 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); + return createLink('link', props || 'api-all', ids, !!soft, excludes); }; - const chainLink = (ids, props) => { + /** 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); + return createLink('chain', props || 'api-all', ids, true, excludes); }; - const unlink = (ids, props) => { - var s = state[SCRIPT_NAME]; - var propsToRemove = (props && props.length > 0) ? props : null; + /** 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) { - removePropsFromLink(entry.id, propsToRemove); + 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/set global excludes. */ + const apiGetGlobalExcludes = () => (state[SCRIPT_NAME].globalExcludes || []).slice(); + const apiSetGlobalExcludes = (excludes) => { state[SCRIPT_NAME].globalExcludes = excludes; }; + // ========================================================================= // Initialization // ========================================================================= @@ -883,11 +1005,19 @@ var Mirror = Mirror || (() => { return { checkInstall, registerEventHandlers, - link: link, - chainLink: chainLink, - unlink: unlink, + link: apiLink, + chainLink: apiChainLink, + unlink: apiUnlink, + unchain: apiUnchain, + addToChain: apiAddToChain, + removeFromChain: apiRemoveFromChain, + align: apiAlign, + getLinks: apiGetLinks, + getGlobalExcludes: apiGetGlobalExcludes, + setGlobalExcludes: apiSetGlobalExcludes, ALL_PROPS: ALL_PROPS, - PROP_GROUPS: PROP_GROUPS + PROP_GROUPS: PROP_GROUPS, + getKnownProps: getKnownProps }; })(); From c693abe0e17ad9998598f4bb4d5bee088ed7df90 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 12:13:38 -0400 Subject: [PATCH 21/28] Mirror: add getParent, getChildren, getChainMembers query helpers --- Mirror/Mirror.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 4e04656056..9c351186f9 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -965,6 +965,31 @@ var Mirror = Mirror || (() => { /** 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; }; @@ -1013,6 +1038,9 @@ var Mirror = Mirror || (() => { removeFromChain: apiRemoveFromChain, align: apiAlign, getLinks: apiGetLinks, + getParent: apiGetParent, + getChildren: apiGetChildren, + getChainMembers: apiGetChainMembers, getGlobalExcludes: apiGetGlobalExcludes, setGlobalExcludes: apiSetGlobalExcludes, ALL_PROPS: ALL_PROPS, From 5f77f31cff2ad9e55dcc279f44acd53e5e6aec1e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 12:19:09 -0400 Subject: [PATCH 22/28] Mirror: add --help update, Help handout generation, gen-dev-docs command --- Mirror/Mirror.js | 65 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 9c351186f9..44cbba5b75 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -787,15 +787,16 @@ var Mirror = Mirror || (() => { }; const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' - + '' + CMD + ' link [--soft] [props] [ids...] -- Unidirectional (hard-lock by default)
' - + '' + CMD + ' unlink [props] [ids...] -- Remove link
' - + '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
' - + '' + CMD + ' unchain [props] [ids...] -- Remove chain
' - + '' + CMD + ' align [--linked|--unlinked] [props] [ids...] -- Align tokens
' + + '' + 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
' + + '
Groups: all, spatial, position, size, bars, light, auras, flip' + + '
Flags: --soft, --align, --exclude, --up, --down, --linked, --unlinked, --if-linked' + '
Props: ' + ALL_PROPS.join(', '); // ========================================================================= @@ -818,6 +819,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; } @@ -1002,6 +1004,7 @@ var Mirror = Mirror || (() => { ensureState(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkConfigDrift(); + generateHelpHandout(); }; const checkConfigDrift = () => { @@ -1022,6 +1025,56 @@ 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, inplayerjournals: 'all', archived: false }); + var html = '

' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

'; + html += '

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

'; + html += '

Commands

    '; + html += '
  • !mirror link [--soft] [--align] [--exclude props] [props] [ids...] — Unidirectional link
  • '; + html += '
  • !mirror unlink [props] [ids...] — Remove link
  • '; + html += '
  • !mirror chain [--align] [--exclude props] [props] [ids...] — Bidirectional chain
  • '; + html += '
  • !mirror unchain [props] [ids...] — Remove chain
  • '; + html += '
  • !mirror align [--up|--down] [--linked|--unlinked] [--if-linked] [props] — Align tokens
  • '; + html += '
  • !mirror config [exclude|include|reset] [props] — Global excludes
  • '; + html += '
  • !mirror status — Show links
  • '; + html += '
  • !mirror --help — Command reference
  • '; + html += '
'; + 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

'; + 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, inplayerjournals: 'all', archived: false }); + 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 += '

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); From ccf19594042e3e3592f141e94aa17b460786b574 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 12:26:44 -0400 Subject: [PATCH 23/28] Mirror: prevent multiple hard parents and hard-linked children from joining chains --- Mirror/Mirror.js | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 44cbba5b75..9949a8986f 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -191,11 +191,43 @@ var Mirror = Mirror || (() => { const createLink = (mode, props, ids, soft, excludes) => { var s = state[SCRIPT_NAME]; - var linkId = genId(); - s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] }; + + // 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 || [] }; return linkId; }; @@ -420,7 +452,8 @@ var Mirror = Mirror || (() => { reply(msg, 'Link', 'Added ' + linkProps.length + ' prop(s) to existing link (' + link.props.length + ' total).'); } } else { - createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes); + 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); @@ -514,7 +547,8 @@ var Mirror = Mirror || (() => { } else { // No existing chains: create new chain with 'all' if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } - createLink('chain', 'all', parsed.ids, true, parsed.excludes); + 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' : '') + ').'); } @@ -537,7 +571,8 @@ var Mirror = Mirror || (() => { } else { // No existing chains: create new if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } - createLink('chain', linkProps, parsed.ids, true, parsed.excludes); + 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); From 1577a097fb1af63dcc3243657a4082b3779288a6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 12:35:53 -0400 Subject: [PATCH 24/28] Mirror: add 'anchor' property group for easy --exclude of Anchor-managed props --- Mirror/Mirror.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 9949a8986f..83a292f4f6 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -55,7 +55,8 @@ var Mirror = Mirror || (() => { 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance'], auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square'], - flip: ['flipv', 'fliph'] + flip: ['flipv', 'fliph'], + anchor: ['left', 'top', 'width', 'height', 'rotation', 'flipv', 'fliph', 'layer'] }; // ========================================================================= From f944248505b107a810c13e528515d920f3e208c1 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 12:40:43 -0400 Subject: [PATCH 25/28] Mirror: add avatar to help and gen-dev-docs handouts --- Mirror/Mirror.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 83a292f4f6..2adb5cea4d 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -1064,7 +1064,7 @@ 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, inplayerjournals: 'all', archived: false }); + if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', 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

    '; @@ -1092,7 +1092,7 @@ var Mirror = Mirror || (() => { 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, inplayerjournals: 'all', archived: false }); + if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', 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

    '; From d445b625611bb382f8e9f9a7ef6f0bb0e9510141 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 13:14:00 -0400 Subject: [PATCH 26/28] Mirror: --exclude filters explicit prop lists immediately instead of storing as runtime excludes --- Mirror/Mirror.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 2adb5cea4d..89fee55c2c 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -169,7 +169,11 @@ var Mirror = Mirror || (() => { } else if (resolved.props.length === getKnownProps().length) { props = 'all'; // explicit 'all' group } else { - props = resolved.props; + // 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 }; From ee6514228fb4b3ec86dcc2b81e8d3223ffd27cc2 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 13:34:22 -0400 Subject: [PATCH 27/28] Mirror: add script.json, README, versioned folder for PR --- Mirror/1.0.0/Mirror.js | 1149 ++++++++++++++++++++++++++++++++++++++++ Mirror/README.md | 84 +++ Mirror/script.json | 20 + 3 files changed, 1253 insertions(+) create mode 100644 Mirror/1.0.0/Mirror.js create mode 100644 Mirror/README.md create mode 100644 Mirror/script.json diff --git a/Mirror/1.0.0/Mirror.js b/Mirror/1.0.0/Mirror.js new file mode 100644 index 0000000000..89fee55c2c --- /dev/null +++ b/Mirror/1.0.0/Mirror.js @@ -0,0 +1,1149 @@ +// ============================================================================= +// Mirror v1.0.0 +// Last Updated: 2026-06-15 +// 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.0.0'; + const CMD = '!mirror'; + + // All syncable graphic properties + const ALL_PROPS = [ + 'left', 'top', 'width', 'height', 'rotation', + 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'aura1_radius', 'aura1_color', 'aura1_square', + 'aura2_radius', 'aura2_color', 'aura2_square', + 'tint_color', 'statusmarkers', 'name', 'showname', + 'light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers', + 'light_hassight', 'light_losangle', 'light_multiplier', + 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', + 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance', + 'baseOpacity', 'currentSide' + ]; + + // Property groups + const PROP_GROUPS = { + position: ['left', 'top'], + size: ['width', 'height'], + spatial: ['left', 'top', 'rotation', 'width', 'height'], + bars: ['bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max'], + light: ['light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers', + 'light_hassight', 'light_losangle', 'light_multiplier', + 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance', + 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance'], + auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square'], + flip: ['flipv', 'fliph'], + anchor: ['left', 'top', 'width', 'height', 'rotation', 'flipv', 'fliph', 'layer'] + }; + + // ========================================================================= + // 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; + args = args.filter(function(a) { return a !== '--soft' && a !== '--align'; }); + + // 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('-'); }); + 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 || [] }; + return linkId; + }; + + /** + * 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 + 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) { + if (link.mode === 'chain') { + link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); + } + delete s.links[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' : '') + ').'); + } + }; + + 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); + }; + + 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); + }; + + 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 + // ========================================================================= + + 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 '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; + case 'gen-dev-docs': generateDevDocs(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); 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(); + generateHelpHandout(); + }; + + 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 generateHelpHandout = () => { + var name = 'Help: ' + SCRIPT_NAME; + var hh = findObjs({ type: 'handout', name: name })[0]; + if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', 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 += '
    • !mirror link [--soft] [--align] [--exclude props] [props] [ids...] — Unidirectional link
    • '; + html += '
    • !mirror unlink [props] [ids...] — Remove link
    • '; + html += '
    • !mirror chain [--align] [--exclude props] [props] [ids...] — Bidirectional chain
    • '; + html += '
    • !mirror unchain [props] [ids...] — Remove chain
    • '; + html += '
    • !mirror align [--up|--down] [--linked|--unlinked] [--if-linked] [props] — Align tokens
    • '; + html += '
    • !mirror config [exclude|include|reset] [props] — Global excludes
    • '; + html += '
    • !mirror status — Show links
    • '; + html += '
    • !mirror --help — Command reference
    • '; + html += '
    '; + 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

    '; + 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, inplayerjournals: 'all', 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 += '

    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); + }; + + return { + checkInstall, + registerEventHandlers, + 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(); +}); diff --git a/Mirror/README.md b/Mirror/README.md new file mode 100644 index 0000000000..db681d338c --- /dev/null +++ b/Mirror/README.md @@ -0,0 +1,84 @@ +# Mirror + +Flat property syncing between Roll20 tokens. No transforms, no offsets — when a property changes on one token, the same value is copied to linked tokens. + +## Requirements + +- Roll20 Pro subscription (API access required) + +## Features + +- **Unidirectional links** — source token drives targets (hard-lock by default) +- **Bidirectional chains** — any token in the ring can drive the others +- **Property groups** — sync all, spatial, bars, light, or individual props +- **Dynamic property discovery** — new Roll20 properties detected automatically +- **Recursive propagation** — changes cascade through link trees +- **Hard/soft lock** — hard (default) reverts child changes; soft allows divergence +- **Global excludes** — configure properties that never sync via the `all` group +- **Anchor-aware** — `--exclude anchor` group for tokens also using Anchor + +## Commands + +| Command | Description | +|---------|-------------| +| `!mirror link [--soft] [--align] [--exclude props] [props] [ids...]` | Unidirectional link | +| `!mirror unlink [props] [ids...]` | Remove link or add excludes | +| `!mirror chain [--align] [--exclude props] [props] [ids...]` | Bidirectional chain | +| `!mirror unchain [props] [ids...]` | Remove chain or add excludes | +| `!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 | + +## Property Groups + +| Group | Properties | +|-------|-----------| +| `all` | Dynamic — all known properties minus global excludes | +| `spatial` | left, top, rotation, width, height | +| `position` | left, top | +| `size` | width, height | +| `bars` | bar1-3 value + max | +| `light` | All light/vision properties | +| `auras` | aura1-2 radius, color, square | +| `flip` | flipv, fliph | +| `anchor` | spatial + flip + layer (for `--exclude` when using Anchor) | + +## Flags + +| Flag | Description | +|------|-------------| +| `--soft` | Don't revert child changes (link only) | +| `--align` | Align on creation | +| `--exclude ` | Exclude from group | +| `--up` | Align to parent first | +| `--down` | Cascade from current value | +| `--linked` | Only operate on linked tokens (align) | +| `--unlinked` | Only operate on unlinked tokens (align) | +| `--if-linked` | Only align props that are actually linked | + +## API + +```javascript +Mirror.link(ids, props, soft, excludes) +Mirror.chainLink(ids, props, excludes) +Mirror.unlink(ids, props) +Mirror.unchain(ids, props) +Mirror.addToChain(existingId, newIds) +Mirror.removeFromChain(tokenId) +Mirror.align(sourceId, { up, ifLinked, props }) +Mirror.getLinks(tokenId) +Mirror.getParent(childId) +Mirror.getChildren(parentId) +Mirror.getChainMembers(tokenId) +Mirror.getGlobalExcludes() +Mirror.setGlobalExcludes(arr) +Mirror.getKnownProps() +Mirror.ALL_PROPS +Mirror.PROP_GROUPS +``` + +## License + +MIT diff --git a/Mirror/script.json b/Mirror/script.json new file mode 100644 index 0000000000..66ee730991 --- /dev/null +++ b/Mirror/script.json @@ -0,0 +1,20 @@ +{ + "name": "Mirror", + "script": "Mirror.js", + "version": "1.0.0", + "previousversions": [], + "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.\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", + "authors": "Kenan Millet", + "roll20userid": "2614613", + "dependencies": [], + "modifies": { + "graphic": "read, write" + }, + "conflicts": [], + "useroptions": [{ + "name": "Global Excludes", + "type": "text", + "default": "", + "description": "Comma-separated list of properties excluded from the 'all' group by default (e.g. represents,imgsrc)" + }] +} From e1bfafc62539c35ce71cc56f77f6d9f98eeafad7 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 13:42:36 -0400 Subject: [PATCH 28/28] Mirror: add Anchor cooperation notes to README, help handout, and dev docs --- Mirror/1.0.0/Mirror.js | 9 +++++---- Mirror/Mirror.js | 9 +++++---- Mirror/README.md | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/Mirror/1.0.0/Mirror.js b/Mirror/1.0.0/Mirror.js index 89fee55c2c..26c3e250e9 100644 --- a/Mirror/1.0.0/Mirror.js +++ b/Mirror/1.0.0/Mirror.js @@ -1090,10 +1090,10 @@ var Mirror = Mirror || (() => { html += '--up: align to parent first
    '; html += '--down: cascade from current value
    '; html += '--if-linked: only align props that are actually linked

    '; - hh.set('notes', html); - }; - - const generateDevDocs = (msg) => { + 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, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); @@ -1105,6 +1105,7 @@ var Mirror = Mirror || (() => { 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

    '; diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 89fee55c2c..26c3e250e9 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -1090,10 +1090,10 @@ var Mirror = Mirror || (() => { html += '--up: align to parent first
    '; html += '--down: cascade from current value
    '; html += '--if-linked: only align props that are actually linked

    '; - hh.set('notes', html); - }; - - const generateDevDocs = (msg) => { + 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, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); @@ -1105,6 +1105,7 @@ var Mirror = Mirror || (() => { 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

    '; diff --git a/Mirror/README.md b/Mirror/README.md index db681d338c..6bda9885e5 100644 --- a/Mirror/README.md +++ b/Mirror/README.md @@ -79,6 +79,23 @@ Mirror.ALL_PROPS Mirror.PROP_GROUPS ``` +## Using with Anchor + +Mirror and Anchor complement each other — Anchor handles spatial transforms (position, rotation, scale with offsets), Mirror handles flat property copying (bars, status, light, etc.). + +To avoid conflicts on tokens that use both: + +``` +!mirror chain --exclude anchor +``` + +This syncs everything *except* what Anchor manages (left, top, rotation, width, height, flipv, fliph, layer). + +**API equivalent of `Anchor.updateObj`:** +```javascript +Mirror.align(tokenId, { ifLinked: true }) // push linked props to dependents +``` + ## License MIT