diff --git a/Mirror/1.0.0/Mirror.js b/Mirror/1.0.0/Mirror.js
new file mode 100644
index 0000000000..26c3e250e9
--- /dev/null
+++ b/Mirror/1.0.0/Mirror.js
@@ -0,0 +1,1150 @@
+// =============================================================================
+// 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 = '
Flat property syncing between tokens. No transforms — values are copied directly.
'; + html += '!mirror link [--soft] [--align] [--exclude props] [props] [ids...] — Unidirectional link!mirror unlink [props] [ids...] — Remove link!mirror chain [--align] [--exclude props] [props] [ids...] — Bidirectional chain!mirror unchain [props] [ids...] — Remove chain!mirror align [--up|--down] [--linked|--unlinked] [--if-linked] [props] — Align tokens!mirror config [exclude|include|reset] [props] — Global excludes!mirror status — Show links!mirror --help — Command referenceall (dynamic), spatial (left,top,rotation,width,height), position (left,top), size (width,height), bars, light, auras, flip
'; + 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
Use --exclude anchor on tokens that also use Anchor for spatial sync. This prevents overlap.
!mirror chain --exclude anchor — sync everything except Anchor-managed props.
Access via Mirror.* after on("ready").
Mirror.link(ids, props, soft, excludes) // unidirectional\nMirror.chainLink(ids, props, excludes) // bidirectional chain'; + 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 += '
Mirror.align(sourceId, { up, ifLinked, props })';
+ html += 'Mirror.align(id, { ifLinked: true }) is the equivalent of Anchor.updateObj() — pushes linked props to all dependents.
Mirror.getLinks(tokenId) // → [{ id, link }]\nMirror.getParent(childId) // → parentId or null\nMirror.getChildren(parentId) // → [childIds]\nMirror.getChainMembers(tokenId) // → [ids]';
+ html += 'Mirror.getGlobalExcludes() // → [props]\nMirror.setGlobalExcludes(arr)\nMirror.getKnownProps() // → [all known prop names]'; + 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/Mirror.js b/Mirror/Mirror.js
new file mode 100644
index 0000000000..26c3e250e9
--- /dev/null
+++ b/Mirror/Mirror.js
@@ -0,0 +1,1150 @@
+// =============================================================================
+// 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 += '!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 += '' + 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!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 = 'Flat property syncing between tokens. No transforms — values are copied directly.
'; + html += '!mirror link [--soft] [--align] [--exclude props] [props] [ids...] — Unidirectional link!mirror unlink [props] [ids...] — Remove link!mirror chain [--align] [--exclude props] [props] [ids...] — Bidirectional chain!mirror unchain [props] [ids...] — Remove chain!mirror align [--up|--down] [--linked|--unlinked] [--if-linked] [props] — Align tokens!mirror config [exclude|include|reset] [props] — Global excludes!mirror status — Show links!mirror --help — Command referenceall (dynamic), spatial (left,top,rotation,width,height), position (left,top), size (width,height), bars, light, auras, flip
'; + 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
Use --exclude anchor on tokens that also use Anchor for spatial sync. This prevents overlap.
!mirror chain --exclude anchor — sync everything except Anchor-managed props.
Access via Mirror.* after on("ready").
Mirror.link(ids, props, soft, excludes) // unidirectional\nMirror.chainLink(ids, props, excludes) // bidirectional chain'; + 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 += '
Mirror.align(sourceId, { up, ifLinked, props })';
+ html += 'Mirror.align(id, { ifLinked: true }) is the equivalent of Anchor.updateObj() — pushes linked props to all dependents.
Mirror.getLinks(tokenId) // → [{ id, link }]\nMirror.getParent(childId) // → parentId or null\nMirror.getChildren(parentId) // → [childIds]\nMirror.getChainMembers(tokenId) // → [ids]';
+ html += 'Mirror.getGlobalExcludes() // → [props]\nMirror.setGlobalExcludes(arr)\nMirror.getKnownProps() // → [all known prop names]'; + 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..6bda9885e5
--- /dev/null
+++ b/Mirror/README.md
@@ -0,0 +1,101 @@
+# 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