From 8257b1f5e8935498bdb594ff00470b2d898a4a54 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 11:31:17 -0400 Subject: [PATCH 01/96] Gaslight: add SCRIPTING_DESIGN.md for reactive per-player automation system --- Gaslight/SCRIPTING_DESIGN.md | 156 +++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 Gaslight/SCRIPTING_DESIGN.md diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md new file mode 100644 index 0000000000..1c519064af --- /dev/null +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -0,0 +1,156 @@ +# Gaslight Scripting — Design Document + +## Concept + +A reactive automation layer within Gaslight that evaluates conditions per-player and applies per-player actions (show/hide/set properties). Scripts are stored in handouts, activated per-page via pins, and triggered automatically when referenced values change. + +## Motivating Example + +A stealthing creature should be invisible to players whose passive perception is below the creature's stealth roll, and visible to those who meet or exceed it: + +``` +if target.stealth_result > viewer.passive_perception: + set target.baseOpacity = 0 +else: + set target.baseOpacity = 1 +``` + +## Architecture + +### Storage + +- **Handout** (notes or gmnotes) = the reusable script logic +- **Pin** on a page = "this script is active here" + - `link` → handout ID + - `gmNotes` → pin-specific configuration (scope, filter, trigger rules) + +### Scope (configured per-pin) + +The pin's gmNotes declares how the script iterates: + +- `scope: token` — runs for each individual token on the page. Per-token data stored in token gmnotes. +- `scope: character` — runs once per character, applies to all tokens of that character. Data stored as character sheet attributes. + +### Targets (configured per-pin) + +Filter which tokens/characters the script evaluates against: + +- `filter: npc` — only tokens not controlled by any player +- `filter: has ` — only tokens/characters with a specific field set +- `filter: tag ` — only characters with a specific tag +- `filter: all` — every token on the page +- Custom filter expressions (v2) + +### Variables + +Two namespaces resolved by Gaslight: + +- `target.*` — the token/character being evaluated + - Resolved from gmnotes (scope: token) or character attribute (scope: character) + - Also includes standard token properties and character attributes +- `viewer.*` — the player whose page we're evaluating + - Character attributes from the viewer's controlled character + +### Integration with Meta-Toolbox + +**Required dependencies:** +- ZeroFrame — ensures processing order +- Fetch — attribute/property resolution; extended by Gaslight via compProps +- Muler — context variable injection (viewer identity) + +**Optional:** +- APILogic — if/elseif/else conditionals +- MathOps — inline math + +**Fetch extension:** +Gaslight registers computed properties on `Fetch.CustomPropsByType.graphic.compProps` at startup. These read from a module-level context variable set during evaluation: + +```javascript +// Pseudocode +let evaluationContext = { target: null, viewer: null, scope: 'token' }; + +Fetch.CustomPropsByType.graphic.compProps.stealth_result = { + nicks: ['stealth_roll'], + val: (o) => { + if (evaluationContext.scope === 'token') return readGmNotesField(o.gmnotes, 'stealth_result'); + else return getAttrByName(o.represents, 'stealth_result'); + } +}; +``` + +**Muler injection:** +Before each evaluation pass, Gaslight sends a Muler set command to establish viewer context variables. + +### Triggers + +Scripts auto-trigger based on: + +1. **Attribute change** — parsed from script references. If the script mentions `target.stealth_result`, Gaslight watches for changes to that field. +2. **Token property change** — if the script references `target.baseOpacity`, watch `change:graphic:baseOpacity`. +3. **Chat roll capture** — configured in pin gmNotes. Matches roll results from chat by pattern/template name, stores the result, which triggers #1. +4. **Manual** — `!gaslight eval ` forces re-evaluation. + +### Chat Roll Capture + +**Trigger rule** (in pin gmNotes): +``` +trigger: roll "Stealth" → stealth_result +``` + +**Resolution order:** +1. Selected token at time of roll → store result on that token +2. Ambiguous (none/multiple selected, not enough rolls) → queue and prompt GM with clickable buttons +3. Future: apply to all tokens of that character (configurable) + +**Storage:** +- `scope: token` → writes to token gmnotes: `stealth_result: ` +- `scope: character` → writes to character attribute: `stealth_result = ` + +### Evaluation Flow + +For each trigger event: +1. Identify which scripts are affected (which pins reference the changed field) +2. For each affected script, for each player page: + a. Set module-level `evaluationContext` (target token, viewer player) + b. Inject viewer context via Muler + c. `sendChat` the script content through ZeroFrame/Fetch/APILogic pipeline + d. Target script (e.g. token-mod) executes the resulting command + +### Pin gmNotes Format + +``` +---GASLIGHT-SCRIPT--- +scope: token +filter: has stealth_result +trigger: roll "Stealth" → stealth_result +``` + +### Handout Format + +The handout notes/gmnotes contain commands using standard Meta-Toolbox syntax: + +``` +{& if @(target.stealth_result) > @(viewer.passive_perception)} +!token-mod --ids @(target.token_id) --set baseOpacity|0 +{& else} +!token-mod --ids @(target.token_id) --set baseOpacity|1 +{& end} +``` + +## Open Questions + +1. How do we detect which fields a script references for auto-trigger registration? Regex parse of `@(target.*)` and `@(viewer.*)` patterns? +2. Should scripts support multi-line (multiple commands per evaluation)? If so, do they execute sequentially or as one batch? +3. How to handle script errors gracefully (bad syntax, missing attributes)? +4. Should there be a "dry run" mode for testing scripts without applying changes? +5. Performance: how many change:attribute listeners is too many? Should we debounce? +6. Can a script reference other scripts (composition/chaining)? +7. Should we support `@(target.*)` for token properties that Fetch already handles (left, top, bar1_value)? Or only for gaslight-managed fields? + +## Future Ideas + +- Visual script editor (handout with structured format) +- Script debugging/logging mode +- Conditional FX (play effects based on script results) +- Script templates (pre-built stealth, darkvision, illusion scripts) +- Event history (log of what changed and why) From fce90239eaad05f559433bd73c2749bf4c71204b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 11:42:58 -0400 Subject: [PATCH 02/96] =?UTF-8?q?Gaslight=20scripting:=20clarify=20viewer.?= =?UTF-8?q?*=20semantics=20=E2=80=94=20iterate=20each,=20any-pass=20defaul?= =?UTF-8?q?t,=20aggregation=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/SCRIPTING_DESIGN.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index 1c519064af..5572ce13dd 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -49,7 +49,14 @@ Two namespaces resolved by Gaslight: - Resolved from gmnotes (scope: token) or character attribute (scope: character) - Also includes standard token properties and character attributes - `viewer.*` — the player whose page we're evaluating - - Character attributes from the viewer's controlled character + - Represents the viewing PLAYER, not a single token + - A player may control multiple tokens on their page + - `viewer.*` attribute references iterate over each controlled token by default ("each" semantics) + - If ANY viewer token's evaluation passes, the action applies (most permissive wins) + - Aggregation functions available: `max(viewer.passive_perception)`, `min(...)`, `any(...)`, `all(...)` + - `all(...)` requires every viewer token to pass + - Player-level properties (viewer.id, viewer.name, viewer.page) are singular, not iterated + - Party-tagged tokens may be used as a narrowing hint but do NOT guarantee a single token ### Integration with Meta-Toolbox From 36fa63f9bd03815db637e5a5d6dd469d4beb91f2 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 11:57:54 -0400 Subject: [PATCH 03/96] =?UTF-8?q?Gaslight=20scripting:=20fix=20registratio?= =?UTF-8?q?n=20timing=20=E2=80=94=20on=20handout=20create/modify,=20not=20?= =?UTF-8?q?startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/SCRIPTING_DESIGN.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index 5572ce13dd..917e71a085 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -70,23 +70,33 @@ Two namespaces resolved by Gaslight: - MathOps — inline math **Fetch extension:** -Gaslight registers computed properties on `Fetch.CustomPropsByType.graphic.compProps` at startup. These read from a module-level context variable set during evaluation: +Gaslight registers computed properties on `Fetch.CustomPropsByType.graphic.compProps` when script handouts are created or modified. Properties use the `gl_` prefix as a namespace. Resolution depends on evaluation context (scope): -```javascript -// Pseudocode -let evaluationContext = { target: null, viewer: null, scope: 'token' }; +- `scope: token` → reads from token gmnotes field `gl_: ` +- `scope: character` → reads from character attribute named `gl_` + +Convention: the `gl_` prefix is used consistently everywhere — in gmnotes, character attributes, AND script references. No stripping. -Fetch.CustomPropsByType.graphic.compProps.stealth_result = { - nicks: ['stealth_roll'], +```javascript +// Registered dynamically per gl_ field found in active scripts +Fetch.CustomPropsByType.graphic.compProps['gl_stealth_result'] = { + nicks: [], val: (o) => { - if (evaluationContext.scope === 'token') return readGmNotesField(o.gmnotes, 'stealth_result'); - else return getAttrByName(o.represents, 'stealth_result'); + if (evaluationContext.scope === 'token') { + return readGmNotesField(o.gmnotes, 'gl_stealth_result'); + } else { + return getAttrByName(o.represents, 'gl_stealth_result'); + } } }; ``` +Script reference: `@(target.gl_stealth_result)` + +CompProps are registered at handout creation/modification time — Gaslight watches `change:handout` and `add:handout`, scans the content for `gl_*` references, and registers any new compProps. This ensures they're ready before any evaluation fires. + **Muler injection:** -Before each evaluation pass, Gaslight sends a Muler set command to establish viewer context variables. +Before each evaluation pass, Gaslight sends a Muler set command to establish viewer context variables (viewer.id, viewer.name, viewer.page, etc.). ### Triggers From ab3f6f829bd987c31bbc9ca6c6ac52eae66e996b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 12:03:57 -0400 Subject: [PATCH 04/96] =?UTF-8?q?Gaslight=20scripting:=20trigger=20rules?= =?UTF-8?q?=20=E2=80=94=20auto=20from=20conditions=20only,=20manual=20over?= =?UTF-8?q?ride,=20eval=20command=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/SCRIPTING_DESIGN.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index 917e71a085..adf79ab5ed 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -100,12 +100,24 @@ Before each evaluation pass, Gaslight sends a Muler set command to establish vie ### Triggers -Scripts auto-trigger based on: +**Auto-detection (default):** +Gaslight parses the script and identifies references inside conditional blocks (`{& if}` ... `{& end}`). Only condition inputs are watched — action outputs (inside `!` command lines) are NOT triggers. This prevents infinite loops. -1. **Attribute change** — parsed from script references. If the script mentions `target.stealth_result`, Gaslight watches for changes to that field. -2. **Token property change** — if the script references `target.baseOpacity`, watch `change:graphic:baseOpacity`. -3. **Chat roll capture** — configured in pin gmNotes. Matches roll results from chat by pattern/template name, stores the result, which triggers #1. -4. **Manual** — `!gaslight eval ` forces re-evaluation. +**Manual override (pin gmNotes):** +``` +trigger: auto ← default, derive from conditions +trigger: manual only ← only fires via !gaslight eval +trigger: on change gl_stealth_result ← explicit field watch (additive) +trigger: on roll "Stealth" ← chat roll capture +trigger: ignore passive_perception ← exclude from auto-detection +``` + +Multiple trigger lines are additive. `manual only` disables all auto-triggers. + +**Manual evaluation:** +- `!gaslight eval` (with pins selected) — evaluate selected pins +- `!gaslight eval ` — evaluate all pins linked to that handout +- `!gaslight eval --all` — re-evaluate all active pins ### Chat Roll Capture From 2d69fe619a0d0570f5b4213952d6528638630e41 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 12:12:41 -0400 Subject: [PATCH 05/96] =?UTF-8?q?Gaslight=20scripting:=20resolve=20open=20?= =?UTF-8?q?questions=20=E2=80=94=20trigger=20map,=20error=20handling,=20dr?= =?UTF-8?q?y=20run,=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/SCRIPTING_DESIGN.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index adf79ab5ed..c18fd84053 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -166,15 +166,28 @@ The handout notes/gmnotes contain commands using standard Meta-Toolbox syntax: {& end} ``` -## Open Questions - -1. How do we detect which fields a script references for auto-trigger registration? Regex parse of `@(target.*)` and `@(viewer.*)` patterns? -2. Should scripts support multi-line (multiple commands per evaluation)? If so, do they execute sequentially or as one batch? -3. How to handle script errors gracefully (bad syntax, missing attributes)? -4. Should there be a "dry run" mode for testing scripts without applying changes? -5. Performance: how many change:attribute listeners is too many? Should we debounce? -6. Can a script reference other scripts (composition/chaining)? -7. Should we support `@(target.*)` for token properties that Fetch already handles (left, top, bar1_value)? Or only for gaslight-managed fields? +## Resolved Questions + +1. **Field detection for auto-triggers:** Regex parse `@(target.gl_*)` and `@(viewer.*)` patterns inside `{& if}` blocks. Basic bracket matching to distinguish conditions from actions. + +2. **Multi-line scripts:** Yes. Multiple commands per evaluation, executed sequentially. APILogic likely handles this natively. + +3. **Error handling:** Try/catch around our resolution/sendChat phase. Whisper GM on errors (missing attributes, bad pin config, missing handout). Downstream script errors are outside our control. + +4. **Dry run:** `!gaslight eval --dry` (pins selected). Shows resolved commands and affected tokens without applying. + +5. **Performance:** Single `on('change:attribute')` handler with a trigger map for O(1) lookup: + ``` + triggerMap = { + 'gl_stealth_result': [{ pinId, handoutId, scope }], + 'passive_perception': [{ pinId, handoutId, scope }] + }; + ``` + Built at handout parse time. Rebuilt on handout change. Debounce per-script (100ms). + +6. **Script composition:** Deferred to v3. Scripts are self-contained for now. + +7. **Standard token properties:** Fetch handles natively. We only register `gl_*` compProps. ## Future Ideas From 2b7102aadbc3447c317c70549e0782976f29830a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 12:18:01 -0400 Subject: [PATCH 06/96] =?UTF-8?q?Gaslight=20scripting:=20basic=20evaluatio?= =?UTF-8?q?n=20engine=20=E2=80=94=20eval=20command,=20pin=20detection,=20h?= =?UTF-8?q?andout=20parsing,=20per-viewer=20iteration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 180 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 94089efa96..a686d60711 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1653,6 +1653,185 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' status -- Show state
' + '' + CMD + ' --help -- This help
'; + // ========================================================================= + // Scripting Engine + // ========================================================================= + + /** + * Read a handout's notes content (async → callback pattern). + * Returns content via callback since Roll20 requires it for notes/gmnotes. + */ + const getHandoutContent = (handoutId, callback) => { + var handout = getObj('handout', handoutId); + if (!handout) { callback(null); return; } + handout.get('notes', function(notes) { + callback(notes || ''); + }); + }; + + /** + * Find pins on a page that are gaslight script pins (linked to a handout). + */ + const findScriptPins = (pageId) => { + var pins = findObjs({ _type: 'pin', _pageid: pageId }); + return pins.filter(function(pin) { + return pin.get('link') && pin.get('linkType') === 'handout'; + }); + }; + + /** + * Parse pin gmNotes for script configuration. + */ + const parsePinConfig = (pin) => { + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + var config = { scope: 'token', filter: 'all', triggers: [] }; + if (!notes.includes('---GASLIGHT-SCRIPT---')) return null; + notes.split('\n').forEach(function(line) { + line = line.trim(); + if (line.startsWith('scope:')) config.scope = line.slice(6).trim(); + else if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); + else if (line.startsWith('trigger:')) config.triggers.push(line.slice(8).trim()); + }); + return config; + }; + + /** + * Get target tokens for evaluation based on pin config filter. + */ + const getTargetTokens = (pageId, config, activeGroups) => { + var tokens = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var filter = config.filter.toLowerCase(); + if (filter === 'all') return tokens; + if (filter === 'npc') { + return tokens.filter(function(t) { + var charId = t.get('represents'); + if (!charId) return false; + var character = getObj('character', charId); + if (!character) return false; + var cb = character.get('controlledby') || ''; + return !cb || cb === ''; + }); + } + if (filter.startsWith('has ')) { + var field = filter.slice(4).trim(); + return tokens.filter(function(t) { + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1; + }); + } + return tokens; + }; + + /** + * Evaluate a script for a specific target token and viewer. + * Sends the script content through the meta-script pipeline via sendChat. + */ + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, config, msg, dryRun) => { + // TODO: Set evaluation context for Fetch compProp resolution + // TODO: Inject Muler variables for viewer context + + // For now, basic string replacement of known patterns + var content = scriptContent; + content = content.replace(/@\(target\.token_id\)/g, targetToken.get('id')); + content = content.replace(/@\(target\.name\)/g, targetToken.get('name') || ''); + + // Split into lines, send each command + var lines = content.split('\n').filter(function(l) { + l = l.trim(); + return l && (l.startsWith('!') || l.startsWith('{&')); + }); + + if (dryRun) { + var out = 'Dry run (target: ' + (targetToken.get('name') || targetToken.get('id')) + ', viewer: ' + viewerPlayerId + '):
'; + lines.forEach(function(l) { out += '' + l + '
'; }); + reply(msg, 'Eval', out); + } else { + // Combine into single message for ZeroFrame to process + var fullCmd = lines.join('\n'); + if (fullCmd) sendChat('player|' + msg.playerid, fullCmd); + } + }; + + /** + * Evaluate all scripts on pins for a given page. + */ + const evaluatePins = (pins, msg, dryRun) => { + var s = state[SCRIPT_NAME]; + pins.forEach(function(pin) { + var config = parsePinConfig(pin); + if (!config) return; + var handoutId = pin.get('link'); + var pageId = pin.get('_pageid'); + + // Find the active group for this page + var activeEntry = Object.entries(s.activeGroups).find(function(e) { + return e[1].masterPageId === pageId || Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); + }); + if (!activeEntry) return; + + var groupInfo = activeEntry[1]; + var targets = getTargetTokens(pageId, config, s.activeGroups); + + getHandoutContent(handoutId, function(content) { + if (!content) return; + // Strip HTML tags from handout content + content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + // Evaluate for each viewer + target combination + Object.entries(groupInfo.playerPages).forEach(function(entry) { + var viewerPlayerId = entry[0]; + targets.forEach(function(target) { + evaluateScript(content, target, viewerPlayerId, config, msg, dryRun); + }); + }); + }); + }); + }; + + /** + * !gaslight eval [--dry] [--all | ] + * With pins selected: evaluate those pins. + * With --all: evaluate all active pins. + * With handout name: evaluate all pins linked to that handout. + */ + const doEval = (msg, args) => { + var dryRun = args.indexOf('--dry') !== -1; + args = args.filter(function(a) { return a !== '--dry'; }); + + var pins = []; + + if (args.indexOf('--all') !== -1) { + // All active gaslit pages + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + pins = pins.concat(findScriptPins(pid)); + }); + }); + } else if (args.length > 0) { + // By handout name + var handoutName = args.join(' '); + var handout = findObjs({ _type: 'handout', name: handoutName })[0]; + if (!handout) { reply(msg, 'Error', 'Handout "' + handoutName + '" not found.'); return; } + var allPins = findObjs({ _type: 'pin' }); + pins = allPins.filter(function(p) { return p.get('link') === handout.get('_id'); }); + } else if (msg.selected && msg.selected.length > 0) { + // Selected pins + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (obj && obj.get('_type') === 'pin') pins.push(obj); + }); + } + + if (pins.length === 0) { reply(msg, 'Error', 'No pins found. Select pins, provide a handout name, or use --all.'); return; } + + reply(msg, 'Eval', 'Evaluating ' + pins.length + ' pin(s)' + (dryRun ? ' (dry run)' : '') + '...'); + evaluatePins(pins, msg, dryRun); + }; + // ========================================================================= // Command Router // ========================================================================= @@ -1678,6 +1857,7 @@ var Gaslight = Gaslight || (() => { case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; + case 'eval': doEval(msg, args); break; case 'test-relay': { // Temporary: test sendChat with {& select} var testId = args[0] || ''; From 70a65f7e7f7de2966674fd58386f15fd4548e208 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 12:24:19 -0400 Subject: [PATCH 07/96] Gaslight: bump version to 2.0.0, add versioned folder --- Gaslight/2.0.0/Gaslight.js | 1997 ++++++++++++++++++++++++++++++++++++ Gaslight/Gaslight.js | 4 +- Gaslight/TODO.md | 2 +- Gaslight/script.json | 4 +- 4 files changed, 2002 insertions(+), 5 deletions(-) create mode 100644 Gaslight/2.0.0/Gaslight.js diff --git a/Gaslight/2.0.0/Gaslight.js b/Gaslight/2.0.0/Gaslight.js new file mode 100644 index 0000000000..fe448bc19d --- /dev/null +++ b/Gaslight/2.0.0/Gaslight.js @@ -0,0 +1,1997 @@ +// ============================================================================= +// Gaslight v2.0.0 +// Last Updated: 2026-06-14 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor. Each player can see different things +// while token movement stays consistent across all copies. +// +// Dependencies: Anchor +// +// Commands: +// !gaslight split Activate a prepared gaslight group +// !gaslight merge [group] Tear down links, return players +// !gaslight test Dry-run linking resolution +// !gaslight link [|new] [ids...] Set gaslight_link on tokens +// !gaslight unlink [ids...] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight master Designate page as group master +// !gaslight status Show current state +// !gaslight --help Command reference +// ============================================================================= + +/* global on, sendChat, getObj, findObjs, createObj, Campaign, playerIsGM, log, state, generateUUID */ + +var Gaslight = Gaslight || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Gaslight'; + const SCRIPT_VERSION = '2.0.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body); + }; + + const genId = () => { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + }; + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + activeGroups: {}, + config: { autoCommit: false, relayCommands: [] }, + view: null + }; + } + if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + }; + + // ========================================================================= + // Config Storage — GM layer text objects + // ========================================================================= + + const getConfigsOnPage = (pageId) => { + const texts = findObjs({ _type: 'text', _pageid: pageId, layer: 'gmlayer' }); + const configs = []; + texts.forEach(t => { + const content = t.get('text') || ''; + if (!content.startsWith(CONFIG_HEADER)) return; + const data = parseConfig(content); + if (data) configs.push({ obj: t, data: data }); + }); + return configs; + }; + + const getGroupConfigOnPage = (pageId, groupName) => { + return getConfigsOnPage(pageId).find(c => c.data.group === groupName); + }; + + const parseConfig = (text) => { + const lines = text.split('\n').filter(l => l.trim() && l.trim() !== CONFIG_HEADER); + const data = {}; + lines.forEach(line => { + const idx = line.indexOf(':'); + if (idx === -1) return; + data[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, ''); + }); + return data.group ? data : null; + }; + + const serializeConfig = (data) => { + let text = CONFIG_HEADER + '\n'; + Object.entries(data).forEach(([key, val]) => { + if (val !== undefined && val !== '') text += key + ': ' + val + '\n'; + }); + return text.trim(); + }; + + const setConfigOnPage = (pageId, groupName, data) => { + const existing = getGroupConfigOnPage(pageId, groupName); + const fullData = Object.assign({ group: groupName }, data); + const text = serializeConfig(fullData); + if (existing) { + existing.obj.set('text', text); + } else { + createObj('text', { + pageid: pageId, + layer: 'gmlayer', + text: text, + left: 70, + top: 70, + font_size: 26, + font_family: 'Arial', + color: '#FFA500' + }); + } + }; + + // ========================================================================= + // Group Discovery + // ========================================================================= + + const discoverGroup = (groupName) => { + const pages = findObjs({ _type: 'page' }); + const result = { master: null, players: {} }; // players keyed by playerid → { pageId, name } + pages.forEach(page => { + const cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg) return; + if (cfg.data.player === 'GM') result.master = page.get('_id'); + else if (cfg.data.playerid) { + result.players[cfg.data.playerid] = { pageId: page.get('_id'), name: cfg.data.player }; + } + }); + return result; + }; + + // ========================================================================= + // Page Resolution + // ========================================================================= + + const resolvePageId = (msg, args) => { + // Check for --page argument + const pageIdx = args.indexOf('--page'); + if (pageIdx !== -1 && args[pageIdx + 1]) { + const pageName = args.splice(pageIdx, 2)[1]; + const page = findObjs({ _type: 'page', name: pageName })[0]; + if (page) return page.get('_id'); + } + // Fall back to selected token's page + if (msg.selected && msg.selected.length > 0) { + const obj = getObj(msg.selected[0]._type, msg.selected[0]._id); + if (obj) return obj.get('_pageid'); + } + // Last resort: player page + return Campaign().get('playerpageid'); + }; + + // ========================================================================= + // Party Detection + // ========================================================================= + + const getPartyTokens = (msg, masterPageId) => { + if (msg.selected && msg.selected.length > 0) { + return msg.selected.map(s => getObj(s._type, s._id)).filter(Boolean); + } + const characters = findObjs({ _type: 'character' }); + const partyChars = characters.filter(c => { + const tags = c.get('tags') || ''; + return tags.toLowerCase().includes('party'); + }); + if (partyChars.length > 0) { + const tokens = []; + partyChars.forEach(c => { + const t = findObjs({ _type: 'graphic', represents: c.get('_id'), _pageid: masterPageId, _subtype: 'token' }); + tokens.push.apply(tokens, t); + }); + return tokens.length > 0 ? tokens : null; + } + return null; + }; + + // ========================================================================= + // Player Resolution + // ========================================================================= + + const GM_ALIASES = ['gm', 'master']; + + /** + * Resolve a player arg to { id, name } or null. + * If ambiguous, whispers disambiguation buttons and returns 'ambiguous'. + * If GM alias, returns { id: 'GM', name: 'GM' }. + */ + const resolvePlayer = (msg, playerArg, cmdPrefix) => { + if (GM_ALIASES.indexOf(playerArg.toLowerCase()) !== -1) { + return { id: 'GM', name: 'GM' }; + } + + // Check if it's a player ID directly (starts with -) + if (playerArg.startsWith('-')) { + var byId = getObj('player', playerArg); + if (byId) return { id: byId.get('_id'), name: byId.get('_displayname') }; + reply(msg, 'Error', 'No player found with ID: ' + playerArg); + return null; + } + + // Search by display name + var players = findObjs({ _type: 'player' }); + var matches = players.filter(function(p) { + return p.get('_displayname').toLowerCase() === playerArg.toLowerCase(); + }); + + // Deduplicate by player ID (Roll20 can return duplicate player objects) + var uniqueById = {}; + matches.forEach(function(p) { uniqueById[p.get('_id')] = p; }); + matches = Object.values(uniqueById); + + if (matches.length === 1) { + return { id: matches[0].get('_id'), name: matches[0].get('_displayname') }; + } + if (matches.length === 0) { + reply(msg, 'Error', 'No player found named "' + playerArg + '".'); + return null; + } + + // Ambiguous — show disambiguation buttons + var out = 'Multiple players named "' + playerArg + '":
'; + matches.forEach(function(p) { + var chars = findObjs({ _type: 'character' }).filter(function(c) { + return (c.get('controlledby') || '').indexOf(p.get('_id')) !== -1; + }); + var charNames = chars.map(function(c) { return c.get('name'); }).join(', ') || 'no characters'; + out += '[' + p.get('_displayname') + ' (' + charNames + ')](' + cmdPrefix + ' ' + p.get('_id') + ')
'; + }); + reply(msg, 'Disambiguate', out); + return 'ambiguous'; + }; + + /** + * Find a player by name or ID (no disambiguation, used internally). + */ + const findPlayerByNameOrId = (nameOrId) => { + if (nameOrId === 'GM') return null; + if (nameOrId.startsWith('-')) return getObj('player', nameOrId); + var players = findObjs({ _type: 'player' }); + return players.find(function(p) { return p.get('_displayname').toLowerCase() === nameOrId.toLowerCase(); }); + }; + + // ========================================================================= + // Token GM Notes — gaslight_link + // ========================================================================= + + const getLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } + const match = notes.match(/gaslight_link:\s*(.+)/); + return match ? match[1].trim() : null; + }; + + const setLinkId = (token, linkId) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.match(/gaslight_link:\s*.+/)) { + notes = notes.replace(/gaslight_link:\s*.+/, LINK_KEY + ': ' + linkId); + } else { + notes = (notes ? notes + '\n' : '') + LINK_KEY + ': ' + linkId; + } + token.set('gmnotes', notes); + }; + + const removeLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/\n?gaslight_link:\s*.+/, '').trim(); + token.set('gmnotes', notes); + }; + + /** + * Auto-populate gaslight_link from character attribute if token doesn't already have one. + */ + /** + * Find a matching token on another page by gaslight_link, represents+name, or represents alone. + */ + const findMatchingToken = (sourceToken, targetPageId) => { + // By gaslight_link + var linkId = getLinkId(sourceToken); + if (linkId) { + var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + var match = targets.find(function(t) { return getLinkId(t) === linkId; }); + if (match) return match; + } + // By represents + name + var charId = sourceToken.get('represents'); + if (charId) { + var name = sourceToken.get('name'); + var byName = findObjs({ _type: 'graphic', _pageid: targetPageId, represents: charId, _subtype: 'token' }); + if (name) byName = byName.filter(function(t) { return t.get('name') === name; }); + if (byName.length === 1) return byName[0]; + } + return null; + }; + + /** + * Stage a single token to target pages using 3-step logic. + * Returns number of clones created. + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPageIds.forEach(function(pageId) { + var targets = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPageIds; + } + + var cloned = 0; + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + var newToken = createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + if (newToken) setLinkId(newToken, getLinkId(token)); + cloned++; + }); + return cloned; + }; + + const autoPopulateLinkId = (token) => { + if (getLinkId(token)) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr && attr.get('current')) { + setLinkId(token, attr.get('current')); + } + }; + + /** + * Read the gaslight_sync character attribute. + * Returns: + * null — attribute absent (default: sync all non-spatial) + * '' — attribute present but empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (charId) => { + if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (!attr) return null; + var val = attr.get('current'); + if (val === undefined || val === null) return null; + val = val.trim(); + if (val === '') return ''; + // Parse comma-separated props, resolve groups + // Prefix with ! to exclude (e.g. "!anchor" = everything except anchor props) + var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var includes = []; + var excludes = []; + parts.forEach(function(p) { + var isExclude = p.startsWith('!'); + var name = isExclude ? p.slice(1) : p; + var expanded; + if (name === 'base' || name === 'anchor') { + expanded = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[name]) { + expanded = Mirror.PROP_GROUPS[name]; + } else { + expanded = [name]; + } + if (isExclude) excludes = excludes.concat(expanded); + else includes = includes.concat(expanded); + }); + // If only excludes specified, start from all known props and subtract + var resolved; + if (includes.length === 0 && excludes.length > 0) { + var allProps = typeof Mirror !== 'undefined' ? Mirror.getKnownProps() : + ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'statusmarkers', 'tint_color', 'name', 'light_radius', 'light_dimradius', 'baseOpacity', 'currentSide']; + resolved = allProps.filter(function(p) { return excludes.indexOf(p) === -1; }); + } else { + resolved = includes.filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return resolved.filter(function(p, i) { return resolved.indexOf(p) === i; }); // dedupe + }; + + // ========================================================================= + // Token Linking Resolution + // ========================================================================= + + /** + * Resolve links from sourcePageId to targetPageId. + * Returns array of { source, target, step } objects. + * Unmatched sources returned as { source, target: null, step: 'unlinked' }. + */ + const resolveLinks = (sourcePageId, targetPageId) => { + const sourceTokens = findObjs({ _type: 'graphic', _pageid: sourcePageId, _subtype: 'token' }); + const targetTokens = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + const results = []; + const matchedTargets = new Set(); + + // Step 1: gaslight_link in GM notes + sourceTokens.forEach(src => { + const linkId = getLinkId(src); + if (!linkId) return; + const match = targetTokens.find(t => !matchedTargets.has(t.get('id')) && getLinkId(t) === linkId); + if (match) { + results.push({ source: src, target: match, step: 1 }); + matchedTargets.add(match.get('id')); + } + }); + + const unmatchedSources = sourceTokens.filter(s => + !results.some(r => r.source.get('id') === s.get('id')) + ); + + // Step 2: represents + name + const step2Sources = unmatchedSources.filter(s => s.get('represents')); + step2Sources.forEach(src => { + const charId = src.get('represents'); + const name = src.get('name'); + // Check uniqueness on source page + const samePairOnSource = sourceTokens.filter(t => + t.get('represents') === charId && t.get('name') === name && + !results.some(r => r.source.get('id') === t.get('id')) + ); + if (samePairOnSource.length !== 1) return; // ambiguous on source page + + const candidates = targetTokens.filter(t => + !matchedTargets.has(t.get('id')) && + t.get('represents') === charId && t.get('name') === name + ); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 2 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 3: represents + fingerprint + const unmatchedAfter2 = unmatchedSources.filter(s => + s.get('represents') && !results.some(r => r.source.get('id') === s.get('id')) + ); + const FINGERPRINT_PROPS = ['represents', 'left', 'top', 'width', 'height', 'rotation', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max']; + + unmatchedAfter2.forEach(src => { + const srcFP = FINGERPRINT_PROPS.map(p => String(src.get(p))); + const candidates = targetTokens.filter(t => { + if (matchedTargets.has(t.get('id'))) return false; + const tFP = FINGERPRINT_PROPS.map(p => String(t.get(p))); + return srcFP.every((v, i) => v === tFP[i]); + }); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 3 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 4: unlinked — only master-page represents tokens + unmatchedSources.forEach(src => { + if (!results.some(r => r.source.get('id') === src.get('id'))) { + if (src.get('represents')) { + results.push({ source: src, target: null, step: 4 }); + } + } + }); + + return results; + }; + + /** + * Check for warning conditions across all pages in a group. + * Returns array of { message, severity } where severity is 'info'|'warning'|'error'. + */ + const checkWarnings = (groupInfo) => { + const warnings = []; + const allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + + // Collect all gaslight_link IDs and their page locations + const linkIdPages = {}; // linkId → Set of pageIds + const linkIdDupes = {}; // pageId → Set of linkIds that appear more than once + allPageIds.forEach(function(pid) { + var tokens = findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }); + var seenOnPage = {}; + tokens.forEach(function(t) { + var lid = getLinkId(t); + if (!lid) return; + if (!linkIdPages[lid]) linkIdPages[lid] = new Set(); + linkIdPages[lid].add(pid); + // Check for duplicates on same page + if (seenOnPage[lid]) { + if (!linkIdDupes[pid]) linkIdDupes[pid] = new Set(); + linkIdDupes[pid].add(lid); + } + seenOnPage[lid] = true; + }); + }); + + // Error: duplicate gaslight_link on same page + Object.entries(linkIdDupes).forEach(function(entry) { + var pid = entry[0], dupes = entry[1]; + var page = getObj('page', pid); + var pageName = page ? page.get('name') : pid; + dupes.forEach(function(lid) { + warnings.push({ message: 'Duplicate gaslight_link "' + lid + '" on page "' + pageName + '"', severity: 'error' }); + }); + }); + + // Info/Warning: gaslight_link missing from pages + Object.entries(linkIdPages).forEach(function(entry) { + var lid = entry[0], pages = entry[1]; + if (pages.size === 1) { + warnings.push({ message: 'gaslight_link "' + lid + '" exists on only 1 page (likely mistake)', severity: 'warning' }); + } else if (pages.size < allPageIds.length) { + warnings.push({ message: 'gaslight_link "' + lid + '" missing from some pages', severity: 'info' }); + } + }); + + return warnings; + }; + + const formatWarnings = (warnings) => { + if (warnings.length === 0) return ''; + var out = '
Warnings:
'; + warnings.forEach(function(w) { + var icon = w.severity === 'error' ? '🔴' : w.severity === 'warning' ? '🟡' : 'ℹ️'; + out += icon + ' ' + w.message + '
'; + }); + return out; + }; + + // ========================================================================= + // Anchor Integration + // ========================================================================= + + const countControllersInGroup = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return 0; + const character = getObj('character', charId); + if (!character) return 0; + const controlledBy = character.get('controlledby') || ''; + if (controlledBy === 'all') return Object.keys(groupInfo.players).length; + const controllerIds = controlledBy.split(',').filter(Boolean); + const groupPlayerIds = new Set(Object.keys(groupInfo.players)); + return controllerIds.filter(id => groupPlayerIds.has(id)).length; + }; + + const getControllingPlayerName = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return null; + const character = getObj('character', charId); + if (!character) return null; + const controlledBy = character.get('controlledby') || ''; + if (!controlledBy) return null; + if (controlledBy === 'all') { + // All players control it — return first group player as representative + var firstPlayer = Object.keys(groupInfo.players)[0]; + return firstPlayer || null; + } + const controllerIds = controlledBy.split(',').filter(Boolean); + for (var i = 0; i < controllerIds.length; i++) { + if (groupInfo.players[controllerIds[i]]) return controllerIds[i]; + } + return null; + }; + + const stripSight = (token) => { + token.set({ has_bright_light_vision: false, has_night_vision: false, light_hassight: false }); + }; + + /** + * Set up Anchor links based on resolved token pairs. + * Also writes gaslight_link IDs to token GM notes for any pair matched + * via steps 2-3, so re-split/restart will catch them via step 1. + */ + const establishLinks = (groupName, groupInfo, allLinks) => { + const s = state[SCRIPT_NAME]; + if (!s.activeGroups[groupName]) { + s.activeGroups[groupName] = { + masterPageId: groupInfo.master, + playerPages: groupInfo.players, + linkedTokens: {} + }; + } + const active = s.activeGroups[groupName]; + + if (typeof Anchor === 'undefined') { + log(SCRIPT_NAME + ': ERROR \u2014 Anchor not loaded. Cannot establish links.'); + return; + } + + // Group all link results by gaslight_link ID + var linkGroups = {}; // linkId -> { id: tokenObj } + allLinks.forEach(function(link) { + if (!link.target) return; + var src = link.source; + var tgt = link.target; + + // Ensure both have a gaslight_link ID + var existingId = getLinkId(src) || getLinkId(tgt); + var linkId = existingId || genId(); + if (!getLinkId(src)) setLinkId(src, linkId); + if (!getLinkId(tgt)) setLinkId(tgt, linkId); + + if (!linkGroups[linkId]) linkGroups[linkId] = {}; + linkGroups[linkId][src.get('id')] = src; + linkGroups[linkId][tgt.get('id')] = tgt; + }); + + // For each link group, determine anchoring strategy + Object.values(linkGroups).forEach(function(tokenMap) { + var tokens = Object.values(tokenMap); + if (tokens.length < 2) return; + + // Find all controlling player IDs in the group for this token + var controllerIds = []; + // Check the character's controlledby — use first token's character as representative + var repCharId = null; + for (var i = 0; i < tokens.length; i++) { + if (tokens[i].get('represents')) { repCharId = tokens[i].get('represents'); break; } + } + if (repCharId) { + var repChar = getObj('character', repCharId); + if (repChar) { + var cb = repChar.get('controlledby') || ''; + if (cb === 'all') { + controllerIds = Object.keys(groupInfo.players); + } else { + var cbIds = cb.split(',').filter(Boolean); + controllerIds = cbIds.filter(function(id) { return !!groupInfo.players[id]; }); + } + } + } + + var ids = tokens.map(function(t) { return t.get('id'); }); + + // Check gaslight_sync attribute + var syncProps = getGaslightSync(repCharId); + // syncProps: null = default (base spatial), '' = no sync at all, array = specific + + // If empty string, skip all linking for this group + if (syncProps === '') return; + + // Determine which props go to Anchor vs Mirror + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var needsAnchor = true; + var anchorComponents = null; // null = use Anchor defaults + var mirrorProps = null; // null = all non-anchor + if (Array.isArray(syncProps)) { + var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0; + // Pass specific components to Anchor if not the full default set + if (needsAnchor) { + anchorComponents = {}; + anchorRequested.forEach(function(p) { anchorComponents[p] = true; }); + } + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; + } + + // Set up Anchor links (spatial sync) + if (needsAnchor) { + if (controllerIds.length === 0) { + // NPC: master is parent, all others are children + var parent = tokens.find(function(t) { return t.get('_pageid') === groupInfo.master; }); + if (!parent) parent = tokens[0]; + tokens.forEach(function(t) { + if (t.get('id') === parent.get('id')) return; + Anchor.anchorObj(t.get('id'), parent.get('id'), anchorComponents); + }); + } else { + // Player-controlled: chain-link master + controlling players' pages + var chainPageIds = [groupInfo.master]; + controllerIds.forEach(function(pid) { + if (groupInfo.players[pid]) chainPageIds.push(groupInfo.players[pid].pageId); + }); + + var chainTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) !== -1; }); + var childTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) === -1; }); + + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds, anchorComponents); + } + + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id'), anchorComponents); + }); + } + } + } + + // Strip sight: only controlling players' pages keep sight + tokens.forEach(function(t) { + var pageId = t.get('_pageid'); + if (controllerIds.length > 0) { + // Keep sight only on pages belonging to controlling players + var isControllerPage = controllerIds.some(function(pid) { + return groupInfo.players[pid] && groupInfo.players[pid].pageId === pageId; + }); + if (!isControllerPage) stripSight(t); + } else { + // NPC: strip sight from children (not master) + if (pageId !== groupInfo.master) stripSight(t); + } + }); + + // Set up Mirror chain for non-spatial property sync + if (typeof Mirror !== 'undefined' && mirrorProps !== false) { + if (mirrorProps === null) { + // Default: sync all minus whatever Anchor is handling + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + Mirror.chainLink(ids, null, mirrorExcludes); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props + Mirror.chainLink(ids, mirrorProps); + } + } + + // Track links for merge teardown + ids.forEach(function(id) { + if (!active.linkedTokens[id]) active.linkedTokens[id] = []; + }); + ids.forEach(function(id) { + ids.forEach(function(otherId) { + if (id !== otherId) active.linkedTokens[id].push(otherId); + }); + }); + }); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + /** + * Quick setup: auto-configure a group from duplicate pages. + * !gaslight setup [--selected | player1 player2 ...] + * Expects N+1 pages with the same name (or name prefix). Assigns master + players. + */ + const doSetup = (msg, args) => { + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + var groupName = args.shift(); + + // Determine players: selected tokens + named args, fallback to party tags + var playerIds = []; + + // From selected tokens + if (msg.selected && msg.selected.length > 0) { + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (!obj) return; + var charId = obj.get('represents'); + if (!charId) return; + var character = getObj('character', charId); + if (!character) return; + var cb = character.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + // From named args + args.forEach(function(name) { + var resolved = resolvePlayer(msg, name, CMD + ' setup ' + groupName); + if (resolved && resolved !== 'ambiguous' && resolved.id !== 'GM') { + if (playerIds.indexOf(resolved.id) === -1) playerIds.push(resolved.id); + } + }); + + // Fallback: party-tagged characters (only if no selected and no args) + if (playerIds.length === 0) { + var characters = findObjs({ _type: 'character' }); + characters.forEach(function(c) { + var tags = c.get('tags') || ''; + if (!tags.toLowerCase().includes('party')) return; + var cb = c.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + if (playerIds.length === 0) { reply(msg, 'Error', 'No players found. Use --selected, provide names, or tag party characters.'); return; } + + // Find the master page (where selected token is, or current player page) + var masterPageId = resolvePageId(msg, []); + var masterPage = getObj('page', masterPageId); + if (!masterPage) { reply(msg, 'Error', 'Could not determine master page. Select a token on the master page.'); return; } + var masterName = masterPage.get('name'); + + // Find candidate pages: same base name (strip recursive "Copy of " prefixes), or already has this group's config + var allPages = findObjs({ _type: 'page' }); + var stripCopyOf = function(name) { + while (name.indexOf('Copy of ') === 0) name = name.slice(8); + return name; + }; + var candidates = allPages.filter(function(p) { + var name = stripCopyOf(p.get('name')); + if (name === masterName) return true; + // Check if page already has config for this group + var cfg = getGroupConfigOnPage(p.get('_id'), groupName); + if (cfg) return true; + return false; + }); + + // We need N+1 pages (1 master + N players) + var needed = playerIds.length + 1; + if (candidates.length < needed) { + reply(msg, 'Error', 'Found ' + candidates.length + ' page(s) named "' + masterName + '..." but need ' + needed + ' (1 master + ' + playerIds.length + ' players). Duplicate the page ' + (needed - candidates.length) + ' more time(s).'); + return; + } + + // Assign: first candidate = master, rest = players (arbitrary order) + var masterCandidate = candidates.find(function(p) { return p.get('_id') === masterPageId; }) || candidates[0]; + var playerCandidates = candidates.filter(function(p) { return p.get('_id') !== masterCandidate.get('_id'); }).slice(0, playerIds.length); + + // Rename and configure + masterCandidate.set('name', masterName + ' (master)'); + setConfigOnPage(masterCandidate.get('_id'), groupName, { player: 'GM' }); + + var assignments = []; + playerIds.forEach(function(pid, i) { + var page = playerCandidates[i]; + var player = getObj('player', pid); + var playerName = player ? player.get('_displayname') : pid; + page.set('name', masterName + ' (' + playerName + ')'); + setConfigOnPage(page.get('_id'), groupName, { player: playerName, playerid: pid }); + assignments.push(playerName + ' → ' + page.get('name')); + }); + + var out = 'Group "' + groupName + '" set up:
'; + out += 'Master: ' + masterCandidate.get('name') + '
'; + out += assignments.join('
'); + out += '

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

' + + '' + CMD + ' split <group> -- Activate group
' + + '' + CMD + ' merge [group] -- Tear down links
' + + '' + CMD + ' test <group> -- Dry-run linking
' + + '' + CMD + ' link [name|new] [ids...] -- Link tokens
' + + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + + '' + CMD + ' group <group> <player|GM> -- Assign page
' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' status -- Show state
' + + '' + CMD + ' --help -- This help
'; + + // ========================================================================= + // Scripting Engine + // ========================================================================= + + /** + * Read a handout's notes content (async → callback pattern). + * Returns content via callback since Roll20 requires it for notes/gmnotes. + */ + const getHandoutContent = (handoutId, callback) => { + var handout = getObj('handout', handoutId); + if (!handout) { callback(null); return; } + handout.get('notes', function(notes) { + callback(notes || ''); + }); + }; + + /** + * Find pins on a page that are gaslight script pins (linked to a handout). + */ + const findScriptPins = (pageId) => { + var pins = findObjs({ _type: 'pin', _pageid: pageId }); + return pins.filter(function(pin) { + return pin.get('link') && pin.get('linkType') === 'handout'; + }); + }; + + /** + * Parse pin gmNotes for script configuration. + */ + const parsePinConfig = (pin) => { + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + var config = { scope: 'token', filter: 'all', triggers: [] }; + if (!notes.includes('---GASLIGHT-SCRIPT---')) return null; + notes.split('\n').forEach(function(line) { + line = line.trim(); + if (line.startsWith('scope:')) config.scope = line.slice(6).trim(); + else if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); + else if (line.startsWith('trigger:')) config.triggers.push(line.slice(8).trim()); + }); + return config; + }; + + /** + * Get target tokens for evaluation based on pin config filter. + */ + const getTargetTokens = (pageId, config, activeGroups) => { + var tokens = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var filter = config.filter.toLowerCase(); + if (filter === 'all') return tokens; + if (filter === 'npc') { + return tokens.filter(function(t) { + var charId = t.get('represents'); + if (!charId) return false; + var character = getObj('character', charId); + if (!character) return false; + var cb = character.get('controlledby') || ''; + return !cb || cb === ''; + }); + } + if (filter.startsWith('has ')) { + var field = filter.slice(4).trim(); + return tokens.filter(function(t) { + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1; + }); + } + return tokens; + }; + + /** + * Evaluate a script for a specific target token and viewer. + * Sends the script content through the meta-script pipeline via sendChat. + */ + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, config, msg, dryRun) => { + // TODO: Set evaluation context for Fetch compProp resolution + // TODO: Inject Muler variables for viewer context + + // For now, basic string replacement of known patterns + var content = scriptContent; + content = content.replace(/@\(target\.token_id\)/g, targetToken.get('id')); + content = content.replace(/@\(target\.name\)/g, targetToken.get('name') || ''); + + // Split into lines, send each command + var lines = content.split('\n').filter(function(l) { + l = l.trim(); + return l && (l.startsWith('!') || l.startsWith('{&')); + }); + + if (dryRun) { + var out = 'Dry run (target: ' + (targetToken.get('name') || targetToken.get('id')) + ', viewer: ' + viewerPlayerId + '):
'; + lines.forEach(function(l) { out += '' + l + '
'; }); + reply(msg, 'Eval', out); + } else { + // Combine into single message for ZeroFrame to process + var fullCmd = lines.join('\n'); + if (fullCmd) sendChat('player|' + msg.playerid, fullCmd); + } + }; + + /** + * Evaluate all scripts on pins for a given page. + */ + const evaluatePins = (pins, msg, dryRun) => { + var s = state[SCRIPT_NAME]; + pins.forEach(function(pin) { + var config = parsePinConfig(pin); + if (!config) return; + var handoutId = pin.get('link'); + var pageId = pin.get('_pageid'); + + // Find the active group for this page + var activeEntry = Object.entries(s.activeGroups).find(function(e) { + return e[1].masterPageId === pageId || Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); + }); + if (!activeEntry) return; + + var groupInfo = activeEntry[1]; + var targets = getTargetTokens(pageId, config, s.activeGroups); + + getHandoutContent(handoutId, function(content) { + if (!content) return; + // Strip HTML tags from handout content + content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + // Evaluate for each viewer + target combination + Object.entries(groupInfo.playerPages).forEach(function(entry) { + var viewerPlayerId = entry[0]; + targets.forEach(function(target) { + evaluateScript(content, target, viewerPlayerId, config, msg, dryRun); + }); + }); + }); + }); + }; + + /** + * !gaslight eval [--dry] [--all | ] + * With pins selected: evaluate those pins. + * With --all: evaluate all active pins. + * With handout name: evaluate all pins linked to that handout. + */ + const doEval = (msg, args) => { + var dryRun = args.indexOf('--dry') !== -1; + args = args.filter(function(a) { return a !== '--dry'; }); + + var pins = []; + + if (args.indexOf('--all') !== -1) { + // All active gaslit pages + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + pins = pins.concat(findScriptPins(pid)); + }); + }); + } else if (args.length > 0) { + // By handout name + var handoutName = args.join(' '); + var handout = findObjs({ _type: 'handout', name: handoutName })[0]; + if (!handout) { reply(msg, 'Error', 'Handout "' + handoutName + '" not found.'); return; } + var allPins = findObjs({ _type: 'pin' }); + pins = allPins.filter(function(p) { return p.get('link') === handout.get('_id'); }); + } else if (msg.selected && msg.selected.length > 0) { + // Selected pins + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (obj && obj.get('_type') === 'pin') pins.push(obj); + }); + } + + if (pins.length === 0) { reply(msg, 'Error', 'No pins found. Select pins, provide a handout name, or use --all.'); return; } + + reply(msg, 'Eval', 'Evaluating ' + pins.length + ' pin(s)' + (dryRun ? ' (dry run)' : '') + '...'); + evaluatePins(pins, msg, dryRun); + }; + + // ========================================================================= + // Command Router + // ========================================================================= + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (msg.content.split(' ')[0] !== CMD) return; + if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; + + const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const sub = (args.shift() || '').toLowerCase(); + + switch (sub) { + case 'setup': doSetup(msg, args); break; + case 'split': doSplit(msg, args); break; + case 'merge': doMerge(msg, args); break; + case 'test': doTest(msg, args); break; + case 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'relay': doRelay(msg, args); break; + case 'view': doView(msg, args); break; + case 'stage': doStage(msg, args); break; + case 'config': doConfig(msg, args); break; + case 'eval': doEval(msg, args); break; + case 'test-relay': { + // Temporary: test sendChat with {& select} + var testId = args[0] || ''; + if (!testId) { reply(msg, 'Error', 'Provide a token ID'); break; } + var testCmd = '!token-mod --set bar1_value|42 {& select ' + testId + '}'; + log(SCRIPT_NAME + ': test-relay sending: ' + testCmd); + sendChat(getPlayerName(msg.playerid), testCmd); + break; + } + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + /** + * When a linked token is deleted, delete its counterparts on other pages. + */ + var destroying = false; + const onTokenDestroyed = (obj) => { + if (destroying) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find if this token is tracked in any active group + var linkedIds = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + // Clean up tracking + delete active.linkedTokens[tokenId]; + linkedIds.forEach(function(id) { + if (active.linkedTokens[id]) { + active.linkedTokens[id] = active.linkedTokens[id].filter(function(lid) { return lid !== tokenId; }); + } + }); + } else { + // Check if it's in someone else's list + Object.entries(active.linkedTokens).forEach(function(entry) { + var idx = entry[1].indexOf(tokenId); + if (idx !== -1) { + entry[1].splice(idx, 1); + if (!linkedIds) linkedIds = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + + // Remove Anchor/Mirror links and delete counterparts + destroying = true; + linkedIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + if (typeof Mirror !== 'undefined') Mirror.unlink([id]); + var target = getObj('graphic', id); + if (target) target.remove(); + }); + destroying = false; + }; + + /** + * In any active view mode, intercept non-gaslight API commands and re-emit + * with linked player tokens as selection via SelectManager. + * Master view: relay to ALL player pages. + * Player view: relay to that player's page only. + */ + const viewInterceptor = (msg) => { + if (msg.type !== 'api') return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var firstWord = msg.content.split(' ')[0]; + if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; + if (!msg.selected || msg.selected.length === 0) return; + if (msg.content.indexOf('{& select') !== -1) return; + + var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) return; + + var pageId = tokens[0].get('_pageid'); + var isGM = playerIsGM(msg.playerid); + + // Case 1: GM on master page — relay based on view + if (isGM) { + var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); + if (!activeEntry) return; + + var viewPlayerId = s.view; + var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); + executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); + return; + } + + // Case 2: Player on their page — relay if command is in relay-commands list + if (s.config.relayCommands.indexOf(firstWord) === -1) return; + + // Find which group/player this page belongs to + var activeEntry = null; + var sourcePlayerId = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + if (pp[1].pageId === pageId) { activeEntry = e; sourcePlayerId = pp[0]; } + }); + }); + if (!activeEntry) return; + + // Relay to all OTHER player pages + master + var targetPlayerIds = Object.keys(activeEntry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, true); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('chat:message', viewInterceptor); + on('add:graphic', onTokenAdded); + on('destroy:graphic', onTokenDestroyed); + setInterval(pollRelayQueue, 500); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index a686d60711..fe448bc19d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,5 +1,5 @@ // ============================================================================= -// Gaslight v1.0.0 +// Gaslight v2.0.0 // Last Updated: 2026-06-14 // Author: Kenan Millet // @@ -28,7 +28,7 @@ var Gaslight = Gaslight || (() => { 'use strict'; const SCRIPT_NAME = 'Gaslight'; - const SCRIPT_VERSION = '1.0.0'; + const SCRIPT_VERSION = '2.0.0'; const CMD = '!gaslight'; const CONFIG_HEADER = '---GASLIGHT---'; const LINK_KEY = 'gaslight_link'; diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 2f90899c60..5596e4ec71 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -1,6 +1,6 @@ # Gaslight TODO -## Done (v1.0.0) +## Done (v1.0.0 — shipped in Gaslight branch PR) - [x] Pre-setup split with test-first behavior - [x] Merge (tear down Anchor + Mirror, unassign players) - [x] Anchor-mode sync (NPC + player tokens via chain-linking) diff --git a/Gaslight/script.json b/Gaslight/script.json index bf0a45dfef..2a512cebfb 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -1,8 +1,8 @@ { "name": "Gaslight", "script": "Gaslight.js", - "version": "1.0.0", - "previousversions": [], + "version": "2.0.0", + "previousversions": ["1.0.0"], "description": "Per-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", From 33ad7566d3209bf75e3fa1224d593262f29c69f5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 12:39:38 -0400 Subject: [PATCH 08/96] Gaslight scripting: fix eval to resolve target to linked copy on viewer's page --- Gaslight/Gaslight.js | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index fe448bc19d..e9bd3cffd6 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1724,20 +1724,42 @@ var Gaslight = Gaslight || (() => { return tokens; }; + /** + * Find the linked counterpart of a token on a specific page. + */ + const findLinkedTokenOnPage = (sourceToken, targetPageId) => { + var s = state[SCRIPT_NAME]; + var sourceId = sourceToken.get('id'); + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = active.linkedTokens[sourceId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(sourceId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i && id !== sourceId; }).forEach(function(id) { + linkedIds.push(id); + }); + }); + for (var i = 0; i < linkedIds.length; i++) { + var obj = getObj('graphic', linkedIds[i]); + if (obj && obj.get('_pageid') === targetPageId) return obj; + } + return null; + }; + /** * Evaluate a script for a specific target token and viewer. - * Sends the script content through the meta-script pipeline via sendChat. + * Resolves target to the linked copy on the viewer's page. */ - const evaluateScript = (scriptContent, targetToken, viewerPlayerId, config, msg, dryRun) => { - // TODO: Set evaluation context for Fetch compProp resolution - // TODO: Inject Muler variables for viewer context + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, viewerPageId, config, msg, dryRun) => { + // Find the linked token on the viewer's page + var viewerTarget = findLinkedTokenOnPage(targetToken, viewerPageId); + if (!viewerTarget) return; // no linked copy on this viewer's page - // For now, basic string replacement of known patterns var content = scriptContent; - content = content.replace(/@\(target\.token_id\)/g, targetToken.get('id')); - content = content.replace(/@\(target\.name\)/g, targetToken.get('name') || ''); + content = content.replace(/@\(target\.token_id\)/g, viewerTarget.get('id')); + content = content.replace(/@\(target\.name\)/g, viewerTarget.get('name') || ''); - // Split into lines, send each command var lines = content.split('\n').filter(function(l) { l = l.trim(); return l && (l.startsWith('!') || l.startsWith('{&')); @@ -1748,7 +1770,6 @@ var Gaslight = Gaslight || (() => { lines.forEach(function(l) { out += '' + l + '
'; }); reply(msg, 'Eval', out); } else { - // Combine into single message for ZeroFrame to process var fullCmd = lines.join('\n'); if (fullCmd) sendChat('player|' + msg.playerid, fullCmd); } @@ -1782,8 +1803,9 @@ var Gaslight = Gaslight || (() => { // Evaluate for each viewer + target combination Object.entries(groupInfo.playerPages).forEach(function(entry) { var viewerPlayerId = entry[0]; + var viewerPageId = entry[1].pageId; targets.forEach(function(target) { - evaluateScript(content, target, viewerPlayerId, config, msg, dryRun); + evaluateScript(content, target, viewerPlayerId, viewerPageId, config, msg, dryRun); }); }); }); From 80e1807f348a8f032901ed702e9e33108b56e9e8 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 12:43:12 -0400 Subject: [PATCH 09/96] Gaslight scripting: document pin placement semantics (master=all, player=that player only) --- Gaslight/SCRIPTING_DESIGN.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index c18fd84053..cf58726d6d 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -21,8 +21,16 @@ else: - **Handout** (notes or gmnotes) = the reusable script logic - **Pin** on a page = "this script is active here" - - `link` → handout ID - - `gmNotes` → pin-specific configuration (scope, filter, trigger rules) + - `link` → handout ID (or empty for self-contained pin scripts) + - `gmNotes` → pin-specific configuration (scope, filter, trigger rules). Inherits from linked handout's GM notes by default unless desynced. + - Pin `notes` can contain the script itself for self-contained one-off scripts (no handout needed) + +### Pin Placement + +- **Pin on master page** → script evaluates for ALL viewers (normal case) +- **Pin on a player page** → script evaluates for ONLY that player (per-player override/special effect) + - Use case: hallucinations, player-specific illusions, per-player narrative moments + - Consistent with Gaslight's master/player-page distinction ### Scope (configured per-pin) From 1a482498c6deb8f1f59c90fc9c3afea195c8a12b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 12:48:39 -0400 Subject: [PATCH 10/96] Gaslight scripting: add gm.* namespace for master page opt-in evaluation --- Gaslight/SCRIPTING_DESIGN.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index cf58726d6d..346fe93533 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -51,7 +51,7 @@ Filter which tokens/characters the script evaluates against: ### Variables -Two namespaces resolved by Gaslight: +Two primary namespaces resolved by Gaslight: - `target.*` — the token/character being evaluated - Resolved from gmnotes (scope: token) or character attribute (scope: character) @@ -65,6 +65,10 @@ Two namespaces resolved by Gaslight: - `all(...)` requires every viewer token to pass - Player-level properties (viewer.id, viewer.name, viewer.page) are singular, not iterated - Party-tagged tokens may be used as a narrowing hint but do NOT guarantee a single token +- `gm.*` — targets the master page (opt-in) + - If the script references `gm.*`, the script also evaluates on master page + - If only `viewer.*` is referenced, master page is untouched + - Use case: GM-side indicators (e.g. transparent overlay to show stealth status) ### Integration with Meta-Toolbox From 9a8f82639bbe413f9b7e4799da884f49ab6ca7b9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:01:49 -0400 Subject: [PATCH 11/96] Gaslight scripting: dry run shows viewer-page token name+id and player name+id --- Gaslight/Gaslight.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index e9bd3cffd6..a02b8cec04 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1766,7 +1766,11 @@ var Gaslight = Gaslight || (() => { }); if (dryRun) { - var out = 'Dry run (target: ' + (targetToken.get('name') || targetToken.get('id')) + ', viewer: ' + viewerPlayerId + '):
'; + var targetName = viewerTarget.get('name'); + var targetDisplay = targetName ? targetName + ' (' + viewerTarget.get('id') + ')' : viewerTarget.get('id'); + var viewerPlayer = getObj('player', viewerPlayerId); + var viewerDisplay = viewerPlayer ? viewerPlayer.get('_displayname') + ' (' + viewerPlayerId + ')' : viewerPlayerId; + var out = 'Dry run (target: ' + targetDisplay + ', viewer: ' + viewerDisplay + '):
'; lines.forEach(function(l) { out += '' + l + '
'; }); reply(msg, 'Eval', out); } else { From 357c7528338ff83e146066b8174ded0caa4d513a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:05:26 -0400 Subject: [PATCH 12/96] =?UTF-8?q?Gaslight=20scripting:=20improve=20dry=20r?= =?UTF-8?q?un=20formatting=20=E2=80=94=20line=20breaks,=20bold=20labels,?= =?UTF-8?q?=20small=20code=20for=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index a02b8cec04..413488b299 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1767,10 +1767,12 @@ var Gaslight = Gaslight || (() => { if (dryRun) { var targetName = viewerTarget.get('name'); - var targetDisplay = targetName ? targetName + ' (' + viewerTarget.get('id') + ')' : viewerTarget.get('id'); + var targetDisplay = targetName ? targetName + ' ' + viewerTarget.get('id') + '' : '' + viewerTarget.get('id') + ''; var viewerPlayer = getObj('player', viewerPlayerId); - var viewerDisplay = viewerPlayer ? viewerPlayer.get('_displayname') + ' (' + viewerPlayerId + ')' : viewerPlayerId; - var out = 'Dry run (target: ' + targetDisplay + ', viewer: ' + viewerDisplay + '):
'; + var viewerDisplay = viewerPlayer ? viewerPlayer.get('_displayname') + ' ' + viewerPlayerId + '' : '' + viewerPlayerId + ''; + var out = 'Dry run
'; + out += 'Target: ' + targetDisplay + '
'; + out += 'Viewer: ' + viewerDisplay + '
'; lines.forEach(function(l) { out += '' + l + '
'; }); reply(msg, 'Eval', out); } else { From c4a939129bf3a6f1f80b357075d036402e043969 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:06:49 -0400 Subject: [PATCH 13/96] Gaslight scripting: support self-contained pins, handout gmNotes config inheritance, player-page pin scoping --- Gaslight/Gaslight.js | 114 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 92 insertions(+), 22 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 413488b299..deb955fdbc 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1670,24 +1670,61 @@ var Gaslight = Gaslight || (() => { }; /** - * Find pins on a page that are gaslight script pins (linked to a handout). + * Find pins on a page that are gaslight script pins. + * A pin is a script pin if: + * - It links to a handout (script in handout notes, config in handout gmNotes or pin gmNotes) + * - OR it has ---GASLIGHT-SCRIPT--- in its own gmNotes (self-contained) */ const findScriptPins = (pageId) => { var pins = findObjs({ _type: 'pin', _pageid: pageId }); return pins.filter(function(pin) { - return pin.get('link') && pin.get('linkType') === 'handout'; + if (pin.get('link') && pin.get('linkType') === 'handout') return true; + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf('---GASLIGHT-SCRIPT---') !== -1; }); }; /** - * Parse pin gmNotes for script configuration. + * Parse pin configuration. Checks pin gmNotes first, falls back to linked handout gmNotes. */ - const parsePinConfig = (pin) => { + const parsePinConfig = (pin, callback) => { var notes = pin.get('gmNotes') || ''; try { notes = decodeURIComponent(notes); } catch(e) {} + + // If pin has its own config, use it + if (notes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(notes)); + return; + } + + // Fall back to linked handout's gmNotes + var handoutId = pin.get('link'); + if (handoutId) { + var handout = getObj('handout', handoutId); + if (handout) { + handout.get('gmnotes', function(gmnotes) { + gmnotes = gmnotes || ''; + try { gmnotes = decodeURIComponent(gmnotes); } catch(e) {} + if (gmnotes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(gmnotes)); + } else { + // No config found, use defaults + callback({ scope: 'token', filter: 'all', triggers: [] }); + } + }); + return; + } + } + callback(null); + }; + + /** + * Parse config text into structured object. + */ + const parseConfigText = (text) => { var config = { scope: 'token', filter: 'all', triggers: [] }; - if (!notes.includes('---GASLIGHT-SCRIPT---')) return null; - notes.split('\n').forEach(function(line) { + text.split('\n').forEach(function(line) { line = line.trim(); if (line.startsWith('scope:')) config.scope = line.slice(6).trim(); else if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); @@ -1696,6 +1733,22 @@ var Gaslight = Gaslight || (() => { return config; }; + /** + * Get the script content for a pin. + * Linked pin: from handout notes. Self-contained: from pin notes. + */ + const getPinScript = (pin, callback) => { + var handoutId = pin.get('link'); + if (handoutId) { + getHandoutContent(handoutId, callback); + } else { + // Self-contained: script in pin notes + var notes = pin.get('notes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + callback(notes); + } + }; + /** * Get target tokens for evaluation based on pin config filter. */ @@ -1787,9 +1840,6 @@ var Gaslight = Gaslight || (() => { const evaluatePins = (pins, msg, dryRun) => { var s = state[SCRIPT_NAME]; pins.forEach(function(pin) { - var config = parsePinConfig(pin); - if (!config) return; - var handoutId = pin.get('link'); var pageId = pin.get('_pageid'); // Find the active group for this page @@ -1799,19 +1849,39 @@ var Gaslight = Gaslight || (() => { if (!activeEntry) return; var groupInfo = activeEntry[1]; - var targets = getTargetTokens(pageId, config, s.activeGroups); - - getHandoutContent(handoutId, function(content) { - if (!content) return; - // Strip HTML tags from handout content - content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); - - // Evaluate for each viewer + target combination - Object.entries(groupInfo.playerPages).forEach(function(entry) { - var viewerPlayerId = entry[0]; - var viewerPageId = entry[1].pageId; - targets.forEach(function(target) { - evaluateScript(content, target, viewerPlayerId, viewerPageId, config, msg, dryRun); + + // Determine which viewers to evaluate for based on pin placement + var viewers; + if (pageId === groupInfo.masterPageId) { + // Pin on master: evaluate for all players + viewers = Object.entries(groupInfo.playerPages); + } else { + // Pin on player page: evaluate only for that player + var playerEntry = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === pageId; }); + viewers = playerEntry ? [playerEntry] : []; + } + if (viewers.length === 0) return; + + // Get targets from master page (source of truth for token list) + var targets = getTargetTokens(groupInfo.masterPageId, { filter: 'all' }, s.activeGroups); + + parsePinConfig(pin, function(config) { + if (!config) return; + // Re-filter targets based on config + targets = getTargetTokens(groupInfo.masterPageId, config, s.activeGroups); + + getPinScript(pin, function(content) { + if (!content) return; + // Strip HTML tags from content + content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + // Evaluate for each viewer + target combination + viewers.forEach(function(entry) { + var viewerPlayerId = entry[0]; + var viewerPageId = entry[1].pageId; + targets.forEach(function(target) { + evaluateScript(content, target, viewerPlayerId, viewerPageId, config, msg, dryRun); + }); }); }); }); From d637557345f6d68bac43e78c90f04da1183fa30f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:15:23 -0400 Subject: [PATCH 14/96] Gaslight scripting: pin title falls back to linked handout name, then pin ID --- Gaslight/Gaslight.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index deb955fdbc..03cc770707 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1875,6 +1875,12 @@ var Gaslight = Gaslight || (() => { // Strip HTML tags from content content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + if (dryRun) { + var handout = pin.get('link') ? getObj('handout', pin.get('link')) : null; + var pinTitle = pin.get('title') || (handout && handout.get('name')) || pin.get('_id'); + reply(msg, 'Eval', '
Pin: ' + pinTitle); + } + // Evaluate for each viewer + target combination viewers.forEach(function(entry) { var viewerPlayerId = entry[0]; From 69c8e8c2bdd7a0513cf062055545205c3645b668 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:19:19 -0400 Subject: [PATCH 15/96] Gaslight scripting: add [GLS] tag convention, strip from display in chat --- Gaslight/Gaslight.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 03cc770707..c32affff2e 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -32,6 +32,15 @@ var Gaslight = Gaslight || (() => { const CMD = '!gaslight'; const CONFIG_HEADER = '---GASLIGHT---'; const LINK_KEY = 'gaslight_link'; + const GLS_TAG = '[GLS]'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const stripGlsTag = (name) => { + return (name || '').replace(/^\[GLS\]\s*/i, '').trim(); + }; // ========================================================================= // Helpers @@ -1877,7 +1886,7 @@ var Gaslight = Gaslight || (() => { if (dryRun) { var handout = pin.get('link') ? getObj('handout', pin.get('link')) : null; - var pinTitle = pin.get('title') || (handout && handout.get('name')) || pin.get('_id'); + var pinTitle = stripGlsTag(pin.get('title') || (handout && handout.get('name')) || pin.get('_id')); reply(msg, 'Eval', '
Pin: ' + pinTitle); } From 6cdec57cd0ea1ede1bef768224d229c9a677a375 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:33:22 -0400 Subject: [PATCH 16/96] Gaslight scripting: dry run uses --echo through pipeline, clean up comments --- Gaslight/Gaslight.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index c32affff2e..e62b9acb72 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1832,11 +1832,11 @@ var Gaslight = Gaslight || (() => { var targetDisplay = targetName ? targetName + ' ' + viewerTarget.get('id') + '' : '' + viewerTarget.get('id') + ''; var viewerPlayer = getObj('player', viewerPlayerId); var viewerDisplay = viewerPlayer ? viewerPlayer.get('_displayname') + ' ' + viewerPlayerId + '' : '' + viewerPlayerId + ''; - var out = 'Dry run
'; - out += 'Target: ' + targetDisplay + '
'; - out += 'Viewer: ' + viewerDisplay + '
'; - lines.forEach(function(l) { out += '' + l + '
'; }); - reply(msg, 'Eval', out); + reply(msg, 'Eval', 'Dry run
Target: ' + targetDisplay + '
Viewer: ' + viewerDisplay); + // Send each line through pipeline wrapped in --echo + lines.forEach(function(l) { + sendChat('player|' + msg.playerid, CMD + ' --echo ' + l); + }); } else { var fullCmd = lines.join('\n'); if (fullCmd) sendChat('player|' + msg.playerid, fullCmd); @@ -1981,6 +1981,12 @@ var Gaslight = Gaslight || (() => { break; } case 'status': doStatus(msg); break; + case '--echo': { + // Echo resolved command back to sender for dry-run display + var echoContent = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); + reply(msg, 'Eval', 'Dry run
' + echoContent + ''); + break; + } case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; } From 148ab9878a3e8e6459e544b86c45b3110b31d8b9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:44:14 -0400 Subject: [PATCH 17/96] Gaslight scripting: use destructuring in --echo handler --- Gaslight/Gaslight.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index e62b9acb72..8245ae6c10 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1828,14 +1828,8 @@ var Gaslight = Gaslight || (() => { }); if (dryRun) { - var targetName = viewerTarget.get('name'); - var targetDisplay = targetName ? targetName + ' ' + viewerTarget.get('id') + '' : '' + viewerTarget.get('id') + ''; - var viewerPlayer = getObj('player', viewerPlayerId); - var viewerDisplay = viewerPlayer ? viewerPlayer.get('_displayname') + ' ' + viewerPlayerId + '' : '' + viewerPlayerId + ''; - reply(msg, 'Eval', 'Dry run
Target: ' + targetDisplay + '
Viewer: ' + viewerDisplay); - // Send each line through pipeline wrapped in --echo lines.forEach(function(l) { - sendChat('player|' + msg.playerid, CMD + ' --echo ' + l); + sendChat('player|' + msg.playerid, CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' + l); }); } else { var fullCmd = lines.join('\n'); @@ -1982,9 +1976,15 @@ var Gaslight = Gaslight || (() => { } case 'status': doStatus(msg); break; case '--echo': { - // Echo resolved command back to sender for dry-run display - var echoContent = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); - reply(msg, 'Eval', 'Dry run
' + echoContent + ''); + // Internal: dry-run echo. Format: !gaslight --echo + var echoRaw = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); + var [echoViewerId, echoTargetId] = echoRaw.split(' '); + var echoCmd = echoRaw.slice(echoViewerId.length + 1 + echoTargetId.length + 1); + var echoViewer = getObj('player', echoViewerId); + var echoTarget = getObj('graphic', echoTargetId); + var viewerName = echoViewer ? echoViewer.get('_displayname') : echoViewerId; + var targetName = echoTarget ? (echoTarget.get('name') || echoTargetId) : echoTargetId; + reply(msg, 'Eval', 'Dry run
Target: ' + targetName + ' ' + echoTargetId + '
Viewer: ' + viewerName + ' ' + echoViewerId + '
' + echoCmd + ''); break; } case '--help': reply(msg, HELP_TEXT); break; From a4bffc0b4c8085d84e728977727ec5e9ea35aae9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:50:11 -0400 Subject: [PATCH 18/96] Gaslight scripting: rename --dry to --dry-run for suite parity --- Gaslight/Gaslight.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 8245ae6c10..f7878a0b58 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1904,8 +1904,8 @@ var Gaslight = Gaslight || (() => { * With handout name: evaluate all pins linked to that handout. */ const doEval = (msg, args) => { - var dryRun = args.indexOf('--dry') !== -1; - args = args.filter(function(a) { return a !== '--dry'; }); + var dryRun = args.indexOf('--dry-run') !== -1; + args = args.filter(function(a) { return a !== '--dry-run'; }); var pins = []; From 7c9c54a388e157445f22b3922434b0c9e14fb122 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:55:47 -0400 Subject: [PATCH 19/96] Gaslight scripting: remove hr from pin header, fix multi-pin ordering --- Gaslight/Gaslight.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index f7878a0b58..413a0f7a9a 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1881,7 +1881,7 @@ var Gaslight = Gaslight || (() => { if (dryRun) { var handout = pin.get('link') ? getObj('handout', pin.get('link')) : null; var pinTitle = stripGlsTag(pin.get('title') || (handout && handout.get('name')) || pin.get('_id')); - reply(msg, 'Eval', '
Pin: ' + pinTitle); + sendChat('player|' + msg.playerid, CMD + ' --echo-header ' + pinTitle); } // Evaluate for each viewer + target combination @@ -1987,6 +1987,12 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Eval', 'Dry run
Target: ' + targetName + ' ' + echoTargetId + '
Viewer: ' + viewerName + ' ' + echoViewerId + '
' + echoCmd + ''); break; } + case '--echo-header': { + // Internal: dry-run pin header + var headerContent = msg.content.slice(msg.content.indexOf('--echo-header') + 13).trim(); + reply(msg, 'Eval', 'Pin: ' + headerContent); + break; + } case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; } From f06b1fe9f1c2dccc43701a27d4e704027d2eeff3 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 13:56:52 -0400 Subject: [PATCH 20/96] Gaslight scripting: fix nameless token showing ID twice in dry run --- Gaslight/Gaslight.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 413a0f7a9a..180fc1fe4a 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1983,8 +1983,9 @@ var Gaslight = Gaslight || (() => { var echoViewer = getObj('player', echoViewerId); var echoTarget = getObj('graphic', echoTargetId); var viewerName = echoViewer ? echoViewer.get('_displayname') : echoViewerId; - var targetName = echoTarget ? (echoTarget.get('name') || echoTargetId) : echoTargetId; - reply(msg, 'Eval', 'Dry run
Target: ' + targetName + ' ' + echoTargetId + '
Viewer: ' + viewerName + ' ' + echoViewerId + '
' + echoCmd + ''); + var echoTargetName = echoTarget ? echoTarget.get('name') : ''; + var targetDisplay = echoTargetName ? echoTargetName + ' ' + echoTargetId + '' : '' + echoTargetId + ''; + reply(msg, 'Eval', 'Dry run
Target: ' + targetDisplay + '
Viewer: ' + viewerName + ' ' + echoViewerId + '
' + echoCmd + ''); break; } case '--echo-header': { From 88fe2b12cc2b99f62e21b18b746071167ffe5973 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 14:19:57 -0400 Subject: [PATCH 21/96] Gaslight scripting: fix parseConfigText HTML stripping, has filter checks both gmnotes+attr, rename dump command --- Gaslight/Gaslight.js | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 180fc1fe4a..adc770825d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1733,6 +1733,8 @@ var Gaslight = Gaslight || (() => { */ const parseConfigText = (text) => { var config = { scope: 'token', filter: 'all', triggers: [] }; + // Strip HTML and normalize line breaks + text = text.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); text.split('\n').forEach(function(line) { line = line.trim(); if (line.startsWith('scope:')) config.scope = line.slice(6).trim(); @@ -1778,9 +1780,17 @@ var Gaslight = Gaslight || (() => { if (filter.startsWith('has ')) { var field = filter.slice(4).trim(); return tokens.filter(function(t) { + // Check gmnotes var notes = t.get('gmnotes') || ''; try { notes = decodeURIComponent(notes); } catch(e) {} - return notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1; + if (notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1) return true; + // Check character attribute + var charId = t.get('represents'); + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: field })[0]; + if (attr) return true; + } + return false; }); } return tokens; @@ -1994,6 +2004,28 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Eval', 'Pin: ' + headerContent); break; } + case '--dump-script': { + // Debug: dump raw handout/pin content to console + var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + sel.forEach(function(pin) { + var handoutId = pin.get('link'); + if (handoutId) { + var ho = getObj('handout', handoutId); + if (ho) { + ho.get('gmnotes', function(gn) { + log(SCRIPT_NAME + ' [gmnotes]: ' + JSON.stringify(gn)); + }); + ho.get('notes', function(n) { + log(SCRIPT_NAME + ' [notes]: ' + JSON.stringify(n)); + }); + } + } else { + log(SCRIPT_NAME + ' [pin gmNotes]: ' + JSON.stringify(pin.get('gmNotes'))); + log(SCRIPT_NAME + ' [pin notes]: ' + JSON.stringify(pin.get('notes'))); + } + }); + break; + } case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; } From 75b19d8243157c0a598814c7d731da6fef160077 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 14:26:39 -0400 Subject: [PATCH 22/96] Gaslight scripting: add trigger map, change:attribute + change:graphic:gmnotes handlers --- Gaslight/Gaslight.js | 137 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index adc770825d..5962a61b97 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -956,6 +956,9 @@ var Gaslight = Gaslight || (() => { summary += formatWarnings(globalWarnings); reply(msg, 'Split', summary); + // Build trigger map for scripting engine + buildTriggerMap(); + // Focus-ping each player to their character token on their page setTimeout(function() { Object.entries(groupInfo.players).forEach(function(entry) { @@ -1662,6 +1665,138 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' status -- Show state
' + '' + CMD + ' --help -- This help
'; + // ========================================================================= + // Scripting Engine — Trigger Map + // ========================================================================= + + // triggerMap: attributeName → [{ pinId, pageId }] + var triggerMap = {}; + + /** + * Parse a script's conditional blocks to find referenced attributes for auto-triggering. + * Looks for @(target.gl_*) and @(viewer.*) inside {& if} blocks. + */ + const parseTriggersFromScript = (content) => { + var triggers = []; + // Strip HTML for parsing + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + // Find content inside {& if ...} blocks (simple regex — catches most cases) + var ifRx = /\{&\s*if\s+(.+?)\}/gi; + var match; + while ((match = ifRx.exec(text)) !== null) { + var condition = match[1]; + // Find @(target.*) and @(viewer.*) references in the condition + var refRx = /@\((?:target|viewer)\.([^)]+)\)/g; + var refMatch; + while ((refMatch = refRx.exec(condition)) !== null) { + var field = refMatch[1]; + if (triggers.indexOf(field) === -1) triggers.push(field); + } + } + return triggers; + }; + + /** + * Build the trigger map for all active script pins. + * Called on split, and when handouts change. + */ + const buildTriggerMap = () => { + triggerMap = {}; + var s = state[SCRIPT_NAME]; + + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + parsePinConfig(pin, function(config) { + if (!config) return; + // Get explicit triggers from config + var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); + var manualOnly = config.triggers.some(function(t) { return t === 'manual only'; }); + + if (manualOnly) return; + + if (explicitTriggers.length > 0) { + // Use explicit triggers + explicitTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + } else { + // Auto-detect from script content + getPinScript(pin, function(content) { + if (!content) return; + var autoTriggers = parseTriggersFromScript(content); + // Remove ignored fields + var ignored = config.triggers.filter(function(t) { return t.startsWith('ignore '); }).map(function(t) { return t.slice(7).trim(); }); + autoTriggers = autoTriggers.filter(function(t) { return ignored.indexOf(t) === -1; }); + + autoTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + }); + } + }); + }); + }); + }); + }; + + /** + * Handle attribute changes — check trigger map and re-evaluate affected pins. + */ + const onAttributeChanged = (obj) => { + var attrName = obj.get('name'); + var entries = triggerMap[attrName]; + if (!entries || entries.length === 0) return; + + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }; + + /** + * Handle gmnotes changes — detect which gl_ fields changed and trigger. + */ + const onGmNotesChanged = (obj, prev) => { + if (!prev || !prev.gmnotes) return; + var oldNotes = prev.gmnotes || ''; + var newNotes = obj.get('gmnotes') || ''; + try { oldNotes = decodeURIComponent(oldNotes); } catch(e) {} + try { newNotes = decodeURIComponent(newNotes); } catch(e) {} + + // Parse gl_ fields from old and new + var glRx = /gl_([a-zA-Z0-9_]+)\s*:\s*(.+)/g; + var oldFields = {}; + var newFields = {}; + var m; + while ((m = glRx.exec(oldNotes)) !== null) oldFields['gl_' + m[1]] = m[2].trim(); + glRx.lastIndex = 0; + while ((m = glRx.exec(newNotes)) !== null) newFields['gl_' + m[1]] = m[2].trim(); + + // Find changed fields + var changedFields = Object.keys(newFields).filter(function(k) { return oldFields[k] !== newFields[k]; }); + // Also check removed fields + Object.keys(oldFields).forEach(function(k) { if (!(k in newFields) && changedFields.indexOf(k) === -1) changedFields.push(k); }); + + changedFields.forEach(function(field) { + var entries = triggerMap[field]; + if (!entries || entries.length === 0) return; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }); + }; + // ========================================================================= // Scripting Engine // ========================================================================= @@ -2142,6 +2277,8 @@ var Gaslight = Gaslight || (() => { on('chat:message', viewInterceptor); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); + on('change:attribute', onAttributeChanged); + on('change:graphic:gmnotes', onGmNotesChanged); setInterval(pollRelayQueue, 500); }; From c0baaf9e6fd98df10832ae28085ba3ea2a243675 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 14:28:31 -0400 Subject: [PATCH 23/96] Gaslight scripting: add generic change:graphic handler for token property triggers (bar values, etc) --- Gaslight/Gaslight.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 5962a61b97..20d0ae3cd0 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1762,8 +1762,26 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle gmnotes changes — detect which gl_ fields changed and trigger. + * Handle token property changes — check trigger map for graphic properties. */ + const onGraphicPropChanged = (obj, prev) => { + var changed = Object.keys(prev).filter(function(k) { return !k.startsWith('_') && prev[k] !== obj.get(k) && k !== 'gmnotes'; }); + if (changed.length === 0) return; + + var triggered = false; + changed.forEach(function(prop) { + var entries = triggerMap[prop]; + if (!entries || entries.length === 0) return; + if (triggered) return; // only evaluate once per change event + triggered = true; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }); + }; const onGmNotesChanged = (obj, prev) => { if (!prev || !prev.gmnotes) return; var oldNotes = prev.gmnotes || ''; @@ -2278,6 +2296,7 @@ var Gaslight = Gaslight || (() => { on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); on('change:attribute', onAttributeChanged); + on('change:graphic', onGraphicPropChanged); on('change:graphic:gmnotes', onGmNotesChanged); setInterval(pollRelayQueue, 500); }; From 434c1791c071a77a74632f3415c2b4f4a25bee76 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 15:06:05 -0400 Subject: [PATCH 24/96] =?UTF-8?q?Gaslight=20scripting:=20triggers=20are=20?= =?UTF-8?q?per-page=20=E2=80=94=20player=20page=20change=20evaluates=20onl?= =?UTF-8?q?y=20for=20that=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 20d0ae3cd0..46001630ae 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1712,24 +1712,20 @@ var Gaslight = Gaslight || (() => { pins.forEach(function(pin) { parsePinConfig(pin, function(config) { if (!config) return; - // Get explicit triggers from config var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); var manualOnly = config.triggers.some(function(t) { return t === 'manual only'; }); if (manualOnly) return; if (explicitTriggers.length > 0) { - // Use explicit triggers explicitTriggers.forEach(function(field) { if (!triggerMap[field]) triggerMap[field] = []; triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); }); } else { - // Auto-detect from script content getPinScript(pin, function(content) { if (!content) return; var autoTriggers = parseTriggersFromScript(content); - // Remove ignored fields var ignored = config.triggers.filter(function(t) { return t.startsWith('ignore '); }).map(function(t) { return t.slice(7).trim(); }); autoTriggers = autoTriggers.filter(function(t) { return ignored.indexOf(t) === -1; }); @@ -1806,11 +1802,30 @@ var Gaslight = Gaslight || (() => { changedFields.forEach(function(field) { var entries = triggerMap[field]; if (!entries || entries.length === 0) return; + // Find the master page counterpart of this token + var tokenId = obj.get('id'); + var masterTokenId = null; + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(active) { + // Check if this token is linked; find the master copy + var allLinked = active.linkedTokens[tokenId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked = allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i; }); + allLinked.forEach(function(id) { + var t = getObj('graphic', id); + if (t && t.get('_pageid') === active.masterPageId) masterTokenId = id; + }); + // If the token itself is on master + if (obj.get('_pageid') === active.masterPageId) masterTokenId = tokenId; + }); + entries.forEach(function(entry) { var pin = getObj('pin', entry.pinId); if (!pin) return; var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; - evaluatePins([pin], fakeMsg, false); + evaluatePins([pin], fakeMsg, false, masterTokenId, obj.get('_pageid')); }); }); }; @@ -2003,7 +2018,7 @@ var Gaslight = Gaslight || (() => { /** * Evaluate all scripts on pins for a given page. */ - const evaluatePins = (pins, msg, dryRun) => { + const evaluatePins = (pins, msg, dryRun, targetTokenId, sourcePageId) => { var s = state[SCRIPT_NAME]; pins.forEach(function(pin) { var pageId = pin.get('_pageid'); @@ -2019,13 +2034,16 @@ var Gaslight = Gaslight || (() => { // Determine which viewers to evaluate for based on pin placement var viewers; if (pageId === groupInfo.masterPageId) { - // Pin on master: evaluate for all players viewers = Object.entries(groupInfo.playerPages); } else { - // Pin on player page: evaluate only for that player var playerEntry = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === pageId; }); viewers = playerEntry ? [playerEntry] : []; } + // If triggered from a specific player page, narrow to that viewer only + if (sourcePageId && sourcePageId !== groupInfo.masterPageId) { + var sourceViewer = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === sourcePageId; }); + if (sourceViewer) viewers = [sourceViewer]; + } if (viewers.length === 0) return; // Get targets from master page (source of truth for token list) @@ -2035,6 +2053,11 @@ var Gaslight = Gaslight || (() => { if (!config) return; // Re-filter targets based on config targets = getTargetTokens(groupInfo.masterPageId, config, s.activeGroups); + // If triggered by a specific token, only evaluate that one + if (targetTokenId) { + targets = targets.filter(function(t) { return t.get('id') === targetTokenId; }); + if (targets.length === 0) return; + } getPinScript(pin, function(content) { if (!content) return; From c077b71eb3d35739a650de2d9760ffa08166955e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 15:17:30 -0400 Subject: [PATCH 25/96] =?UTF-8?q?Gaslight:=20fix=20getLinkId=20regex=20?= =?UTF-8?q?=E2=80=94=20stop=20at=20whitespace,=20strip=20HTML=20before=20m?= =?UTF-8?q?atching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 46001630ae..c70e24393d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -270,7 +270,8 @@ var Gaslight = Gaslight || (() => { const getLinkId = (token) => { var notes = token.get('gmnotes') || ''; try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } - const match = notes.match(/gaslight_link:\s*(.+)/); + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_link:\s*(\S+)/); return match ? match[1].trim() : null; }; From 62a5a642b7880b0ba35911d9df8eece52af52717 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 15:30:01 -0400 Subject: [PATCH 26/96] =?UTF-8?q?Gaslight=20scripting:=20simplify=20variab?= =?UTF-8?q?le=20resolution=20=E2=80=94=20just=20substitute=20token=20IDs,?= =?UTF-8?q?=20let=20Fetch=20handle=20everything?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 100 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index c70e24393d..edcdc5be05 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -957,8 +957,9 @@ var Gaslight = Gaslight || (() => { summary += formatWarnings(globalWarnings); reply(msg, 'Split', summary); - // Build trigger map for scripting engine + // Build trigger map and register Fetch compProps for scripting engine buildTriggerMap(); + registerAllCompProps(); // Focus-ping each player to their character token on their page setTimeout(function() { @@ -1666,6 +1667,80 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' status -- Show state
' + '' + CMD + ' --help -- This help
'; + // ========================================================================= + // Scripting Engine — Fetch Integration + // ========================================================================= + + // Module-level evaluation context for Fetch compProp resolution + var evaluationContext = { scope: 'token', targetId: null, viewerPlayerId: null }; + + /** + * Read a gl_ field from a token's gmnotes. + */ + const readGlField = (gmnotes, fieldName) => { + var notes = gmnotes || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + var rx = new RegExp(fieldName + '\\s*:\\s*(\\S+)'); + var match = notes.match(rx); + return match ? match[1] : ''; + }; + + /** + * Register a gl_ field as a Fetch compProp on the graphic type. + * Resolution depends on evaluationContext.scope. + */ + const registerGlCompProp = (fieldName) => { + if (typeof Fetch === 'undefined' || !Fetch.CustomPropsByType) return; + if (Fetch.CustomPropsByType.graphic.compProps[fieldName]) return; // already registered + + Fetch.CustomPropsByType.graphic.compProps[fieldName] = { + nicks: [], + val: function(o) { + if (evaluationContext.scope === 'token') { + return readGlField(o.gmnotes, fieldName); + } else { + // scope: character — read from character attribute + var charId = o.represents; + if (!charId) return ''; + return getAttrByName(charId, fieldName) || ''; + } + } + }; + log(SCRIPT_NAME + ': registered Fetch compProp "' + fieldName + '"'); + }; + + /** + * Scan a script for gl_ references and register compProps for each. + */ + const registerCompPropsFromScript = (content) => { + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + var rx = /@\([^)]*\.(gl_[a-zA-Z0-9_]+)\)/g; + var match; + while ((match = rx.exec(text)) !== null) { + registerGlCompProp(match[1]); + } + }; + + /** + * Scan all active script handouts and register compProps. + * Called on split and when handouts change. + */ + const registerAllCompProps = () => { + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + getPinScript(pin, function(content) { + if (content) registerCompPropsFromScript(content); + }); + }); + }); + }); + }; + // ========================================================================= // Scripting Engine — Trigger Map // ========================================================================= @@ -1995,11 +2070,28 @@ var Gaslight = Gaslight || (() => { const evaluateScript = (scriptContent, targetToken, viewerPlayerId, viewerPageId, config, msg, dryRun) => { // Find the linked token on the viewer's page var viewerTarget = findLinkedTokenOnPage(targetToken, viewerPageId); - if (!viewerTarget) return; // no linked copy on this viewer's page + if (!viewerTarget) return; + + // Set evaluation context for Fetch compProp resolution + evaluationContext.scope = config.scope || 'token'; + evaluationContext.targetId = viewerTarget.get('id'); + evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page var content = scriptContent; - content = content.replace(/@\(target\.token_id\)/g, viewerTarget.get('id')); - content = content.replace(/@\(target\.name\)/g, viewerTarget.get('name') || ''); + // Replace @(target.*) with token ID — Fetch resolves properties/attributes/compProps + content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); + // Replace @(viewer.*) with viewer's controlled token ID (first found) + var viewerTokens = findObjs({ _type: 'graphic', _pageid: viewerPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return cb === 'all' || cb.split(',').indexOf(viewerPlayerId) !== -1; + }); + if (viewerTokens.length > 0) { + content = content.replace(/@\(viewer\./g, '@(' + viewerTokens[0].get('id') + '.'); + } var lines = content.split('\n').filter(function(l) { l = l.trim(); From 82374098e6d8050c27b04993f087ac8181ce10fb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 07:22:48 -0400 Subject: [PATCH 27/96] =?UTF-8?q?Gaslight=20scripting:=20fix=20Fetch=20com?= =?UTF-8?q?pProp=20=E2=80=94=20inject=20into=20PropContainers=20cache,=20f?= =?UTF-8?q?ull=20resolution=20chain=20working?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 80 +++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index edcdc5be05..ac316756f3 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1692,21 +1692,23 @@ var Gaslight = Gaslight || (() => { */ const registerGlCompProp = (fieldName) => { if (typeof Fetch === 'undefined' || !Fetch.CustomPropsByType) return; - if (Fetch.CustomPropsByType.graphic.compProps[fieldName]) return; // already registered + if (Fetch.CustomPropsByType.graphic.compProps[fieldName]) return; - Fetch.CustomPropsByType.graphic.compProps[fieldName] = { - nicks: [], - val: function(o) { - if (evaluationContext.scope === 'token') { - return readGlField(o.gmnotes, fieldName); - } else { - // scope: character — read from character attribute - var charId = o.represents; - if (!charId) return ''; - return getAttrByName(charId, fieldName) || ''; - } + var valFn = function(o) { + if (evaluationContext.scope === 'token') { + return readGlField(o.gmnotes, fieldName); + } else { + var charId = o.represents; + if (!charId) return ''; + return getAttrByName(charId, fieldName) || ''; } }; + + Fetch.CustomPropsByType.graphic.compProps[fieldName] = { nicks: [], val: valFn }; + // Also inject into the cached PropContainers so Fetch uses it immediately + if (Fetch.PropContainers && Fetch.PropContainers.graphic) { + Fetch.PropContainers.graphic[fieldName] = valFn; + } log(SCRIPT_NAME + ': registered Fetch compProp "' + fieldName + '"'); }; @@ -2078,7 +2080,16 @@ var Gaslight = Gaslight || (() => { evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page var content = scriptContent; - // Replace @(target.*) with token ID — Fetch resolves properties/attributes/compProps + // Resolve @(target.gl_*) ourselves since Fetch compProps don't fire for sendChat messages + content = content.replace(/@\(target\.(gl_[a-zA-Z0-9_]+)\)/g, function(match, field) { + if (config.scope === 'token') { + return readGlField(viewerTarget.get('gmnotes'), field); + } else { + var charId = viewerTarget.get('represents'); + return charId ? (getAttrByName(charId, field) || '') : ''; + } + }); + // Replace remaining @(target.*) with token ID — Fetch resolves native props content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); // Replace @(viewer.*) with viewer's controlled token ID (first found) var viewerTokens = findObjs({ _type: 'graphic', _pageid: viewerPageId, _subtype: 'token' }).filter(function(t) { @@ -2104,7 +2115,15 @@ var Gaslight = Gaslight || (() => { }); } else { var fullCmd = lines.join('\n'); - if (fullCmd) sendChat('player|' + msg.playerid, fullCmd); + if (fullCmd) { + var senderId = msg.playerid; + if (senderId === 'API') { + var gmPlayer = findObjs({ _type: 'player' }).find(function(p) { return playerIsGM(p.get('_id')); }); + if (gmPlayer) senderId = gmPlayer.get('_id'); + } + log(SCRIPT_NAME + ': SENDING: ' + JSON.stringify(fullCmd)); + sendChat(getPlayerName(senderId), fullCmd); + } } }; @@ -2273,24 +2292,25 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Eval', 'Pin: ' + headerContent); break; } - case '--dump-script': { - // Debug: dump raw handout/pin content to console + case '--dump-html': { + // Debug: dump raw content to console for selected pins or tokens var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); - sel.forEach(function(pin) { - var handoutId = pin.get('link'); - if (handoutId) { - var ho = getObj('handout', handoutId); - if (ho) { - ho.get('gmnotes', function(gn) { - log(SCRIPT_NAME + ' [gmnotes]: ' + JSON.stringify(gn)); - }); - ho.get('notes', function(n) { - log(SCRIPT_NAME + ' [notes]: ' + JSON.stringify(n)); - }); + sel.forEach(function(obj) { + var type = obj.get('_type') || obj.get('type'); + if (type === 'pin') { + var handoutId = obj.get('link'); + if (handoutId) { + var ho = getObj('handout', handoutId); + if (ho) { + ho.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [handout gmnotes]: ' + JSON.stringify(gn)); }); + ho.get('notes', function(n) { log(SCRIPT_NAME + ' [handout notes]: ' + JSON.stringify(n)); }); + } + } else { + log(SCRIPT_NAME + ' [pin gmNotes]: ' + JSON.stringify(obj.get('gmNotes'))); + log(SCRIPT_NAME + ' [pin notes]: ' + JSON.stringify(obj.get('notes'))); } - } else { - log(SCRIPT_NAME + ' [pin gmNotes]: ' + JSON.stringify(pin.get('gmNotes'))); - log(SCRIPT_NAME + ' [pin notes]: ' + JSON.stringify(pin.get('notes'))); + } else if (type === 'graphic') { + log(SCRIPT_NAME + ' [token ' + (obj.get('name') || obj.get('id')) + ' gmnotes]: ' + JSON.stringify(obj.get('gmnotes'))); } }); break; From 953a0ace14a124f7820eed4de53183e5b194fd10 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 07:40:24 -0400 Subject: [PATCH 28/96] =?UTF-8?q?Gaslight=20scripting:=20add=20Roll=20Capt?= =?UTF-8?q?ure=20design=20section=20=E2=80=94=20rules,=20extraction,=20tok?= =?UTF-8?q?en=20association,=20ambiguity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/SCRIPTING_DESIGN.md | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index 346fe93533..529573b744 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -201,6 +201,84 @@ The handout notes/gmnotes contain commands using standard Meta-Toolbox syntax: 7. **Standard token properties:** Fetch handles natively. We only register `gl_*` compProps. +## Roll Capture + +### Concept + +Roll capture is a separate system from script evaluation. It monitors chat for roll results, extracts values, and stores them in `gl_*` fields (gmnotes or character attributes). This storage then triggers script re-evaluation via the normal trigger map. + +Roll capture rules are defined in handouts (tagged `[GLS]` for discoverability). The system runs silently in the background — no special trigger syntax needed in script pins. + +### Capture Rule Handout + +A capture rule handout defines: + +1. **Identification** — how to recognize a specific kind of roll in chat (roll template name, content pattern, regex) +2. **Value extraction** — which inline roll result to capture (by index, by field name, advantage/disadvantage handling) +3. **Variable name** (optional) — which `gl_*` field to store it in. If omitted, derived from the roll name (e.g. "Stealth" → `gl_stealth`) +4. **Character identification** (optional) — how to determine which character the roll belongs to. May be auto-detected from `msg.content` character references. + +### Format (TBD) + +``` +---GLS-CAPTURE--- +match: rolltemplate "atk" where {{name}} contains "Stealth" +extract: inline_roll[0].total +advantage: highest +variable: gl_stealth_result +``` + +Or a simpler generic form: +``` +---GLS-CAPTURE--- +match: rolltemplate "simple" +name_field: {{rname}} +extract: inline_roll[0].total +variable_prefix: gl_ +``` + +This generic form captures ANY "simple" template roll and maps it to `gl_`. + +### Value Extraction Challenges + +- **Advantage/Disadvantage** (D&D 5E): Two d20s rolled, result depends on token state. Options: + - Always take `inline_roll[0].total` (the final computed result after sheet logic) + - Configurable: `extract: highest`, `extract: lowest`, `extract: first`, `extract: inline_roll[N].total` + - Sheet-specific: different sheets encode advantage differently + +- **Multiple rolls in one message**: Capture rule specifies which roll by index or by position in template + +### Token Association + +When a capture rule matches a roll: + +1. **Selected token** (default) — `msg.selected[0]` identifies the token. Store on that token. +2. **Character-level context check** — if ALL active scripts using this `gl_*` field are `scope: character`, store on the character attribute (no token ambiguity issue). +3. **Ambiguity** — if at least one script uses `scope: token` AND no token is selected (or multiple are selected without enough rolls): + - Whisper the GM: "Stealth roll of 14 captured. Select a token to assign it to, or roll X more times for Y tokens." + - Provide clickable buttons per eligible token + - Queue the result until assigned +4. **Character fallback** — if the roll message identifies a character (via template content like `{{charname=Goblin}}`), and no token-level scripts exist for this field, store directly on the character attribute. + +### Auto-Detection vs Custom Handouts + +- **Custom handouts** (v1): GM writes capture rules as `[GLS]` handouts. Full control over pattern matching. +- **Auto-generation** (v2): Gaslight analyzes the character sheet template(s) in use and auto-generates capture rules in memory. No handout needed for common rolls. + +### Capture Flow + +1. `on('chat:message')` — check all capture rules against message +2. If match: extract value, determine character, determine token (if needed) +3. Store: write to `gl_*` in gmnotes (scope: token) or character attribute (scope: character) +4. After write: manually call trigger evaluation for the affected field (since API `set()` won't fire `change:graphic` events for gmnotes) + +### Open Questions + +1. Should capture rules be active only on gaslit pages, or always active (so rolls captured before split are ready)? +2. How to handle roll results that arrive before any script references the field (pre-capture)? +3. Should there be a `!gaslight captures` command to list active capture rules and recent captured values? +4. Can we support ScriptCards output as a capture source? + ## Future Ideas - Visual script editor (handout with structured format) From 9b4d4506cbc390d840bbd5f1091983dc67e5ce69 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 08:11:51 -0400 Subject: [PATCH 29/96] Gaslight scripting: document D&D 5E roll structure and proposed capture rule format --- Gaslight/Gaslight.js | 25 ++++++++++++++++- Gaslight/SCRIPTING_DESIGN.md | 53 +++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index ac316756f3..3acf8fce07 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2293,7 +2293,18 @@ var Gaslight = Gaslight || (() => { break; } case '--dump-html': { - // Debug: dump raw content to console for selected pins or tokens + // Debug: dump raw content to console for selected pins/tokens or named character + if (args.length > 0) { + var charName = args.join(' '); + var charObj = findObjs({ _type: 'character', name: charName })[0]; + if (charObj) { + charObj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char "' + charName + '" bio]: ' + JSON.stringify(bio)); }); + charObj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char "' + charName + '" gmnotes]: ' + JSON.stringify(gn)); }); + } else { + reply(msg, 'Error', 'Character "' + charName + '" not found.'); + } + break; + } var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); sel.forEach(function(obj) { var type = obj.get('_type') || obj.get('type'); @@ -2311,6 +2322,9 @@ var Gaslight = Gaslight || (() => { } } else if (type === 'graphic') { log(SCRIPT_NAME + ' [token ' + (obj.get('name') || obj.get('id')) + ' gmnotes]: ' + JSON.stringify(obj.get('gmnotes'))); + } else if (type === 'character') { + obj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' bio]: ' + JSON.stringify(bio)); }); + obj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' gmnotes]: ' + JSON.stringify(gn)); }); } }); break; @@ -2429,6 +2443,15 @@ var Gaslight = Gaslight || (() => { const registerEventHandlers = () => { on('chat:message', handleInput); on('chat:message', viewInterceptor); + on('chat:message', function(msg) { + if (msg.rolltemplate && msg.inlinerolls) { + log(SCRIPT_NAME + ' [ROLL]: template=' + msg.rolltemplate + ', inlinerolls count=' + msg.inlinerolls.length); + msg.inlinerolls.forEach(function(r, i) { + log(SCRIPT_NAME + ' [' + i + ']: total=' + (r.results ? r.results.total : 'N/A')); + }); + log(SCRIPT_NAME + ' content=' + msg.content.slice(0, 200)); + } + }); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); on('change:attribute', onAttributeChanged); diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index 529573b744..20d54eaff4 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -272,7 +272,58 @@ When a capture rule matches a roll: 3. Store: write to `gl_*` in gmnotes (scope: token) or character attribute (scope: character) 4. After write: manually call trigger evaluation for the affected field (since API `set()` won't fire `change:graphic` events for gmnotes) -### Open Questions +### D&D 5E Roll Message Structure (Observed) + +**NPCs** (template: `npc`): +``` +content: {{name=Blackguard}} {{rname=^{stealth}}} {{mod=1}} {{r1=$[[0]]}} {{query=1}} {{normal=1}} {{r2=$[[1]]}} {{type=Skill}} +inlinerolls: [0]=total, [1]=total (always 2 rolls) +``` + +**PCs** (template: `simple`): +``` +content: {{rname=^{stealth-u}}} {{mod=12}} {{r1=$[[0]]}} {{query=1}} {{normal=1}} {{r2=$[[1]]}} {{global=}} {{charname=Leilah "Obscura"}} +inlinerolls: [0]=total, [1]=total (always 2 rolls) +``` + +**Advantage flags** (mutually exclusive): +- `{{normal=1}}` → use r1 (index 0) +- `{{advantage=1}}` → use max(r1, r2) +- `{{disadvantage=1}}` → use min(r1, r2) +- `{{always=1}}` → ambiguous; default to max(r1, r2) + +**Character identification:** +- NPC: `{{name=X}}` (when "add name to template" is on) +- PC: `{{charname=X}}` or bare `charname=X` at end of content + +**Skill name:** +- NPC: `{{rname=^{stealth}}}` (translation key format) +- PC: `{{rname=^{stealth-u}}}` (with `-u` suffix) + +### Proposed Capture Rule Format + +``` +---GLS-CAPTURE--- +template: npc, simple +name_field: rname +char_field: name, charname +value: r1=0, r2=1 +advantage: {{advantage=1}} → max(r1,r2) +disadvantage: {{disadvantage=1}} → min(r1,r2) +normal: {{normal=1}} → r1 +always: {{always=1}} → max(r1,r2) +variable: gl_${rname} +``` + +Fields: +- `template` — which roll templates to match (comma-separated) +- `name_field` — which template field contains the skill/ability name +- `char_field` — which template field(s) contain the character name (for identification) +- `value` — maps symbolic names to inline roll indices +- `advantage/disadvantage/normal/always` — condition → extraction rule +- `variable` — gl_ field name pattern (`${rname}` substitutes the matched skill name) + +### Roll Capture Open Questions (Continued) 1. Should capture rules be active only on gaslit pages, or always active (so rolls captured before split are ready)? 2. How to handle roll results that arrive before any script references the field (pre-capture)? From 6981d4a464e2f38a397acedac1112f6965b10e28 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 08:23:12 -0400 Subject: [PATCH 30/96] =?UTF-8?q?RollCapture:=20initial=20design=20documen?= =?UTF-8?q?t=20=E2=80=94=20generic=20roll=20capture=20with=20D&D=205E=20de?= =?UTF-8?q?fault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/DESIGN.md | 117 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 RollCapture/DESIGN.md diff --git a/RollCapture/DESIGN.md b/RollCapture/DESIGN.md new file mode 100644 index 0000000000..8d250d8a80 --- /dev/null +++ b/RollCapture/DESIGN.md @@ -0,0 +1,117 @@ +# RollCapture — Design Document + +## Concept + +RollCapture monitors chat for roll results, extracts numeric values based on configurable rules, and stores them on tokens (gmnotes) or character attributes. It operates silently in the background — other scripts (like Gaslight) react to the stored values via change events. + +## Use Cases + +- Store stealth rolls per-token for perception-based visibility scripts +- Track damage dealt for combat logging +- Record saving throw results for automated status effects +- Feed roll results into conditional automation systems + +## Architecture + +### Capture Rules + +Rules are defined in handouts tagged `[RC]` (Roll Capture). Each handout defines one capture rule set. + +### Rule Format + +``` +---ROLLCAPTURE--- +template: npc, simple +name_field: rname +char_field: name, charname +value: r1=0, r2=1 +normal: {{normal=1}} → r1 +advantage: {{advantage=1}} → max(r1,r2) +disadvantage: {{disadvantage=1}} → min(r1,r2) +always: {{always=1}} → max(r1,r2) +variable: gl_${rname} +storage: gmnotes +``` + +Fields: +- `template` — roll template name(s) to match (comma-separated) +- `name_field` — template field containing the roll name (e.g. skill name) +- `char_field` — template field(s) for character identification (comma-separated, tried in order) +- `value` — maps symbolic names to inline roll indices (e.g. `r1=0` means "r1 is inlinerolls[0].results.total") +- `normal/advantage/disadvantage/always` — condition pattern → extraction formula +- `variable` — gl_ field name pattern. `${rname}` substitutes the matched roll name, cleaned (lowercase, stripped) +- `storage` — `gmnotes` (per-token) or `attribute` (per-character). Default: `gmnotes` + +### Extraction Logic + +1. Check `msg.rolltemplate` against rule's `template` list +2. Parse `msg.content` for `{{field=value}}` pairs +3. Check which advantage flag is present → select extraction formula +4. Resolve formula: `r1` → `msg.inlinerolls[0].results.total`, `max(r1,r2)` → `Math.max(inlinerolls[0], inlinerolls[1])` +5. Determine variable name: substitute `${rname}` with cleaned roll name +6. Store value + +### Token Association + +1. `msg.selected[0]` — if a token is selected, store on it +2. Character lookup — if `char_field` matches a character name, find tokens representing that character +3. Scope-aware ambiguity: + - If only `storage: attribute` rules use this field → store on character (no token needed) + - If `storage: gmnotes` rules exist AND multiple tokens represent same character → prompt GM +4. Queue unresolved captures — whisper GM with clickable assignment buttons + +### Name Cleaning + +`${rname}` from `{{rname=^{stealth}}}` needs cleaning: +- Strip `^{` and `}` (translation key wrappers) +- Strip `-u` suffix (uppercase variant marker) +- Lowercase +- Result: `stealth` + +So `gl_${rname}` → `gl_stealth` + +### API + +```javascript +RollCapture.getCapturedValue(tokenId, fieldName) // read latest captured value +RollCapture.getLastCapture() // most recent capture result +RollCapture.registerRule(ruleObj) // programmatic rule registration +``` + +### Events / Integration + +After storing a value: +- If `storage: gmnotes` → manually fire `change:graphic:gmnotes` won't work (API set doesn't trigger) +- Instead: expose a callback/hook that other scripts register for: `RollCapture.onCapture(fieldName, callback)` +- Gaslight registers: `RollCapture.onCapture('gl_*', evaluateTriggeredPins)` + +### Dependencies + +None required. Optional integration with: +- Gaslight (consumes captured values) +- Fetch (gl_ compProps for reading stored values) + +### D&D 5E Default Rule + +Ships with a pre-built `[RC] D&D 5E Skills` handout: +``` +---ROLLCAPTURE--- +template: npc, simple +name_field: rname +char_field: name, charname +value: r1=0, r2=1 +normal: {{normal=1}} → r1 +advantage: {{advantage=1}} → max(r1,r2) +disadvantage: {{disadvantage=1}} → min(r1,r2) +always: {{always=1}} → max(r1,r2) +variable: gl_${rname} +storage: gmnotes +``` + +### Open Questions + +1. Should rules be hot-reloaded when handouts change, or require a `!rollcapture reload`? +2. How to handle sheets that don't use Roll20's standard `{{field=value}}` format? +3. Should there be a capture history (last N rolls per token)? +4. Command interface: `!rollcapture status`, `!rollcapture rules`, `!rollcapture clear`? +5. Should the script auto-detect which character sheet is in use and suggest rules? From 2528d554277f5ca34f2e933e94aadac5aaf302da Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 08:25:50 -0400 Subject: [PATCH 31/96] Gaslight scripting: reference RollCapture as separate script, remove inline capture design --- Gaslight/SCRIPTING_DESIGN.md | 69 ++---------------------------------- 1 file changed, 3 insertions(+), 66 deletions(-) diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md index 20d54eaff4..e17b1c183c 100644 --- a/Gaslight/SCRIPTING_DESIGN.md +++ b/Gaslight/SCRIPTING_DESIGN.md @@ -203,74 +203,11 @@ The handout notes/gmnotes contain commands using standard Meta-Toolbox syntax: ## Roll Capture -### Concept +Roll capture is handled by the separate **RollCapture** script (see `RollCapture/DESIGN.md`). RollCapture monitors chat for roll results, extracts values, and stores them in `gl_*` fields. Gaslight integrates via `RollCapture.onCapture()` callback to trigger script re-evaluation when values change. -Roll capture is a separate system from script evaluation. It monitors chat for roll results, extracts values, and stores them in `gl_*` fields (gmnotes or character attributes). This storage then triggers script re-evaluation via the normal trigger map. +**Dependency:** RollCapture is optional. Without it, `gl_*` values can be set manually (via gmnotes editing or chat commands). With it, rolls are automatically captured and stored. -Roll capture rules are defined in handouts (tagged `[GLS]` for discoverability). The system runs silently in the background — no special trigger syntax needed in script pins. - -### Capture Rule Handout - -A capture rule handout defines: - -1. **Identification** — how to recognize a specific kind of roll in chat (roll template name, content pattern, regex) -2. **Value extraction** — which inline roll result to capture (by index, by field name, advantage/disadvantage handling) -3. **Variable name** (optional) — which `gl_*` field to store it in. If omitted, derived from the roll name (e.g. "Stealth" → `gl_stealth`) -4. **Character identification** (optional) — how to determine which character the roll belongs to. May be auto-detected from `msg.content` character references. - -### Format (TBD) - -``` ----GLS-CAPTURE--- -match: rolltemplate "atk" where {{name}} contains "Stealth" -extract: inline_roll[0].total -advantage: highest -variable: gl_stealth_result -``` - -Or a simpler generic form: -``` ----GLS-CAPTURE--- -match: rolltemplate "simple" -name_field: {{rname}} -extract: inline_roll[0].total -variable_prefix: gl_ -``` - -This generic form captures ANY "simple" template roll and maps it to `gl_`. - -### Value Extraction Challenges - -- **Advantage/Disadvantage** (D&D 5E): Two d20s rolled, result depends on token state. Options: - - Always take `inline_roll[0].total` (the final computed result after sheet logic) - - Configurable: `extract: highest`, `extract: lowest`, `extract: first`, `extract: inline_roll[N].total` - - Sheet-specific: different sheets encode advantage differently - -- **Multiple rolls in one message**: Capture rule specifies which roll by index or by position in template - -### Token Association - -When a capture rule matches a roll: - -1. **Selected token** (default) — `msg.selected[0]` identifies the token. Store on that token. -2. **Character-level context check** — if ALL active scripts using this `gl_*` field are `scope: character`, store on the character attribute (no token ambiguity issue). -3. **Ambiguity** — if at least one script uses `scope: token` AND no token is selected (or multiple are selected without enough rolls): - - Whisper the GM: "Stealth roll of 14 captured. Select a token to assign it to, or roll X more times for Y tokens." - - Provide clickable buttons per eligible token - - Queue the result until assigned -4. **Character fallback** — if the roll message identifies a character (via template content like `{{charname=Goblin}}`), and no token-level scripts exist for this field, store directly on the character attribute. - -### Auto-Detection vs Custom Handouts - -- **Custom handouts** (v1): GM writes capture rules as `[GLS]` handouts. Full control over pattern matching. -- **Auto-generation** (v2): Gaslight analyzes the character sheet template(s) in use and auto-generates capture rules in memory. No handout needed for common rolls. - -### Capture Flow - -1. `on('chat:message')` — check all capture rules against message -2. If match: extract value, determine character, determine token (if needed) -3. Store: write to `gl_*` in gmnotes (scope: token) or character attribute (scope: character) -4. After write: manually call trigger evaluation for the affected field (since API `set()` won't fire `change:graphic` events for gmnotes) +See `RollCapture/DESIGN.md` for full architecture, rule format, and D&D 5E default configuration. ### D&D 5E Roll Message Structure (Observed) From 104daf612abfa12baff7302d1fd5add5420266c3 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 08:49:00 -0400 Subject: [PATCH 32/96] RollCapture: add example capture rules for 5 systems to design doc --- RollCapture/DESIGN.md | 65 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/RollCapture/DESIGN.md b/RollCapture/DESIGN.md index 8d250d8a80..35dfab88f0 100644 --- a/RollCapture/DESIGN.md +++ b/RollCapture/DESIGN.md @@ -115,3 +115,68 @@ storage: gmnotes 3. Should there be a capture history (last N rolls per token)? 4. Command interface: `!rollcapture status`, `!rollcapture rules`, `!rollcapture clear`? 5. Should the script auto-detect which character sheet is in use and suggest rules? + +## Example Capture Rules + +### D&D 5E Skills +``` +---ROLLCAPTURE--- +template: npc, simple +name_field: rname +char_field: name, charname +value: r1=0, r2=1 +normal: {{normal=1}} → r1 +advantage: {{advantage=1}} → max(r1,r2) +disadvantage: {{disadvantage=1}} → min(r1,r2) +always: {{always=1}} → max(r1,r2) +variable: gl_${rname} +storage: gmnotes +``` + +### Savage Worlds +``` +---ROLLCAPTURE--- +template: roll +name_field: trait +char_field: name +value: skill=0, wild=1 +default: max(skill, wild) +variable: gl_${trait} +storage: gmnotes +``` + +### Shadow of the Demon Lord +``` +---ROLLCAPTURE--- +template: sotdl +name_field: roll-label +char_field: name, title +value: roll=0 +default: roll +variable: gl_${roll-label} +storage: gmnotes +``` + +### Warhammer Fantasy Roleplay 4E +``` +---ROLLCAPTURE--- +template: wfrp +name_field: roll_name +char_field: name +value: roll=0 +default: roll +variable: gl_${roll_name} +storage: gmnotes +``` + +### Call of Cthulhu 7E +``` +---ROLLCAPTURE--- +template: coc-dice-roll +name_field: name +char_field: name +value: roll=0 +default: roll +variable: gl_${name} +storage: gmnotes +``` From 39aa983b43afa245a7456bd8e4f375e5af0e316f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 09:16:51 -0400 Subject: [PATCH 33/96] RollCapture: generic when/default format, no arrows, add choose() for GM prompts --- RollCapture/DESIGN.md | 11 +++++----- RollCapture/diagnostic.js | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 RollCapture/diagnostic.js diff --git a/RollCapture/DESIGN.md b/RollCapture/DESIGN.md index 35dfab88f0..8579226385 100644 --- a/RollCapture/DESIGN.md +++ b/RollCapture/DESIGN.md @@ -125,10 +125,10 @@ template: npc, simple name_field: rname char_field: name, charname value: r1=0, r2=1 -normal: {{normal=1}} → r1 -advantage: {{advantage=1}} → max(r1,r2) -disadvantage: {{disadvantage=1}} → min(r1,r2) -always: {{always=1}} → max(r1,r2) +when: {{advantage=1}} => max(r1, r2) +when: {{disadvantage=1}} => min(r1, r2) +when: {{always=1}} => choose(r1, r2) +default: r1 variable: gl_${rname} storage: gmnotes ``` @@ -140,7 +140,8 @@ template: roll name_field: trait char_field: name value: skill=0, wild=1 -default: max(skill, wild) +when: {{wildcard=1}} => max(skill, wild) +default: skill variable: gl_${trait} storage: gmnotes ``` diff --git a/RollCapture/diagnostic.js b/RollCapture/diagnostic.js new file mode 100644 index 0000000000..100f315322 --- /dev/null +++ b/RollCapture/diagnostic.js @@ -0,0 +1,42 @@ +// RollCapture Diagnostic — paste this into your Roll20 API sandbox +// Logs EVERYTHING about any roll template message to the console +// Remove when done testing + +on('chat:message', function(msg) { + if (!msg.rolltemplate) return; + + log('=== ROLL DIAGNOSTIC ==='); + log(' type: ' + msg.type); + log(' who: ' + msg.who); + log(' playerid: ' + msg.playerid); + log(' rolltemplate: ' + msg.rolltemplate); + log(' content: ' + msg.content); + log(' selected: ' + JSON.stringify(msg.selected || null)); + + if (msg.inlinerolls) { + log(' inlinerolls (' + msg.inlinerolls.length + '):'); + msg.inlinerolls.forEach(function(roll, i) { + log(' [' + i + ']:'); + log(' expression: ' + roll.expression); + log(' total: ' + (roll.results ? roll.results.total : 'N/A')); + log(' type: ' + (roll.results ? roll.results.type : 'N/A')); + if (roll.results && roll.results.rolls) { + roll.results.rolls.forEach(function(r, j) { + if (r.type === 'R' && r.results) { + log(' roll[' + j + ']: ' + r.dice + 'd' + r.sides + ' → [' + + r.results.map(function(d) { + return d.v + (d.d ? '(dropped)' : ''); + }).join(', ') + ']'); + } else if (r.type === 'M') { + log(' mod[' + j + ']: ' + r.expr); + } else { + log(' [' + j + ']: type=' + r.type + ' ' + JSON.stringify(r).slice(0, 100)); + } + }); + } + }); + } + + log(' --- raw msg keys: ' + Object.keys(msg).join(', ')); + log('=== END DIAGNOSTIC ==='); +}); From 966edf64124e474740337f4a9d1502936a2be341 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 09:35:49 -0400 Subject: [PATCH 34/96] =?UTF-8?q?RollCapture:=20finalize=20format=20?= =?UTF-8?q?=E2=80=94=20field-name=20refs,=20multi-capture,=20choose(),=20m?= =?UTF-8?q?issing-field=20drop,=20clear=20syntax,=20no=20indentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/DESIGN.md | 78 ++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/RollCapture/DESIGN.md b/RollCapture/DESIGN.md index 8579226385..9c9c94c41d 100644 --- a/RollCapture/DESIGN.md +++ b/RollCapture/DESIGN.md @@ -24,24 +24,65 @@ Rules are defined in handouts tagged `[RC]` (Roll Capture). Each handout defines template: npc, simple name_field: rname char_field: name, charname -value: r1=0, r2=1 -normal: {{normal=1}} → r1 -advantage: {{advantage=1}} → max(r1,r2) -disadvantage: {{disadvantage=1}} → min(r1,r2) -always: {{always=1}} → max(r1,r2) -variable: gl_${rname} +when: {{advantage=1}} +attack: max(r1, r2) +damage: sum(dmg1, dmg2, globaldamage) +when: {{disadvantage=1}} +attack: min(r1, r2) +damage: sum(dmg1, dmg2, globaldamage) +when: {{always=1}} +attack: choose(r1, r2) +damage: sum(dmg1, dmg2, globaldamage) +default: +attack: r1 +damage: sum(dmg1, dmg2, globaldamage) +variable: gl_${rname}_${capture} storage: gmnotes ``` Fields: - `template` — roll template name(s) to match (comma-separated) -- `name_field` — template field containing the roll name (e.g. skill name) +- `name_field` — template field containing the roll name (e.g. skill/weapon name) - `char_field` — template field(s) for character identification (comma-separated, tried in order) -- `value` — maps symbolic names to inline roll indices (e.g. `r1=0` means "r1 is inlinerolls[0].results.total") -- `normal/advantage/disadvantage/always` — condition pattern → extraction formula -- `variable` — gl_ field name pattern. `${rname}` substitutes the matched roll name, cleaned (lowercase, stripped) +- `when: ` — condition block: if pattern found in content, use the captures that follow +- `default:` — captures to use when no `when` condition matches +- `: ` — after a `when:` or `default:`, any non-keyword line is a capture +- `variable` — gl_ field name pattern. `${rname}` = roll name, `${capture}` = capture name - `storage` — `gmnotes` (per-token) or `attribute` (per-character). Default: `gmnotes` +### Field Name Resolution + +Template content uses `{{field=$[[N]]}}` patterns. Formulas reference field names (e.g. `r1`, `dmg1`), which resolve to inline roll indices by parsing the content: +- `{{r1=$[[2]]}}` → `r1` = `inlinerolls[2].results.total` +- `{{dmg1=$[[6]]}}` → `dmg1` = `inlinerolls[6].results.total` +- Raw `rN` without a matching template field falls back to `inlinerolls[N].results.total` + +### Formula Language + +- `r1` — value of a single field +- `max(r1, r2, ...)` — highest value among fields +- `min(r1, r2, ...)` — lowest value among fields +- `sum(r1, r2, ...)` — total of all fields +- `choose(r1, r2, ...)` — whisper GM with buttons to pick + +**Missing fields:** If a field referenced in a formula doesn't exist in the content, it is dropped from the function (not set to 0). This prevents interference with min/max/choose. + +### Capture Semantics + +- `attack: r1` — store the resolved value +- `attack:` (empty formula) — clear/unset the captured variable +- Capture name not listed in a block — don't touch, leave previous value + +### Multi-Variable Capture + +One rule can capture multiple variables from a single roll. Each `when:`/`default:` block can define any number of captures. The `variable` pattern uses `${capture}` to differentiate: +- `variable: gl_${rname}_${capture}` with captures `attack` and `damage` and rname `Scimitar` +- Produces: `gl_scimitar_attack` and `gl_scimitar_damage` + +### Block Parsing (No Indentation Required) + +Lines after `when:` or `default:` are treated as captures until the next `when:`, `default:`, or known keyword (`template:`, `name_field:`, `char_field:`, `variable:`, `storage:`). No indentation sensitivity. + ### Extraction Logic 1. Check `msg.rolltemplate` against rule's `template` list @@ -91,23 +132,6 @@ None required. Optional integration with: - Gaslight (consumes captured values) - Fetch (gl_ compProps for reading stored values) -### D&D 5E Default Rule - -Ships with a pre-built `[RC] D&D 5E Skills` handout: -``` ----ROLLCAPTURE--- -template: npc, simple -name_field: rname -char_field: name, charname -value: r1=0, r2=1 -normal: {{normal=1}} → r1 -advantage: {{advantage=1}} → max(r1,r2) -disadvantage: {{disadvantage=1}} → min(r1,r2) -always: {{always=1}} → max(r1,r2) -variable: gl_${rname} -storage: gmnotes -``` - ### Open Questions 1. Should rules be hot-reloaded when handouts change, or require a `!rollcapture reload`? From 4bbea75cf92e1ed2750af369d1183a739705cad5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 09:42:00 -0400 Subject: [PATCH 35/96] =?UTF-8?q?RollCapture:=20remove=20storage=20from=20?= =?UTF-8?q?capture=20rules=20=E2=80=94=20consumers=20decide=20where=20to?= =?UTF-8?q?=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/DESIGN.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/RollCapture/DESIGN.md b/RollCapture/DESIGN.md index 9c9c94c41d..29b959148c 100644 --- a/RollCapture/DESIGN.md +++ b/RollCapture/DESIGN.md @@ -37,7 +37,6 @@ default: attack: r1 damage: sum(dmg1, dmg2, globaldamage) variable: gl_${rname}_${capture} -storage: gmnotes ``` Fields: @@ -48,7 +47,8 @@ Fields: - `default:` — captures to use when no `when` condition matches - `: ` — after a `when:` or `default:`, any non-keyword line is a capture - `variable` — gl_ field name pattern. `${rname}` = roll name, `${capture}` = capture name -- `storage` — `gmnotes` (per-token) or `attribute` (per-character). Default: `gmnotes` + +Consumers (e.g. Gaslight) decide where to store captured values (token gmnotes, character attribute, or both) based on their own configuration. ### Field Name Resolution @@ -154,7 +154,6 @@ when: {{disadvantage=1}} => min(r1, r2) when: {{always=1}} => choose(r1, r2) default: r1 variable: gl_${rname} -storage: gmnotes ``` ### Savage Worlds @@ -167,7 +166,6 @@ value: skill=0, wild=1 when: {{wildcard=1}} => max(skill, wild) default: skill variable: gl_${trait} -storage: gmnotes ``` ### Shadow of the Demon Lord @@ -179,7 +177,6 @@ char_field: name, title value: roll=0 default: roll variable: gl_${roll-label} -storage: gmnotes ``` ### Warhammer Fantasy Roleplay 4E @@ -191,7 +188,6 @@ char_field: name value: roll=0 default: roll variable: gl_${roll_name} -storage: gmnotes ``` ### Call of Cthulhu 7E @@ -203,5 +199,4 @@ char_field: name value: roll=0 default: roll variable: gl_${name} -storage: gmnotes ``` From 40faf7193891231a13f884db32d8bee09ccdfc15 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 09:45:57 -0400 Subject: [PATCH 36/96] RollCapture: rewrite examples to multi-capture block format, update extraction logic and API docs --- RollCapture/DESIGN.md | 92 +++++++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/RollCapture/DESIGN.md b/RollCapture/DESIGN.md index 29b959148c..4453689625 100644 --- a/RollCapture/DESIGN.md +++ b/RollCapture/DESIGN.md @@ -81,25 +81,23 @@ One rule can capture multiple variables from a single roll. Each `when:`/`defaul ### Block Parsing (No Indentation Required) -Lines after `when:` or `default:` are treated as captures until the next `when:`, `default:`, or known keyword (`template:`, `name_field:`, `char_field:`, `variable:`, `storage:`). No indentation sensitivity. +Lines after `when:` or `default:` are treated as captures until the next `when:`, `default:`, or known keyword (`template:`, `name_field:`, `char_field:`, `variable:`). No indentation sensitivity. ### Extraction Logic 1. Check `msg.rolltemplate` against rule's `template` list -2. Parse `msg.content` for `{{field=value}}` pairs -3. Check which advantage flag is present → select extraction formula -4. Resolve formula: `r1` → `msg.inlinerolls[0].results.total`, `max(r1,r2)` → `Math.max(inlinerolls[0], inlinerolls[1])` -5. Determine variable name: substitute `${rname}` with cleaned roll name -6. Store value +2. Parse `msg.content` for `{{field=$[[N]]}}` pairs → builds field-to-index map +3. Parse `msg.content` for `{{field=value}}` pairs → builds flag map +4. Check `when:` conditions against flag map → select matching capture block +5. Resolve formula: field names map to `inlinerolls[N].results.total` via the index map. Missing fields are dropped from functions. +6. Determine variable name: substitute `${rname}`, `${capture}` with cleaned values +7. Emit via `onCapture` callback ### Token Association -1. `msg.selected[0]` — if a token is selected, store on it -2. Character lookup — if `char_field` matches a character name, find tokens representing that character -3. Scope-aware ambiguity: - - If only `storage: attribute` rules use this field → store on character (no token needed) - - If `storage: gmnotes` rules exist AND multiple tokens represent same character → prompt GM -4. Queue unresolved captures — whisper GM with clickable assignment buttons +1. Character lookup — if `char_field` matches a character name, find tokens representing that character on the current page +2. If multiple tokens represent same character → prompt GM or let consumer decide +3. Queue unresolved captures — whisper GM with clickable assignment buttons ### Name Cleaning @@ -109,7 +107,7 @@ Lines after `when:` or `default:` are treated as captures until the next `when:` - Lowercase - Result: `stealth` -So `gl_${rname}` → `gl_stealth` +So `gl_${rname}_${capture}` → `gl_stealth_attack` ### API @@ -117,14 +115,15 @@ So `gl_${rname}` → `gl_stealth` RollCapture.getCapturedValue(tokenId, fieldName) // read latest captured value RollCapture.getLastCapture() // most recent capture result RollCapture.registerRule(ruleObj) // programmatic rule registration +RollCapture.onCapture(pattern, callback) // register consumer callback ``` ### Events / Integration -After storing a value: -- If `storage: gmnotes` → manually fire `change:graphic:gmnotes` won't work (API set doesn't trigger) -- Instead: expose a callback/hook that other scripts register for: `RollCapture.onCapture(fieldName, callback)` +After capturing a value: +- Expose a callback/hook that other scripts register for: `RollCapture.onCapture('gl_*', callback)` - Gaslight registers: `RollCapture.onCapture('gl_*', evaluateTriggeredPins)` +- Consumer decides storage (gmnotes, attributes, or both) ### Dependencies @@ -148,12 +147,36 @@ None required. Optional integration with: template: npc, simple name_field: rname char_field: name, charname -value: r1=0, r2=1 -when: {{advantage=1}} => max(r1, r2) -when: {{disadvantage=1}} => min(r1, r2) -when: {{always=1}} => choose(r1, r2) -default: r1 -variable: gl_${rname} +when: {{advantage=1}} +result: max(r1, r2) +when: {{disadvantage=1}} +result: min(r1, r2) +when: {{always=1}} +result: choose(r1, r2) +default: +result: r1 +variable: gl_${rname}_${capture} +``` + +### D&D 5E Attack + Damage +``` +---ROLLCAPTURE--- +template: atkdmg +name_field: rname +char_field: charname +when: {{always=1}} +attack: choose(r1, r2) +damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) +when: {{advantage=1}} +attack: max(r1, r2) +damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) +when: {{disadvantage=1}} +attack: min(r1, r2) +damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) +default: +attack: r1 +damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) +variable: gl_${rname}_${capture} ``` ### Savage Worlds @@ -162,10 +185,9 @@ variable: gl_${rname} template: roll name_field: trait char_field: name -value: skill=0, wild=1 -when: {{wildcard=1}} => max(skill, wild) -default: skill -variable: gl_${trait} +default: +result: max(skill_roll, wild_die) +variable: gl_${trait}_${capture} ``` ### Shadow of the Demon Lord @@ -174,9 +196,9 @@ variable: gl_${trait} template: sotdl name_field: roll-label char_field: name, title -value: roll=0 -default: roll -variable: gl_${roll-label} +default: +result: roll +variable: gl_${roll-label}_${capture} ``` ### Warhammer Fantasy Roleplay 4E @@ -185,9 +207,9 @@ variable: gl_${roll-label} template: wfrp name_field: roll_name char_field: name -value: roll=0 -default: roll -variable: gl_${roll_name} +default: +result: roll +variable: gl_${roll_name}_${capture} ``` ### Call of Cthulhu 7E @@ -196,7 +218,7 @@ variable: gl_${roll_name} template: coc-dice-roll name_field: name char_field: name -value: roll=0 -default: roll -variable: gl_${name} +default: +result: roll +variable: gl_${name}_${capture} ``` From ed4939b81ef1502050fbfcb3a10fd571251344f2 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 23 Jun 2026 07:03:11 -0400 Subject: [PATCH 37/96] =?UTF-8?q?RollCapture=20v0.1.0:=20working=20scaffol?= =?UTF-8?q?d=20=E2=80=94=20rule=20parsing,=20template=20matching,=20field?= =?UTF-8?q?=20resolution,=20formula=20eval,=20choose(),=20onCapture=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/RollCapture.js | 343 +++++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 RollCapture/RollCapture.js diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js new file mode 100644 index 0000000000..4625f8d1df --- /dev/null +++ b/RollCapture/RollCapture.js @@ -0,0 +1,343 @@ +// RollCapture v0.1.0 — Generic roll result extraction/storage for Roll20 +// Detects rolls via chat:message, extracts values per configurable rules, +// and emits captured data to registered consumer callbacks. + +const RollCapture = (() => { // eslint-disable-line no-unused-vars + 'use strict'; + + const VERSION = '0.1.0'; + const HANDOUT_MARKER = '---ROLLCAPTURE---'; + const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'variable:', 'when:', 'default:']; + const CMD = '!rollcapture'; + + let rules = []; + let callbacks = []; + let pendingChoices = {}; // id → { captures, resolve info } + + // ─── Rule Parser ──────────────────────────────────────────────────────────── + + const parseRules = (text) => { + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && l !== HANDOUT_MARKER); + const rule = { templates: [], nameField: '', charFields: [], variable: '', blocks: [] }; + let currentBlock = null; + + for (const line of lines) { + if (line.startsWith('template:')) { + rule.templates = line.slice(9).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('name_field:')) { + rule.nameField = line.slice(11).trim(); + } else if (line.startsWith('char_field:')) { + rule.charFields = line.slice(11).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('variable:')) { + rule.variable = line.slice(9).trim(); + } else if (line.startsWith('when:')) { + currentBlock = { condition: line.slice(5).trim(), captures: {} }; + rule.blocks.push(currentBlock); + } else if (line.startsWith('default:')) { + currentBlock = { condition: null, captures: {} }; + rule.blocks.push(currentBlock); + } else if (currentBlock) { + // capture line: name: formula + const sep = line.indexOf(':'); + if (sep > 0) { + const name = line.slice(0, sep).trim(); + const formula = line.slice(sep + 1).trim(); + if (!KEYWORDS.some(k => line.startsWith(k))) { + currentBlock.captures[name] = formula; // empty string = clear + } + } + } + } + return rule; + }; + + // ─── Handout Loading ──────────────────────────────────────────────────────── + + const loadRulesFromHandouts = () => { + rules = []; + const handouts = findObjs({ type: 'handout' }).filter(h => + h.get('name').includes('[RollCapture]') || h.get('name').includes('[RC]') + ); + let loaded = 0; + handouts.forEach(h => { + h.get('notes', (notes) => { + if (!notes) return; + const text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + if (text.includes(HANDOUT_MARKER)) { + const rule = parseRules(text); + if (rule.templates.length) { + rules.push(rule); + loaded++; + } + } + }); + }); + setTimeout(() => log(`RollCapture: loaded ${loaded} rule(s) from ${handouts.length} handout(s)`), 500); + }; + + // ─── Field Resolution ─────────────────────────────────────────────────────── + + const buildFieldMap = (content, inlinerolls) => { + // Map field names to their inline roll totals + // {{r1=$[[2]]}} → fieldMap.r1 = inlinerolls[2].results.total + const map = {}; + const re = /\{\{(\w[\w-]*)=\$\[\[(\d+)\]\]\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + const idx = parseInt(m[2], 10); + if (inlinerolls[idx] && inlinerolls[idx].results) { + map[m[1]] = inlinerolls[idx].results.total; + } + } + return map; + }; + + const buildFlagMap = (content) => { + // Map fields with non-roll values: {{normal=1}}, {{attack=1}}, etc. + const map = {}; + const re = /\{\{(\w[\w-]*)=([^$}][^}]*)\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + map[m[1]] = m[2]; + } + // Also match empty fields: {{range=}} + const reEmpty = /\{\{(\w[\w-]*)=\}\}/g; + while ((m = reEmpty.exec(content)) !== null) { + map[m[1]] = ''; + } + return map; + }; + + // ─── Formula Evaluation ───────────────────────────────────────────────────── + + const evalFormula = (formula, fieldMap) => { + if (!formula) return undefined; // empty = clear + + // Function call: max(...), min(...), sum(...), choose(...) + const funcMatch = formula.match(/^(max|min|sum|choose)\((.+)\)$/); + if (funcMatch) { + const fn = funcMatch[1]; + const args = funcMatch[2].split(',').map(a => a.trim()); + const resolved = args.map(a => fieldMap[a]).filter(v => v !== undefined); + + if (resolved.length === 0) return undefined; + + switch (fn) { + case 'max': return Math.max(...resolved); + case 'min': return Math.min(...resolved); + case 'sum': return resolved.reduce((a, b) => a + b, 0); + case 'choose': return { __choose: true, options: args.filter(a => fieldMap[a] !== undefined).map(a => ({ name: a, value: fieldMap[a] })) }; + } + } + + // Direct field reference + return fieldMap[formula] !== undefined ? fieldMap[formula] : undefined; + }; + + // ─── Condition Matching ───────────────────────────────────────────────────── + + const matchCondition = (condition, content) => { + // condition is something like "{{advantage=1}}" + return content.includes(condition); + }; + + // ─── Name Cleaning ────────────────────────────────────────────────────────── + + const cleanName = (raw) => { + if (!raw) return ''; + return raw + .replace(/^\^{/, '').replace(/}$/, '') // strip ^{ } + .replace(/-u$/, '') // strip -u suffix + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); // sanitize + }; + + // ─── Variable Name Building ───────────────────────────────────────────────── + + const buildVarName = (pattern, rname, captureName) => { + return pattern + .replace(/\$\{rname\}/g, cleanName(rname)) + .replace(/\$\{name_field\}/g, cleanName(rname)) + .replace(/\$\{capture\}/g, captureName); + }; + + // ─── Choose Prompt ────────────────────────────────────────────────────────── + + const promptChoose = (context, captureName, options) => { + const id = generateUUID(); + pendingChoices[id] = context; + const buttons = options.map(o => + `[${o.name}: ${o.value}](${CMD} --choose ${id} ${captureName} ${o.value})` + ).join(' '); + whisper(`**${context.charName} — ${context.rollName}** (${captureName}): ${buttons}`); + }; + + // ─── Core Processing ──────────────────────────────────────────────────────── + + const processMessage = (msg) => { + if (!msg.rolltemplate || !msg.inlinerolls) return; + + const template = msg.rolltemplate; + const content = msg.content; + const inlinerolls = msg.inlinerolls; + + for (const rule of rules) { + if (!rule.templates.includes(template)) continue; + + const fieldMap = buildFieldMap(content, inlinerolls); + const flagMap = buildFlagMap(content); + + // Resolve roll name + const rollName = flagMap[rule.nameField] || fieldMap[rule.nameField] || ''; + + // Resolve character name + let charName = ''; + for (const cf of rule.charFields) { + if (flagMap[cf]) { charName = flagMap[cf]; break; } + } + // Also check bare charname= at end of content + if (!charName) { + const bareMatch = content.match(/charname=(.+?)(?:\s*"|\s*$)/); + if (bareMatch) charName = bareMatch[1].replace(/\\"/g, '"').trim(); + } + + // Find matching block + let activeBlock = null; + for (const block of rule.blocks) { + if (block.condition === null) continue; // skip default for now + if (matchCondition(block.condition, content)) { + activeBlock = block; + break; + } + } + // Fallback to default + if (!activeBlock) { + activeBlock = rule.blocks.find(b => b.condition === null); + } + if (!activeBlock) continue; + + // Process captures + const results = {}; + let hasChoose = false; + + for (const [captureName, formula] of Object.entries(activeBlock.captures)) { + const value = evalFormula(formula, fieldMap); + if (value && value.__choose) { + hasChoose = true; + const context = { rule, rollName: cleanName(rollName), charName, playerId: msg.playerid, results }; + promptChoose(context, captureName, value.options); + } else { + const varName = buildVarName(rule.variable, rollName, captureName); + results[varName] = value; // undefined = clear + } + } + + if (!hasChoose) { + emitCapture(charName, cleanName(rollName), results, msg.playerid); + } + } + }; + + // ─── Callback Registry ────────────────────────────────────────────────────── + + const emitCapture = (charName, rollName, captures, playerId) => { + const event = { charName, rollName, captures, playerId }; + for (const { pattern, fn } of callbacks) { + if (pattern === '*' || Object.keys(captures).some(k => matchPattern(pattern, k))) { + fn(event); + } + } + }; + + const matchPattern = (pattern, key) => { + if (pattern === '*') return true; + if (pattern.endsWith('*')) return key.startsWith(pattern.slice(0, -1)); + return pattern === key; + }; + + const onCapture = (pattern, fn) => { + callbacks.push({ pattern, fn }); + }; + + // ─── Command Handling ─────────────────────────────────────────────────────── + + const handleCommand = (msg) => { + const args = msg.content.split(/\s+/); + args.shift(); // remove !rollcapture + + if (args[0] === '--choose') { + const [, id, captureName, value] = args; + const ctx = pendingChoices[id]; + if (!ctx) return whisper('Choice expired or invalid.'); + const varName = buildVarName(ctx.rule.variable, ctx.rollName, captureName); + ctx.results[varName] = parseInt(value, 10) || 0; + delete pendingChoices[id]; + emitCapture(ctx.charName, ctx.rollName, ctx.results, ctx.playerId); + whisper(`Captured ${captureName} = ${value}`); + return; + } + + if (args[0] === 'reload') { + loadRulesFromHandouts(); + whisper('Rules reloaded.'); + return; + } + + if (args[0] === 'status') { + whisper(`**RollCapture v${VERSION}**\nRules: ${rules.length}\nCallbacks: ${callbacks.length}\nPending choices: ${Object.keys(pendingChoices).length}`); + return; + } + + if (args[0] === 'rules') { + if (!rules.length) return whisper('No rules loaded.'); + const list = rules.map((r, i) => `${i + 1}. templates: ${r.templates.join(', ')} | var: ${r.variable}`).join('\n'); + whisper(`**Loaded Rules:**\n${list}`); + return; + } + + whisper(`**RollCapture v${VERSION}** — Commands:\n` + + `\`!rollcapture status\` — Show status\n` + + `\`!rollcapture rules\` — List loaded rules\n` + + `\`!rollcapture reload\` — Reload rules from handouts`); + }; + + // ─── Utilities ────────────────────────────────────────────────────────────── + + const whisper = (msg) => sendChat('RollCapture', `/w gm ${msg}`); + + const generateUUID = () => { + return 'rc_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); + }; + + // ─── Public API ───────────────────────────────────────────────────────────── + + const registerEventHandlers = () => { + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content.startsWith(CMD)) { + handleCommand(msg); + } else if (msg.rolltemplate) { + processMessage(msg); + } + }); + }; + + on('ready', () => { + loadRulesFromHandouts(); + registerEventHandlers(); + log(`RollCapture v${VERSION} loaded.`); + }); + + return { + onCapture, + getCapturedValue: () => null, // placeholder — consumers store their own way + getLastCapture: () => null, // placeholder + registerRule: (ruleObj) => rules.push(ruleObj), + }; +})(); From 3b3e4bad48cc39147c8e30e32fb283eb5c68e8fb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 23 Jun 2026 07:03:59 -0400 Subject: [PATCH 38/96] RollCapture: remove diagnostic script --- RollCapture/diagnostic.js | 42 --------------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 RollCapture/diagnostic.js diff --git a/RollCapture/diagnostic.js b/RollCapture/diagnostic.js deleted file mode 100644 index 100f315322..0000000000 --- a/RollCapture/diagnostic.js +++ /dev/null @@ -1,42 +0,0 @@ -// RollCapture Diagnostic — paste this into your Roll20 API sandbox -// Logs EVERYTHING about any roll template message to the console -// Remove when done testing - -on('chat:message', function(msg) { - if (!msg.rolltemplate) return; - - log('=== ROLL DIAGNOSTIC ==='); - log(' type: ' + msg.type); - log(' who: ' + msg.who); - log(' playerid: ' + msg.playerid); - log(' rolltemplate: ' + msg.rolltemplate); - log(' content: ' + msg.content); - log(' selected: ' + JSON.stringify(msg.selected || null)); - - if (msg.inlinerolls) { - log(' inlinerolls (' + msg.inlinerolls.length + '):'); - msg.inlinerolls.forEach(function(roll, i) { - log(' [' + i + ']:'); - log(' expression: ' + roll.expression); - log(' total: ' + (roll.results ? roll.results.total : 'N/A')); - log(' type: ' + (roll.results ? roll.results.type : 'N/A')); - if (roll.results && roll.results.rolls) { - roll.results.rolls.forEach(function(r, j) { - if (r.type === 'R' && r.results) { - log(' roll[' + j + ']: ' + r.dice + 'd' + r.sides + ' → [' + - r.results.map(function(d) { - return d.v + (d.d ? '(dropped)' : ''); - }).join(', ') + ']'); - } else if (r.type === 'M') { - log(' mod[' + j + ']: ' + r.expr); - } else { - log(' [' + j + ']: type=' + r.type + ' ' + JSON.stringify(r).slice(0, 100)); - } - }); - } - }); - } - - log(' --- raw msg keys: ' + Object.keys(msg).join(', ')); - log('=== END DIAGNOSTIC ==='); -}); From 00c141d354a53a04c8be8486aedb65c6bb3fde47 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Tue, 23 Jun 2026 07:11:44 -0400 Subject: [PATCH 39/96] RollCapture: auto-resolve choose() when all values are equal --- RollCapture/RollCapture.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 4625f8d1df..2947fb0686 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -133,7 +133,11 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars case 'max': return Math.max(...resolved); case 'min': return Math.min(...resolved); case 'sum': return resolved.reduce((a, b) => a + b, 0); - case 'choose': return { __choose: true, options: args.filter(a => fieldMap[a] !== undefined).map(a => ({ name: a, value: fieldMap[a] })) }; + case 'choose': { + const unique = [...new Set(resolved)]; + if (unique.length === 1) return unique[0]; + return { __choose: true, options: args.filter(a => fieldMap[a] !== undefined).map(a => ({ name: a, value: fieldMap[a] })) }; + } } } From 74ac625ad6ff073c9b190240372faf61475ae1d4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 08:31:31 -0400 Subject: [PATCH 40/96] =?UTF-8?q?Gaslight=20v1.1.0:=20unified=20relay=20?= =?UTF-8?q?=E2=80=94=20universal=20auto-relay,=20ID=20scanning,=20relaying?= =?UTF-8?q?=20set=20loop=20prevention,=20remove=20=5Flastpage=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 280 +++++++++++++++++-------------------------- Gaslight/TODO.md | 3 +- Gaslight/script.json | 4 +- 3 files changed, 110 insertions(+), 177 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 94089efa96..62c630c3b1 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -28,11 +28,13 @@ var Gaslight = Gaslight || (() => { 'use strict'; const SCRIPT_NAME = 'Gaslight'; - const SCRIPT_VERSION = '1.0.0'; + const SCRIPT_VERSION = '1.1.0'; const CMD = '!gaslight'; const CONFIG_HEADER = '---GASLIGHT---'; const LINK_KEY = 'gaslight_link'; + var relaying = new Set(); + // ========================================================================= // Helpers // ========================================================================= @@ -1223,163 +1225,59 @@ var Gaslight = Gaslight || (() => { }; /** - * Shared relay execution: sends command to linked tokens on target pages. - * Returns number of tokens relayed to. - */ - /** - * Find all Roll20 IDs (starting with -) in a command string that match linked tokens. - * Returns { found: [{id, linkedIds}], hasIds: bool } - */ - const findLinkedIdsInCommand = (command, activeGroups) => { - var idRx = /-[A-Za-z0-9_-]{19}/g; - var matches = command.match(idRx) || []; - var found = []; - matches.forEach(function(id) { - var linkedIds = []; - Object.values(activeGroups).forEach(function(active) { - var allLinked = active.linkedTokens[id] || []; - Object.entries(active.linkedTokens).forEach(function(entry) { - if (entry[1].indexOf(id) !== -1) { - allLinked = allLinked.concat([entry[0]]).concat(entry[1]); - } - }); - allLinked = allLinked.filter(function(lid, i) { return allLinked.indexOf(lid) === i && lid !== id; }); - linkedIds = linkedIds.concat(allLinked); - }); - if (linkedIds.length > 0) found.push({ id: id, linkedIds: linkedIds }); - }); - return { found: found, hasIds: found.length > 0 }; - }; - - /** - * Path 2: Replace token IDs in command with linked counterparts per target page, emit immediately. - */ - const relayByIdReplacement = (sender, command, activeGroups, targetPlayerIds) => { - var idInfo = findLinkedIdsInCommand(command, activeGroups); - if (!idInfo.hasIds) return 0; - - var relayed = 0; - targetPlayerIds.forEach(function(playerId) { - var newCmd = command; - idInfo.found.forEach(function(entry) { - // Find the linked token that's on this player's page - var targetId = null; - Object.values(activeGroups).forEach(function(active) { - if (targetId) return; - var playerPage = active.playerPages[playerId]; - if (!playerPage) return; - entry.linkedIds.forEach(function(lid) { - if (targetId) return; - var obj = getObj('graphic', lid); - if (obj && obj.get('_pageid') === playerPage.pageId) targetId = lid; - }); - }); - if (targetId) newCmd = newCmd.replace(entry.id, targetId); - }); - if (newCmd !== command) { - sendChat(sender, newCmd); - relayed++; - } - }); - return relayed; - }; - - /** - * Path 1: Queue commands for execution when GM visits the target page. + * Relay execution: replaces token IDs in command with linked counterparts per + * target page and appends {& select} for SelectManager cross-page targeting. */ - const queueRelay = (sender, tokens, command, targetPlayerIds) => { + const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { var s = state[SCRIPT_NAME]; - if (!s.relayQueue) s.relayQueue = {}; + var relayed = 0; var tokenIds = tokens.map(function(t) { return t.get('id'); }); - var newlyQueued = 0; + + if (includeMaster) { + relaying.add(command); + sendChat(sender, command + ' {& select ' + tokenIds.join(', ') + '}'); + relayed += tokenIds.length; + } targetPlayerIds.forEach(function(playerId) { - // Find the linked token IDs for this player page var linkedIds = []; + var newCmd = command; + Object.values(s.activeGroups).forEach(function(active) { var playerPage = active.playerPages[playerId]; if (!playerPage) return; + tokenIds.forEach(function(tokenId) { - var allLinked = active.linkedTokens[tokenId] || []; + // Find all linked counterparts + var allLinked = (active.linkedTokens[tokenId] || []).slice(); Object.entries(active.linkedTokens).forEach(function(entry) { - if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + if (entry[1].indexOf(tokenId) !== -1) { + allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + } }); - allLinked.filter(function(id) { + // Filter to ones on this player's page + var onPage = allLinked.filter(function(id, i, arr) { + if (arr.indexOf(id) !== i || id === tokenId) return false; var obj = getObj('graphic', id); return obj && obj.get('_pageid') === playerPage.pageId; - }).forEach(function(id) { + }); + onPage.forEach(function(id) { + newCmd = newCmd.split(tokenId).join(id); if (linkedIds.indexOf(id) === -1) linkedIds.push(id); }); }); }); if (linkedIds.length > 0) { - // Queue for when GM visits the page - var pageId = null; - Object.values(s.activeGroups).forEach(function(active) { - var pp = active.playerPages[playerId]; - if (pp) pageId = pp.pageId; - }); - if (pageId) { - if (!s.relayQueue[pageId]) s.relayQueue[pageId] = []; - s.relayQueue[pageId].push({ sender: sender, command: command, selectIds: linkedIds }); - newlyQueued++; - } + relaying.add(newCmd); + sendChat(sender, newCmd + ' {& select ' + linkedIds.join(', ') + '}'); + relayed += linkedIds.length; } }); - if (newlyQueued > 0) { - var totalPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }).length; - sendChat(SCRIPT_NAME, '/w gm Queued for ' + newlyQueued + ' page(s). Total pending: ' + totalPages + '. Navigate to player pages to execute.'); - } - }; - - const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { - var s = state[SCRIPT_NAME]; - var relayed = 0; - - if (includeMaster) { - var masterIds = tokens.map(function(t) { return t.get('id'); }); - sendChat(sender, command + ' {& select ' + masterIds.join(', ') + '}'); - relayed += masterIds.length; - } - - if (targetPlayerIds.length > 0) { - // Path 2: try ID replacement first (works cross-page) - var idRelayed = relayByIdReplacement(sender, command, s.activeGroups, targetPlayerIds); - if (idRelayed > 0) { - relayed += idRelayed; - } else { - // Path 1: queue for when GM visits page (selection-based) - queueRelay(sender, tokens, command, targetPlayerIds); - } - } - return relayed; }; - /** - * Poll _lastpage to fire queued relay commands when GM arrives on a target page. - */ - const pollRelayQueue = () => { - var s = state[SCRIPT_NAME]; - if (!s.relayQueue) return; - - var gmPlayers = findObjs({ _type: 'player' }).filter(function(p) { return playerIsGM(p.get('_id')); }); - gmPlayers.forEach(function(gm) { - var lastPage = gm.get('_lastpage'); - if (!lastPage) return; - var queue = s.relayQueue[lastPage]; - if (!queue || queue.length === 0) return; - - // Fire all queued commands for this page - queue.forEach(function(entry) { - sendChat(entry.sender, entry.command + ' {& select ' + entry.selectIds.join(', ') + '}'); - }); - delete s.relayQueue[lastPage]; - }); - }; - /** * Stage selected tokens: duplicate to player pages and link. * !gaslight stage [playerName1 playerName2 ...] @@ -1678,15 +1576,6 @@ var Gaslight = Gaslight || (() => { case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; - case 'test-relay': { - // Temporary: test sendChat with {& select} - var testId = args[0] || ''; - if (!testId) { reply(msg, 'Error', 'Provide a token ID'); break; } - var testCmd = '!token-mod --set bar1_value|42 {& select ' + testId + '}'; - log(SCRIPT_NAME + ': test-relay sending: ' + testCmd); - sendChat(getPlayerName(msg.playerid), testCmd); - break; - } case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; @@ -1750,53 +1639,99 @@ var Gaslight = Gaslight || (() => { }; /** - * In any active view mode, intercept non-gaslight API commands and re-emit - * with linked player tokens as selection via SelectManager. - * Master view: relay to ALL player pages. - * Player view: relay to that player's page only. + * Universal relay interceptor. Automatically relays commands to linked tokens: + * - If selected tokens or IDs in command reference master-page linked tokens + * AND no player-page tokens are selected/referenced → relay to all player pages. + * - If player-page tokens are involved → only relay if command is in relayCommands. */ const viewInterceptor = (msg) => { if (msg.type !== 'api') return; var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; - var firstWord = msg.content.split(' ')[0]; + var content = msg.content.trim(); + if (!content) return; + var firstWord = content.split(' ')[0]; if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; - if (!msg.selected || msg.selected.length === 0) return; - if (msg.content.indexOf('{& select') !== -1) return; - var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); - if (tokens.length === 0) return; + // Check relaying set to prevent loops + if (relaying.has(content)) { relaying.delete(content); return; } + if (content.indexOf('{& select') !== -1) return; - var pageId = tokens[0].get('_pageid'); - var isGM = playerIsGM(msg.playerid); + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + + // Scan command for token IDs that belong to linked groups + var idRx = /-[A-Za-z0-9_-]{19}/g; + var idsInCommand = (content.match(idRx) || []).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); + + // Classify: which IDs/tokens are on master pages vs player pages? + var masterTokens = []; + var hasPlayerPageRef = false; + var activeEntry = null; + + // Check selected tokens + tokens.forEach(function(t) { + var pid = t.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + masterTokens.push(t); + if (!activeEntry) activeEntry = entry; + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + // Check IDs in command text + idsInCommand.forEach(function(id) { + // Skip IDs already accounted for by selection + if (tokens.some(function(t) { return t.get('id') === id; })) return; + var obj = getObj('graphic', id); + if (!obj) return; + var pid = obj.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + // Check if this token is actually linked + var linked = entry[1].linkedTokens[id] || []; + var isLinked = linked.length > 0 || Object.values(entry[1].linkedTokens).some(function(arr) { return arr.indexOf(id) !== -1; }); + if (isLinked) { + masterTokens.push(obj); + if (!activeEntry) activeEntry = entry; + } + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); - // Case 1: GM on master page — relay based on view - if (isGM) { - var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); - if (!activeEntry) return; + if (masterTokens.length === 0 && !hasPlayerPageRef) return; + // Universal relay: master-page refs, no player-page refs + if (masterTokens.length > 0 && !hasPlayerPageRef) { var viewPlayerId = s.view; var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); - executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); + executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); return; } - // Case 2: Player on their page — relay if command is in relay-commands list - if (s.config.relayCommands.indexOf(firstWord) === -1) return; - - // Find which group/player this page belongs to - var activeEntry = null; - var sourcePlayerId = null; - Object.entries(s.activeGroups).forEach(function(e) { - Object.entries(e[1].playerPages).forEach(function(pp) { - if (pp[1].pageId === pageId) { activeEntry = e; sourcePlayerId = pp[0]; } + // Player-page involved: only relay if relayCommands allows it + if (hasPlayerPageRef && s.config.relayCommands.indexOf(firstWord) !== -1) { + // Find source player page + var sourcePlayerId = null; + var entry = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + var srcToken = tokens.find(function(t) { return t.get('_pageid') === pp[1].pageId; }); + if (srcToken) { entry = e; sourcePlayerId = pp[0]; } + }); }); - }); - if (!activeEntry) return; - - // Relay to all OTHER player pages + master - var targetPlayerIds = Object.keys(activeEntry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); - executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, true); + if (!entry) return; + var targetPlayerIds = Object.keys(entry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, content, targetPlayerIds, true); + } }; const registerEventHandlers = () => { @@ -1804,7 +1739,6 @@ var Gaslight = Gaslight || (() => { on('chat:message', viewInterceptor); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); - setInterval(pollRelayQueue, 500); }; return { checkInstall, registerEventHandlers }; diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 2f90899c60..857070270c 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -48,6 +48,5 @@ - [ ] On-demand page cloning (if TruePageCopy exposes API) ## Known Issues -- Relay Path 1 (selection-based) requires GM to navigate to target page -- Roll20 limitation: sendChat as player carries their UI selection state +- SelectManager `{& select}` text may visibly bleed into chat even though selection works correctly (upstream issue, reported to timmaugh) - linkedTokens accumulates duplicates on repeated splits (cosmetic, deduped at use) diff --git a/Gaslight/script.json b/Gaslight/script.json index bf0a45dfef..8e72d230ee 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -1,8 +1,8 @@ { "name": "Gaslight", "script": "Gaslight.js", - "version": "1.0.0", - "previousversions": [], + "version": "1.1.0", + "previousversions": ["1.0.0"], "description": "Per-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", From b16fe8d7166d426e8609a95b8e370808a55d4844 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 08:51:17 -0400 Subject: [PATCH 41/96] Gaslight v1.1.0: unified relay with loop prevention, versioned folder --- Gaslight/1.1.0/Gaslight.js | 1755 ++++++++++++++++++++++++++++++++++++ Gaslight/Gaslight.js | 10 +- Gaslight/README.md | 13 +- 3 files changed, 1771 insertions(+), 7 deletions(-) create mode 100644 Gaslight/1.1.0/Gaslight.js diff --git a/Gaslight/1.1.0/Gaslight.js b/Gaslight/1.1.0/Gaslight.js new file mode 100644 index 0000000000..f13587a4e6 --- /dev/null +++ b/Gaslight/1.1.0/Gaslight.js @@ -0,0 +1,1755 @@ +// ============================================================================= +// Gaslight v1.0.0 +// Last Updated: 2026-06-14 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor. Each player can see different things +// while token movement stays consistent across all copies. +// +// Dependencies: Anchor +// +// Commands: +// !gaslight split Activate a prepared gaslight group +// !gaslight merge [group] Tear down links, return players +// !gaslight test Dry-run linking resolution +// !gaslight link [|new] [ids...] Set gaslight_link on tokens +// !gaslight unlink [ids...] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight master Designate page as group master +// !gaslight status Show current state +// !gaslight --help Command reference +// ============================================================================= + +/* global on, sendChat, getObj, findObjs, createObj, Campaign, playerIsGM, log, state, generateUUID */ + +var Gaslight = Gaslight || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Gaslight'; + const SCRIPT_VERSION = '1.1.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + + var relaying = new Set(); + + const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body); + }; + + const genId = () => { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + }; + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + activeGroups: {}, + config: { autoCommit: false, relayCommands: [] }, + view: null + }; + } + if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + }; + + // ========================================================================= + // Config Storage — GM layer text objects + // ========================================================================= + + const getConfigsOnPage = (pageId) => { + const texts = findObjs({ _type: 'text', _pageid: pageId, layer: 'gmlayer' }); + const configs = []; + texts.forEach(t => { + const content = t.get('text') || ''; + if (!content.startsWith(CONFIG_HEADER)) return; + const data = parseConfig(content); + if (data) configs.push({ obj: t, data: data }); + }); + return configs; + }; + + const getGroupConfigOnPage = (pageId, groupName) => { + return getConfigsOnPage(pageId).find(c => c.data.group === groupName); + }; + + const parseConfig = (text) => { + const lines = text.split('\n').filter(l => l.trim() && l.trim() !== CONFIG_HEADER); + const data = {}; + lines.forEach(line => { + const idx = line.indexOf(':'); + if (idx === -1) return; + data[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, ''); + }); + return data.group ? data : null; + }; + + const serializeConfig = (data) => { + let text = CONFIG_HEADER + '\n'; + Object.entries(data).forEach(([key, val]) => { + if (val !== undefined && val !== '') text += key + ': ' + val + '\n'; + }); + return text.trim(); + }; + + const setConfigOnPage = (pageId, groupName, data) => { + const existing = getGroupConfigOnPage(pageId, groupName); + const fullData = Object.assign({ group: groupName }, data); + const text = serializeConfig(fullData); + if (existing) { + existing.obj.set('text', text); + } else { + createObj('text', { + pageid: pageId, + layer: 'gmlayer', + text: text, + left: 70, + top: 70, + font_size: 26, + font_family: 'Arial', + color: '#FFA500' + }); + } + }; + + // ========================================================================= + // Group Discovery + // ========================================================================= + + const discoverGroup = (groupName) => { + const pages = findObjs({ _type: 'page' }); + const result = { master: null, players: {} }; // players keyed by playerid → { pageId, name } + pages.forEach(page => { + const cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg) return; + if (cfg.data.player === 'GM') result.master = page.get('_id'); + else if (cfg.data.playerid) { + result.players[cfg.data.playerid] = { pageId: page.get('_id'), name: cfg.data.player }; + } + }); + return result; + }; + + // ========================================================================= + // Page Resolution + // ========================================================================= + + const resolvePageId = (msg, args) => { + // Check for --page argument + const pageIdx = args.indexOf('--page'); + if (pageIdx !== -1 && args[pageIdx + 1]) { + const pageName = args.splice(pageIdx, 2)[1]; + const page = findObjs({ _type: 'page', name: pageName })[0]; + if (page) return page.get('_id'); + } + // Fall back to selected token's page + if (msg.selected && msg.selected.length > 0) { + const obj = getObj(msg.selected[0]._type, msg.selected[0]._id); + if (obj) return obj.get('_pageid'); + } + // Last resort: player page + return Campaign().get('playerpageid'); + }; + + // ========================================================================= + // Party Detection + // ========================================================================= + + const getPartyTokens = (msg, masterPageId) => { + if (msg.selected && msg.selected.length > 0) { + return msg.selected.map(s => getObj(s._type, s._id)).filter(Boolean); + } + const characters = findObjs({ _type: 'character' }); + const partyChars = characters.filter(c => { + const tags = c.get('tags') || ''; + return tags.toLowerCase().includes('party'); + }); + if (partyChars.length > 0) { + const tokens = []; + partyChars.forEach(c => { + const t = findObjs({ _type: 'graphic', represents: c.get('_id'), _pageid: masterPageId, _subtype: 'token' }); + tokens.push.apply(tokens, t); + }); + return tokens.length > 0 ? tokens : null; + } + return null; + }; + + // ========================================================================= + // Player Resolution + // ========================================================================= + + const GM_ALIASES = ['gm', 'master']; + + /** + * Resolve a player arg to { id, name } or null. + * If ambiguous, whispers disambiguation buttons and returns 'ambiguous'. + * If GM alias, returns { id: 'GM', name: 'GM' }. + */ + const resolvePlayer = (msg, playerArg, cmdPrefix) => { + if (GM_ALIASES.indexOf(playerArg.toLowerCase()) !== -1) { + return { id: 'GM', name: 'GM' }; + } + + // Check if it's a player ID directly (starts with -) + if (playerArg.startsWith('-')) { + var byId = getObj('player', playerArg); + if (byId) return { id: byId.get('_id'), name: byId.get('_displayname') }; + reply(msg, 'Error', 'No player found with ID: ' + playerArg); + return null; + } + + // Search by display name + var players = findObjs({ _type: 'player' }); + var matches = players.filter(function(p) { + return p.get('_displayname').toLowerCase() === playerArg.toLowerCase(); + }); + + // Deduplicate by player ID (Roll20 can return duplicate player objects) + var uniqueById = {}; + matches.forEach(function(p) { uniqueById[p.get('_id')] = p; }); + matches = Object.values(uniqueById); + + if (matches.length === 1) { + return { id: matches[0].get('_id'), name: matches[0].get('_displayname') }; + } + if (matches.length === 0) { + reply(msg, 'Error', 'No player found named "' + playerArg + '".'); + return null; + } + + // Ambiguous — show disambiguation buttons + var out = 'Multiple players named "' + playerArg + '":
'; + matches.forEach(function(p) { + var chars = findObjs({ _type: 'character' }).filter(function(c) { + return (c.get('controlledby') || '').indexOf(p.get('_id')) !== -1; + }); + var charNames = chars.map(function(c) { return c.get('name'); }).join(', ') || 'no characters'; + out += '[' + p.get('_displayname') + ' (' + charNames + ')](' + cmdPrefix + ' ' + p.get('_id') + ')
'; + }); + reply(msg, 'Disambiguate', out); + return 'ambiguous'; + }; + + /** + * Find a player by name or ID (no disambiguation, used internally). + */ + const findPlayerByNameOrId = (nameOrId) => { + if (nameOrId === 'GM') return null; + if (nameOrId.startsWith('-')) return getObj('player', nameOrId); + var players = findObjs({ _type: 'player' }); + return players.find(function(p) { return p.get('_displayname').toLowerCase() === nameOrId.toLowerCase(); }); + }; + + // ========================================================================= + // Token GM Notes — gaslight_link + // ========================================================================= + + const getLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } + const match = notes.match(/gaslight_link:\s*(.+)/); + return match ? match[1].trim() : null; + }; + + const setLinkId = (token, linkId) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.match(/gaslight_link:\s*.+/)) { + notes = notes.replace(/gaslight_link:\s*.+/, LINK_KEY + ': ' + linkId); + } else { + notes = (notes ? notes + '\n' : '') + LINK_KEY + ': ' + linkId; + } + token.set('gmnotes', notes); + }; + + const removeLinkId = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/\n?gaslight_link:\s*.+/, '').trim(); + token.set('gmnotes', notes); + }; + + /** + * Auto-populate gaslight_link from character attribute if token doesn't already have one. + */ + /** + * Find a matching token on another page by gaslight_link, represents+name, or represents alone. + */ + const findMatchingToken = (sourceToken, targetPageId) => { + // By gaslight_link + var linkId = getLinkId(sourceToken); + if (linkId) { + var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + var match = targets.find(function(t) { return getLinkId(t) === linkId; }); + if (match) return match; + } + // By represents + name + var charId = sourceToken.get('represents'); + if (charId) { + var name = sourceToken.get('name'); + var byName = findObjs({ _type: 'graphic', _pageid: targetPageId, represents: charId, _subtype: 'token' }); + if (name) byName = byName.filter(function(t) { return t.get('name') === name; }); + if (byName.length === 1) return byName[0]; + } + return null; + }; + + /** + * Stage a single token to target pages using 3-step logic. + * Returns number of clones created. + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPageIds.forEach(function(pageId) { + var targets = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPageIds; + } + + var cloned = 0; + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + var newToken = createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + if (newToken) setLinkId(newToken, getLinkId(token)); + cloned++; + }); + return cloned; + }; + + const autoPopulateLinkId = (token) => { + if (getLinkId(token)) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr && attr.get('current')) { + setLinkId(token, attr.get('current')); + } + }; + + /** + * Read the gaslight_sync character attribute. + * Returns: + * null — attribute absent (default: sync all non-spatial) + * '' — attribute present but empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (charId) => { + if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (!attr) return null; + var val = attr.get('current'); + if (val === undefined || val === null) return null; + val = val.trim(); + if (val === '') return ''; + // Parse comma-separated props, resolve groups + // Prefix with ! to exclude (e.g. "!anchor" = everything except anchor props) + var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var includes = []; + var excludes = []; + parts.forEach(function(p) { + var isExclude = p.startsWith('!'); + var name = isExclude ? p.slice(1) : p; + var expanded; + if (name === 'base' || name === 'anchor') { + expanded = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[name]) { + expanded = Mirror.PROP_GROUPS[name]; + } else { + expanded = [name]; + } + if (isExclude) excludes = excludes.concat(expanded); + else includes = includes.concat(expanded); + }); + // If only excludes specified, start from all known props and subtract + var resolved; + if (includes.length === 0 && excludes.length > 0) { + var allProps = typeof Mirror !== 'undefined' ? Mirror.getKnownProps() : + ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'statusmarkers', 'tint_color', 'name', 'light_radius', 'light_dimradius', 'baseOpacity', 'currentSide']; + resolved = allProps.filter(function(p) { return excludes.indexOf(p) === -1; }); + } else { + resolved = includes.filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return resolved.filter(function(p, i) { return resolved.indexOf(p) === i; }); // dedupe + }; + + // ========================================================================= + // Token Linking Resolution + // ========================================================================= + + /** + * Resolve links from sourcePageId to targetPageId. + * Returns array of { source, target, step } objects. + * Unmatched sources returned as { source, target: null, step: 'unlinked' }. + */ + const resolveLinks = (sourcePageId, targetPageId) => { + const sourceTokens = findObjs({ _type: 'graphic', _pageid: sourcePageId, _subtype: 'token' }); + const targetTokens = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + const results = []; + const matchedTargets = new Set(); + + // Step 1: gaslight_link in GM notes + sourceTokens.forEach(src => { + const linkId = getLinkId(src); + if (!linkId) return; + const match = targetTokens.find(t => !matchedTargets.has(t.get('id')) && getLinkId(t) === linkId); + if (match) { + results.push({ source: src, target: match, step: 1 }); + matchedTargets.add(match.get('id')); + } + }); + + const unmatchedSources = sourceTokens.filter(s => + !results.some(r => r.source.get('id') === s.get('id')) + ); + + // Step 2: represents + name + const step2Sources = unmatchedSources.filter(s => s.get('represents')); + step2Sources.forEach(src => { + const charId = src.get('represents'); + const name = src.get('name'); + // Check uniqueness on source page + const samePairOnSource = sourceTokens.filter(t => + t.get('represents') === charId && t.get('name') === name && + !results.some(r => r.source.get('id') === t.get('id')) + ); + if (samePairOnSource.length !== 1) return; // ambiguous on source page + + const candidates = targetTokens.filter(t => + !matchedTargets.has(t.get('id')) && + t.get('represents') === charId && t.get('name') === name + ); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 2 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 3: represents + fingerprint + const unmatchedAfter2 = unmatchedSources.filter(s => + s.get('represents') && !results.some(r => r.source.get('id') === s.get('id')) + ); + const FINGERPRINT_PROPS = ['represents', 'left', 'top', 'width', 'height', 'rotation', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max']; + + unmatchedAfter2.forEach(src => { + const srcFP = FINGERPRINT_PROPS.map(p => String(src.get(p))); + const candidates = targetTokens.filter(t => { + if (matchedTargets.has(t.get('id'))) return false; + const tFP = FINGERPRINT_PROPS.map(p => String(t.get(p))); + return srcFP.every((v, i) => v === tFP[i]); + }); + if (candidates.length === 1) { + results.push({ source: src, target: candidates[0], step: 3 }); + matchedTargets.add(candidates[0].get('id')); + } + }); + + // Step 4: unlinked — only master-page represents tokens + unmatchedSources.forEach(src => { + if (!results.some(r => r.source.get('id') === src.get('id'))) { + if (src.get('represents')) { + results.push({ source: src, target: null, step: 4 }); + } + } + }); + + return results; + }; + + /** + * Check for warning conditions across all pages in a group. + * Returns array of { message, severity } where severity is 'info'|'warning'|'error'. + */ + const checkWarnings = (groupInfo) => { + const warnings = []; + const allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + + // Collect all gaslight_link IDs and their page locations + const linkIdPages = {}; // linkId → Set of pageIds + const linkIdDupes = {}; // pageId → Set of linkIds that appear more than once + allPageIds.forEach(function(pid) { + var tokens = findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }); + var seenOnPage = {}; + tokens.forEach(function(t) { + var lid = getLinkId(t); + if (!lid) return; + if (!linkIdPages[lid]) linkIdPages[lid] = new Set(); + linkIdPages[lid].add(pid); + // Check for duplicates on same page + if (seenOnPage[lid]) { + if (!linkIdDupes[pid]) linkIdDupes[pid] = new Set(); + linkIdDupes[pid].add(lid); + } + seenOnPage[lid] = true; + }); + }); + + // Error: duplicate gaslight_link on same page + Object.entries(linkIdDupes).forEach(function(entry) { + var pid = entry[0], dupes = entry[1]; + var page = getObj('page', pid); + var pageName = page ? page.get('name') : pid; + dupes.forEach(function(lid) { + warnings.push({ message: 'Duplicate gaslight_link "' + lid + '" on page "' + pageName + '"', severity: 'error' }); + }); + }); + + // Info/Warning: gaslight_link missing from pages + Object.entries(linkIdPages).forEach(function(entry) { + var lid = entry[0], pages = entry[1]; + if (pages.size === 1) { + warnings.push({ message: 'gaslight_link "' + lid + '" exists on only 1 page (likely mistake)', severity: 'warning' }); + } else if (pages.size < allPageIds.length) { + warnings.push({ message: 'gaslight_link "' + lid + '" missing from some pages', severity: 'info' }); + } + }); + + return warnings; + }; + + const formatWarnings = (warnings) => { + if (warnings.length === 0) return ''; + var out = '
Warnings:
'; + warnings.forEach(function(w) { + var icon = w.severity === 'error' ? '🔴' : w.severity === 'warning' ? '🟡' : 'ℹ️'; + out += icon + ' ' + w.message + '
'; + }); + return out; + }; + + // ========================================================================= + // Anchor Integration + // ========================================================================= + + const countControllersInGroup = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return 0; + const character = getObj('character', charId); + if (!character) return 0; + const controlledBy = character.get('controlledby') || ''; + if (controlledBy === 'all') return Object.keys(groupInfo.players).length; + const controllerIds = controlledBy.split(',').filter(Boolean); + const groupPlayerIds = new Set(Object.keys(groupInfo.players)); + return controllerIds.filter(id => groupPlayerIds.has(id)).length; + }; + + const getControllingPlayerName = (token, groupInfo) => { + const charId = token.get('represents'); + if (!charId) return null; + const character = getObj('character', charId); + if (!character) return null; + const controlledBy = character.get('controlledby') || ''; + if (!controlledBy) return null; + if (controlledBy === 'all') { + // All players control it — return first group player as representative + var firstPlayer = Object.keys(groupInfo.players)[0]; + return firstPlayer || null; + } + const controllerIds = controlledBy.split(',').filter(Boolean); + for (var i = 0; i < controllerIds.length; i++) { + if (groupInfo.players[controllerIds[i]]) return controllerIds[i]; + } + return null; + }; + + const stripSight = (token) => { + token.set({ has_bright_light_vision: false, has_night_vision: false, light_hassight: false }); + }; + + /** + * Set up Anchor links based on resolved token pairs. + * Also writes gaslight_link IDs to token GM notes for any pair matched + * via steps 2-3, so re-split/restart will catch them via step 1. + */ + const establishLinks = (groupName, groupInfo, allLinks) => { + const s = state[SCRIPT_NAME]; + if (!s.activeGroups[groupName]) { + s.activeGroups[groupName] = { + masterPageId: groupInfo.master, + playerPages: groupInfo.players, + linkedTokens: {} + }; + } + const active = s.activeGroups[groupName]; + + if (typeof Anchor === 'undefined') { + log(SCRIPT_NAME + ': ERROR \u2014 Anchor not loaded. Cannot establish links.'); + return; + } + + // Group all link results by gaslight_link ID + var linkGroups = {}; // linkId -> { id: tokenObj } + allLinks.forEach(function(link) { + if (!link.target) return; + var src = link.source; + var tgt = link.target; + + // Ensure both have a gaslight_link ID + var existingId = getLinkId(src) || getLinkId(tgt); + var linkId = existingId || genId(); + if (!getLinkId(src)) setLinkId(src, linkId); + if (!getLinkId(tgt)) setLinkId(tgt, linkId); + + if (!linkGroups[linkId]) linkGroups[linkId] = {}; + linkGroups[linkId][src.get('id')] = src; + linkGroups[linkId][tgt.get('id')] = tgt; + }); + + // For each link group, determine anchoring strategy + Object.values(linkGroups).forEach(function(tokenMap) { + var tokens = Object.values(tokenMap); + if (tokens.length < 2) return; + + // Find all controlling player IDs in the group for this token + var controllerIds = []; + // Check the character's controlledby — use first token's character as representative + var repCharId = null; + for (var i = 0; i < tokens.length; i++) { + if (tokens[i].get('represents')) { repCharId = tokens[i].get('represents'); break; } + } + if (repCharId) { + var repChar = getObj('character', repCharId); + if (repChar) { + var cb = repChar.get('controlledby') || ''; + if (cb === 'all') { + controllerIds = Object.keys(groupInfo.players); + } else { + var cbIds = cb.split(',').filter(Boolean); + controllerIds = cbIds.filter(function(id) { return !!groupInfo.players[id]; }); + } + } + } + + var ids = tokens.map(function(t) { return t.get('id'); }); + + // Check gaslight_sync attribute + var syncProps = getGaslightSync(repCharId); + // syncProps: null = default (base spatial), '' = no sync at all, array = specific + + // If empty string, skip all linking for this group + if (syncProps === '') return; + + // Determine which props go to Anchor vs Mirror + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var needsAnchor = true; + var anchorComponents = null; // null = use Anchor defaults + var mirrorProps = null; // null = all non-anchor + if (Array.isArray(syncProps)) { + var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0; + // Pass specific components to Anchor if not the full default set + if (needsAnchor) { + anchorComponents = {}; + anchorRequested.forEach(function(p) { anchorComponents[p] = true; }); + } + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; + } + + // Set up Anchor links (spatial sync) + if (needsAnchor) { + if (controllerIds.length === 0) { + // NPC: master is parent, all others are children + var parent = tokens.find(function(t) { return t.get('_pageid') === groupInfo.master; }); + if (!parent) parent = tokens[0]; + tokens.forEach(function(t) { + if (t.get('id') === parent.get('id')) return; + Anchor.anchorObj(t.get('id'), parent.get('id'), anchorComponents); + }); + } else { + // Player-controlled: chain-link master + controlling players' pages + var chainPageIds = [groupInfo.master]; + controllerIds.forEach(function(pid) { + if (groupInfo.players[pid]) chainPageIds.push(groupInfo.players[pid].pageId); + }); + + var chainTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) !== -1; }); + var childTokens = tokens.filter(function(t) { return chainPageIds.indexOf(t.get('_pageid')) === -1; }); + + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds, anchorComponents); + } + + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id'), anchorComponents); + }); + } + } + } + + // Strip sight: only controlling players' pages keep sight + tokens.forEach(function(t) { + var pageId = t.get('_pageid'); + if (controllerIds.length > 0) { + // Keep sight only on pages belonging to controlling players + var isControllerPage = controllerIds.some(function(pid) { + return groupInfo.players[pid] && groupInfo.players[pid].pageId === pageId; + }); + if (!isControllerPage) stripSight(t); + } else { + // NPC: strip sight from children (not master) + if (pageId !== groupInfo.master) stripSight(t); + } + }); + + // Set up Mirror chain for non-spatial property sync + if (typeof Mirror !== 'undefined' && mirrorProps !== false) { + if (mirrorProps === null) { + // Default: sync all minus whatever Anchor is handling + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + Mirror.chainLink(ids, null, mirrorExcludes); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props + Mirror.chainLink(ids, mirrorProps); + } + } + + // Track links for merge teardown + ids.forEach(function(id) { + if (!active.linkedTokens[id]) active.linkedTokens[id] = []; + }); + ids.forEach(function(id) { + ids.forEach(function(otherId) { + if (id !== otherId) active.linkedTokens[id].push(otherId); + }); + }); + }); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + /** + * Quick setup: auto-configure a group from duplicate pages. + * !gaslight setup [--selected | player1 player2 ...] + * Expects N+1 pages with the same name (or name prefix). Assigns master + players. + */ + const doSetup = (msg, args) => { + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + var groupName = args.shift(); + + // Determine players: selected tokens + named args, fallback to party tags + var playerIds = []; + + // From selected tokens + if (msg.selected && msg.selected.length > 0) { + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (!obj) return; + var charId = obj.get('represents'); + if (!charId) return; + var character = getObj('character', charId); + if (!character) return; + var cb = character.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + // From named args + args.forEach(function(name) { + var resolved = resolvePlayer(msg, name, CMD + ' setup ' + groupName); + if (resolved && resolved !== 'ambiguous' && resolved.id !== 'GM') { + if (playerIds.indexOf(resolved.id) === -1) playerIds.push(resolved.id); + } + }); + + // Fallback: party-tagged characters (only if no selected and no args) + if (playerIds.length === 0) { + var characters = findObjs({ _type: 'character' }); + characters.forEach(function(c) { + var tags = c.get('tags') || ''; + if (!tags.toLowerCase().includes('party')) return; + var cb = c.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + if (playerIds.length === 0) { reply(msg, 'Error', 'No players found. Use --selected, provide names, or tag party characters.'); return; } + + // Find the master page (where selected token is, or current player page) + var masterPageId = resolvePageId(msg, []); + var masterPage = getObj('page', masterPageId); + if (!masterPage) { reply(msg, 'Error', 'Could not determine master page. Select a token on the master page.'); return; } + var masterName = masterPage.get('name'); + + // Find candidate pages: same base name (strip recursive "Copy of " prefixes), or already has this group's config + var allPages = findObjs({ _type: 'page' }); + var stripCopyOf = function(name) { + while (name.indexOf('Copy of ') === 0) name = name.slice(8); + return name; + }; + var candidates = allPages.filter(function(p) { + var name = stripCopyOf(p.get('name')); + if (name === masterName) return true; + // Check if page already has config for this group + var cfg = getGroupConfigOnPage(p.get('_id'), groupName); + if (cfg) return true; + return false; + }); + + // We need N+1 pages (1 master + N players) + var needed = playerIds.length + 1; + if (candidates.length < needed) { + reply(msg, 'Error', 'Found ' + candidates.length + ' page(s) named "' + masterName + '..." but need ' + needed + ' (1 master + ' + playerIds.length + ' players). Duplicate the page ' + (needed - candidates.length) + ' more time(s).'); + return; + } + + // Assign: first candidate = master, rest = players (arbitrary order) + var masterCandidate = candidates.find(function(p) { return p.get('_id') === masterPageId; }) || candidates[0]; + var playerCandidates = candidates.filter(function(p) { return p.get('_id') !== masterCandidate.get('_id'); }).slice(0, playerIds.length); + + // Rename and configure + masterCandidate.set('name', masterName + ' (master)'); + setConfigOnPage(masterCandidate.get('_id'), groupName, { player: 'GM' }); + + var assignments = []; + playerIds.forEach(function(pid, i) { + var page = playerCandidates[i]; + var player = getObj('player', pid); + var playerName = player ? player.get('_displayname') : pid; + page.set('name', masterName + ' (' + playerName + ')'); + setConfigOnPage(page.get('_id'), groupName, { player: playerName, playerid: pid }); + assignments.push(playerName + ' → ' + page.get('name')); + }); + + var out = 'Group "' + groupName + '" set up:
'; + out += 'Master: ' + masterCandidate.get('name') + '
'; + out += assignments.join('
'); + out += '

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

' + + '' + CMD + ' split <group> -- Activate group
' + + '' + CMD + ' merge [group] -- Tear down links
' + + '' + CMD + ' test <group> -- Dry-run linking
' + + '' + CMD + ' link [name|new] [ids...] -- Link tokens
' + + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + + '' + CMD + ' group <group> <player|GM> -- Assign page
' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' status -- Show state
' + + '' + CMD + ' --help -- This help
'; + + // ========================================================================= + // Command Router + // ========================================================================= + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (msg.content.split(' ')[0] !== CMD) return; + if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; + + const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const sub = (args.shift() || '').toLowerCase(); + + switch (sub) { + case 'setup': doSetup(msg, args); break; + case 'split': doSplit(msg, args); break; + case 'merge': doMerge(msg, args); break; + case 'test': doTest(msg, args); break; + case 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'relay': doRelay(msg, args); break; + case 'view': doView(msg, args); break; + case 'stage': doStage(msg, args); break; + case 'config': doConfig(msg, args); break; + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + /** + * When a linked token is deleted, delete its counterparts on other pages. + */ + var destroying = false; + const onTokenDestroyed = (obj) => { + if (destroying) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find if this token is tracked in any active group + var linkedIds = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + // Clean up tracking + delete active.linkedTokens[tokenId]; + linkedIds.forEach(function(id) { + if (active.linkedTokens[id]) { + active.linkedTokens[id] = active.linkedTokens[id].filter(function(lid) { return lid !== tokenId; }); + } + }); + } else { + // Check if it's in someone else's list + Object.entries(active.linkedTokens).forEach(function(entry) { + var idx = entry[1].indexOf(tokenId); + if (idx !== -1) { + entry[1].splice(idx, 1); + if (!linkedIds) linkedIds = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + + // Remove Anchor/Mirror links and delete counterparts + destroying = true; + linkedIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + if (typeof Mirror !== 'undefined') Mirror.unlink([id]); + var target = getObj('graphic', id); + if (target) target.remove(); + }); + destroying = false; + }; + + /** + * Universal relay interceptor. Automatically relays commands to linked tokens: + * - If selected tokens or IDs in command reference master-page linked tokens + * AND no player-page tokens are selected/referenced → relay to all player pages. + * - If player-page tokens are involved → only relay if command is in relayCommands. + */ + const viewInterceptor = (msg) => { + if (msg.type !== 'api') return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var content = msg.content.trim(); + if (!content) return; + var firstWord = content.split(' ')[0]; + if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; + + // Check relaying set to prevent loops + var selectedIds = (msg.selected || []).map(function(sel) { return sel._id; }); + var key = relayKey(content, 'player|' + msg.playerid, selectedIds); + if (relaying.delete(key)) return; + if (content.indexOf('{& select') !== -1) return; + + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + + // Scan command for token IDs that belong to linked groups + var idRx = /-[A-Za-z0-9_-]{19}/g; + var idsInCommand = (content.match(idRx) || []).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); + + // Classify: which IDs/tokens are on master pages vs player pages? + var masterTokens = []; + var hasPlayerPageRef = false; + var activeEntry = null; + + // Check selected tokens + tokens.forEach(function(t) { + var pid = t.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + masterTokens.push(t); + if (!activeEntry) activeEntry = entry; + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + // Check IDs in command text + idsInCommand.forEach(function(id) { + // Skip IDs already accounted for by selection + if (tokens.some(function(t) { return t.get('id') === id; })) return; + var obj = getObj('graphic', id); + if (!obj) return; + var pid = obj.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + // Check if this token is actually linked + var linked = entry[1].linkedTokens[id] || []; + var isLinked = linked.length > 0 || Object.values(entry[1].linkedTokens).some(function(arr) { return arr.indexOf(id) !== -1; }); + if (isLinked) { + masterTokens.push(obj); + if (!activeEntry) activeEntry = entry; + } + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + if (masterTokens.length === 0 && !hasPlayerPageRef) return; + + // Universal relay: master-page refs, no player-page refs + if (masterTokens.length > 0 && !hasPlayerPageRef) { + var viewPlayerId = s.view; + var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); + executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); + return; + } + + // Player-page involved: only relay if relayCommands allows it + if (hasPlayerPageRef && s.config.relayCommands.indexOf(firstWord) !== -1) { + // Find source player page + var sourcePlayerId = null; + var entry = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + var srcToken = tokens.find(function(t) { return t.get('_pageid') === pp[1].pageId; }); + if (srcToken) { entry = e; sourcePlayerId = pp[0]; } + }); + }); + if (!entry) return; + var targetPlayerIds = Object.keys(entry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, content, targetPlayerIds, true); + } + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('chat:message', viewInterceptor); + on('add:graphic', onTokenAdded); + on('destroy:graphic', onTokenDestroyed); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 62c630c3b1..f13587a4e6 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -35,6 +35,8 @@ var Gaslight = Gaslight || (() => { var relaying = new Set(); + const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); + // ========================================================================= // Helpers // ========================================================================= @@ -1234,7 +1236,7 @@ var Gaslight = Gaslight || (() => { var tokenIds = tokens.map(function(t) { return t.get('id'); }); if (includeMaster) { - relaying.add(command); + relaying.add(relayKey(command, sender, tokenIds)); sendChat(sender, command + ' {& select ' + tokenIds.join(', ') + '}'); relayed += tokenIds.length; } @@ -1269,7 +1271,7 @@ var Gaslight = Gaslight || (() => { }); if (linkedIds.length > 0) { - relaying.add(newCmd); + relaying.add(relayKey(newCmd, sender, linkedIds)); sendChat(sender, newCmd + ' {& select ' + linkedIds.join(', ') + '}'); relayed += linkedIds.length; } @@ -1654,7 +1656,9 @@ var Gaslight = Gaslight || (() => { if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; // Check relaying set to prevent loops - if (relaying.has(content)) { relaying.delete(content); return; } + var selectedIds = (msg.selected || []).map(function(sel) { return sel._id; }); + var key = relayKey(content, 'player|' + msg.playerid, selectedIds); + if (relaying.delete(key)) return; if (content.indexOf('{& select') !== -1) return; var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); diff --git a/Gaslight/README.md b/Gaslight/README.md index f2886ce638..f45912d330 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -65,11 +65,16 @@ Controlled by `gaslight_sync` character attribute: ## Command Relay -Commands run on master page auto-relay to player pages: -- **Path 1 (IDs in command)**: immediate cross-page via ID replacement -- **Path 2 (selection only)**: queued, fires when GM navigates to target page +Any API command that references master-page linked tokens (via selection or token IDs in the command) is automatically relayed to all player pages with token IDs replaced by their linked counterparts. This happens transparently — no configuration needed. -Configure player auto-relay: `!gaslight config relay-add !token-mod` +**Rules:** +- Master-page tokens selected or IDs in command → auto-relay to all player pages +- Player-page tokens involved → only relay if the command is in `relayCommands` list +- Commands already relayed are not re-relayed (loop prevention) + +**Manual relay:** `!gaslight relay ` — explicitly relay to specific views. + +**Player auto-relay:** `!gaslight config relay-add !token-mod` — allow player-page commands to relay to other pages. ## Staging From 5a985b34e4088d97194ff474460ca3f8748c9a65 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 08:53:29 -0400 Subject: [PATCH 42/96] Gaslight: update file header to v1.1.0 with full command list and dependencies --- Gaslight/1.1.0/Gaslight.js | 24 +++++++++++++++--------- Gaslight/Gaslight.js | 24 +++++++++++++++--------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/Gaslight/1.1.0/Gaslight.js b/Gaslight/1.1.0/Gaslight.js index f13587a4e6..0b81f344a3 100644 --- a/Gaslight/1.1.0/Gaslight.js +++ b/Gaslight/1.1.0/Gaslight.js @@ -1,23 +1,29 @@ // ============================================================================= -// Gaslight v1.0.0 -// Last Updated: 2026-06-14 +// Gaslight v1.1.0 +// Last Updated: 2026-06-25 // Author: Kenan Millet // // Description: // Per-player map perception. Split players onto individual copies of a page -// with tokens synchronized via Anchor. Each player can see different things -// while token movement stays consistent across all copies. +// with tokens synchronized via Anchor and Mirror. Each player can see +// different things while token movement stays consistent across all copies. +// Commands auto-relay to all player pages transparently. // -// Dependencies: Anchor +// Dependencies: Anchor, Mirror, SelectManager // // Commands: -// !gaslight split Activate a prepared gaslight group +// !gaslight setup Quick-configure from duplicates +// !gaslight split [--force] Activate a prepared group // !gaslight merge [group] Tear down links, return players // !gaslight test Dry-run linking resolution // !gaslight link [|new] [ids...] Set gaslight_link on tokens -// !gaslight unlink [ids...] Remove gaslight_link from tokens -// !gaslight group Assign page to group -// !gaslight master Designate page as group master +// !gaslight unlink [ids...|--group ] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight ungroup Remove page from group +// !gaslight stage [players...] Propagate tokens to player pages +// !gaslight view [player|master] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands // !gaslight status Show current state // !gaslight --help Command reference // ============================================================================= diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index f13587a4e6..0b81f344a3 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,23 +1,29 @@ // ============================================================================= -// Gaslight v1.0.0 -// Last Updated: 2026-06-14 +// Gaslight v1.1.0 +// Last Updated: 2026-06-25 // Author: Kenan Millet // // Description: // Per-player map perception. Split players onto individual copies of a page -// with tokens synchronized via Anchor. Each player can see different things -// while token movement stays consistent across all copies. +// with tokens synchronized via Anchor and Mirror. Each player can see +// different things while token movement stays consistent across all copies. +// Commands auto-relay to all player pages transparently. // -// Dependencies: Anchor +// Dependencies: Anchor, Mirror, SelectManager // // Commands: -// !gaslight split Activate a prepared gaslight group +// !gaslight setup Quick-configure from duplicates +// !gaslight split [--force] Activate a prepared group // !gaslight merge [group] Tear down links, return players // !gaslight test Dry-run linking resolution // !gaslight link [|new] [ids...] Set gaslight_link on tokens -// !gaslight unlink [ids...] Remove gaslight_link from tokens -// !gaslight group Assign page to group -// !gaslight master Designate page as group master +// !gaslight unlink [ids...|--group ] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight ungroup Remove page from group +// !gaslight stage [players...] Propagate tokens to player pages +// !gaslight view [player|master] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands // !gaslight status Show current state // !gaslight --help Command reference // ============================================================================= From 153660a72c4bdb52a45491b28e853c09922e476e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 09:21:18 -0400 Subject: [PATCH 43/96] Gaslight: help handout always updates on init, fix merge description, add relay/sync/staging details --- Gaslight/1.1.0/Gaslight.js | 65 ++++++++++++++++++++++++++++++++++++++ Gaslight/Gaslight.js | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/Gaslight/1.1.0/Gaslight.js b/Gaslight/1.1.0/Gaslight.js index 0b81f344a3..f5ec1aaa8c 100644 --- a/Gaslight/1.1.0/Gaslight.js +++ b/Gaslight/1.1.0/Gaslight.js @@ -1594,8 +1594,73 @@ var Gaslight = Gaslight || (() => { // Initialization // ========================================================================= + const HANDOUT_NAME = 'Help: Gaslight'; + const HANDOUT_AVATAR = 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385'; + + const createHelpHandout = () => { + var existing = findObjs({ type: 'handout', name: HANDOUT_NAME }); + var h = existing.length > 0 ? existing[0] : createObj('handout', { name: HANDOUT_NAME, avatar: HANDOUT_AVATAR }); + if (HANDOUT_AVATAR) h.set('avatar', HANDOUT_AVATAR); + h.set('notes', [ + '

Gaslight v' + SCRIPT_VERSION + '

', + '

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

', + '

Quick Start

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

Commands

', + '

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

', + '

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

', + '

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

', + '

!gaslight test <group> — Dry-run linking

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

!gaslight status — Show state

', + '

Auto-Relay

', + '

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

', + '

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

', + '

Selective Relay

', + '

Use !gaslight relay to target specific players. Example: only Alice and Bob see a door open, but Charlie does not:

', + '

!gaslight relay Alice Bob !token-mod --set layer|objects

', + '

Or relay to everyone except by relaying to "all" and handling exceptions separately:

', + '

!gaslight relay all !token-mod --set bar1_value|10

', + '

Token Linking

', + '

Tokens are linked across pages automatically by:

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

Sync Control

', + '

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

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

Staging

', + '

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

', + '

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

', + ].join('')); + }; + const checkInstall = () => { ensureState(); + createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); }; diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 0b81f344a3..f5ec1aaa8c 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1594,8 +1594,73 @@ var Gaslight = Gaslight || (() => { // Initialization // ========================================================================= + const HANDOUT_NAME = 'Help: Gaslight'; + const HANDOUT_AVATAR = 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385'; + + const createHelpHandout = () => { + var existing = findObjs({ type: 'handout', name: HANDOUT_NAME }); + var h = existing.length > 0 ? existing[0] : createObj('handout', { name: HANDOUT_NAME, avatar: HANDOUT_AVATAR }); + if (HANDOUT_AVATAR) h.set('avatar', HANDOUT_AVATAR); + h.set('notes', [ + '

Gaslight v' + SCRIPT_VERSION + '

', + '

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

', + '

Quick Start

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

Commands

', + '

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

', + '

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

', + '

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

', + '

!gaslight test <group> — Dry-run linking

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

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

', + '

!gaslight status — Show state

', + '

Auto-Relay

', + '

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

', + '

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

', + '

Selective Relay

', + '

Use !gaslight relay to target specific players. Example: only Alice and Bob see a door open, but Charlie does not:

', + '

!gaslight relay Alice Bob !token-mod --set layer|objects

', + '

Or relay to everyone except by relaying to "all" and handling exceptions separately:

', + '

!gaslight relay all !token-mod --set bar1_value|10

', + '

Token Linking

', + '

Tokens are linked across pages automatically by:

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

Sync Control

', + '

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

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

Staging

', + '

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

', + '

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

', + ].join('')); + }; + const checkInstall = () => { ensureState(); + createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); }; From 714d28745f6d953ee3f55eb91c7a32b309867a07 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 09:24:36 -0400 Subject: [PATCH 44/96] Gaslight: fix selective relay handout wording, add --except to TODO --- Gaslight/1.1.0/Gaslight.js | 7 +++---- Gaslight/Gaslight.js | 7 +++---- Gaslight/TODO.md | 1 + 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Gaslight/1.1.0/Gaslight.js b/Gaslight/1.1.0/Gaslight.js index f5ec1aaa8c..d023044c71 100644 --- a/Gaslight/1.1.0/Gaslight.js +++ b/Gaslight/1.1.0/Gaslight.js @@ -1631,10 +1631,9 @@ var Gaslight = Gaslight || (() => { '

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

', '

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

', '

Selective Relay

', - '

Use !gaslight relay to target specific players. Example: only Alice and Bob see a door open, but Charlie does not:

', - '

!gaslight relay Alice Bob !token-mod --set layer|objects

', - '

Or relay to everyone except by relaying to "all" and handling exceptions separately:

', - '

!gaslight relay all !token-mod --set bar1_value|10

', + '

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

', + '

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

', + '

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

', '

Token Linking

', '

Tokens are linked across pages automatically by:

', '
    ', diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index f5ec1aaa8c..d023044c71 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1631,10 +1631,9 @@ var Gaslight = Gaslight || (() => { '

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

    ', '

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

    ', '

    Selective Relay

    ', - '

    Use !gaslight relay to target specific players. Example: only Alice and Bob see a door open, but Charlie does not:

    ', - '

    !gaslight relay Alice Bob !token-mod --set layer|objects

    ', - '

    Or relay to everyone except by relaying to "all" and handling exceptions separately:

    ', - '

    !gaslight relay all !token-mod --set bar1_value|10

    ', + '

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

    ', + '

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

    ', + '

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

    ', '

    Token Linking

    ', '

    Tokens are linked across pages automatically by:

    ', '
      ', diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 857070270c..5e18a90411 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -38,6 +38,7 @@ - [ ] Focus-ping on split ## v2 Ideas +- [ ] `!gaslight relay all --except ` flag - [ ] Config handout (editable in-game, live reload) - [ ] Group/page-level relay-command overrides - [ ] Config visibility toggle (hide gaslight text in HTML comment) From 0b950a8ffd5e7218a0a5e051de961459dd0c53ba Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 09:28:10 -0400 Subject: [PATCH 45/96] Gaslight: remove resolved SelectManager known issue --- Gaslight/TODO.md | 1 - 1 file changed, 1 deletion(-) diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 5e18a90411..aa11a9e9a8 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -49,5 +49,4 @@ - [ ] On-demand page cloning (if TruePageCopy exposes API) ## Known Issues -- SelectManager `{& select}` text may visibly bleed into chat even though selection works correctly (upstream issue, reported to timmaugh) - linkedTokens accumulates duplicates on repeated splits (cosmetic, deduped at use) From 100df16c8cc1a0beeac5fbe720bf6e5dc85dc517 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 10:42:08 -0400 Subject: [PATCH 46/96] RollCapture: fire character abilities (rc_any, rc_name, rc_default) with captured value substitution --- RollCapture/RollCapture.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 2947fb0686..13748ec05c 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -258,6 +258,36 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars fn(event); } } + fireAbility(charName, rollName, captures, playerId); + }; + + // ─── Ability Firing ───────────────────────────────────────────────────────── + + const fireAbility = (charName, rollName, captures, playerId) => { + const chars = findObjs({ type: 'character', name: charName }); + if (chars.length === 0) return; + const charId = chars[0].get('id'); + + const abilities = findObjs({ type: 'ability', _characterid: charId }); + const specificName = 'rc_' + rollName; + + const any_abils = abilities.filter(a => a.get('name') === 'rc_any'); + const match_abils = abilities.filter(a => a.get('name') === specificName); + const default_abils = match_abils.length === 0 ? abilities.filter(a => a.get('name') === 'rc_default') : []; + for (const a of [...any_abils, ...match_abils, ...default_abils]) { + runAbility(a, captures, rollName, playerId); + } + }; + + const runAbility = (ability, captures, rollName, playerId) => { + const action = ability.get('action'); + if (!action) return; + let cmd = action.replace(/\$\{rollname\}/gi, rollName); + for (const [varName, value] of Object.entries(captures)) { + const captureName = varName.split('_').pop(); + cmd = cmd.replace(new RegExp('\\$\\{' + captureName + '\\}', 'gi'), value !== undefined ? value : ''); + } + sendChat('player|' + playerId, cmd); }; const matchPattern = (pattern, key) => { From 0b588f4a15717bee0382ee32edede251c25c97e9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 10:50:21 -0400 Subject: [PATCH 47/96] =?UTF-8?q?RollCapture:=20drop=20variable=20?= =?UTF-8?q?=E2=80=94=20emit=20bare=20capture=20names=20+=20full=20msg,=20c?= =?UTF-8?q?onsumers=20handle=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/RollCapture.js | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 13748ec05c..a7400fb26f 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -7,7 +7,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const VERSION = '0.1.0'; const HANDOUT_MARKER = '---ROLLCAPTURE---'; - const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'variable:', 'when:', 'default:']; + const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'when:', 'default:']; const CMD = '!rollcapture'; let rules = []; @@ -18,7 +18,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const parseRules = (text) => { const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && l !== HANDOUT_MARKER); - const rule = { templates: [], nameField: '', charFields: [], variable: '', blocks: [] }; + const rule = { templates: [], nameField: '', charFields: [], blocks: [] }; let currentBlock = null; for (const line of lines) { @@ -28,8 +28,6 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars rule.nameField = line.slice(11).trim(); } else if (line.startsWith('char_field:')) { rule.charFields = line.slice(11).split(',').map(s => s.trim()).filter(Boolean); - } else if (line.startsWith('variable:')) { - rule.variable = line.slice(9).trim(); } else if (line.startsWith('when:')) { currentBlock = { condition: line.slice(5).trim(), captures: {} }; rule.blocks.push(currentBlock); @@ -163,15 +161,6 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars .replace(/[^a-z0-9_-]/g, '_'); // sanitize }; - // ─── Variable Name Building ───────────────────────────────────────────────── - - const buildVarName = (pattern, rname, captureName) => { - return pattern - .replace(/\$\{rname\}/g, cleanName(rname)) - .replace(/\$\{name_field\}/g, cleanName(rname)) - .replace(/\$\{capture\}/g, captureName); - }; - // ─── Choose Prompt ────────────────────────────────────────────────────────── const promptChoose = (context, captureName, options) => { @@ -235,24 +224,23 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const value = evalFormula(formula, fieldMap); if (value && value.__choose) { hasChoose = true; - const context = { rule, rollName: cleanName(rollName), charName, playerId: msg.playerid, results }; + const context = { rule, rollName: cleanName(rollName), charName, playerId: msg.playerid, results, msg }; promptChoose(context, captureName, value.options); } else { - const varName = buildVarName(rule.variable, rollName, captureName); - results[varName] = value; // undefined = clear + results[captureName] = value; // undefined = clear } } if (!hasChoose) { - emitCapture(charName, cleanName(rollName), results, msg.playerid); + emitCapture(charName, cleanName(rollName), results, msg.playerid, msg); } } }; // ─── Callback Registry ────────────────────────────────────────────────────── - const emitCapture = (charName, rollName, captures, playerId) => { - const event = { charName, rollName, captures, playerId }; + const emitCapture = (charName, rollName, captures, playerId, msg) => { + const event = { charName, rollName, captures, playerId, msg }; for (const { pattern, fn } of callbacks) { if (pattern === '*' || Object.keys(captures).some(k => matchPattern(pattern, k))) { fn(event); @@ -310,10 +298,9 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const [, id, captureName, value] = args; const ctx = pendingChoices[id]; if (!ctx) return whisper('Choice expired or invalid.'); - const varName = buildVarName(ctx.rule.variable, ctx.rollName, captureName); - ctx.results[varName] = parseInt(value, 10) || 0; + ctx.results[captureName] = parseInt(value, 10) || 0; delete pendingChoices[id]; - emitCapture(ctx.charName, ctx.rollName, ctx.results, ctx.playerId); + emitCapture(ctx.charName, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); whisper(`Captured ${captureName} = ${value}`); return; } @@ -331,7 +318,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (args[0] === 'rules') { if (!rules.length) return whisper('No rules loaded.'); - const list = rules.map((r, i) => `${i + 1}. templates: ${r.templates.join(', ')} | var: ${r.variable}`).join('\n'); + const list = rules.map((r, i) => `${i + 1}. templates: ${r.templates.join(', ')} | name: ${r.nameField}`).join('\n'); whisper(`**Loaded Rules:**\n${list}`); return; } From 13004235aaaa1e70286c2210ce73cbd5a863c117 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:08:49 -0400 Subject: [PATCH 48/96] RollCapture: add SCRIPT_NAME, Map-based callbacks with sourceId dedup, emit !rollcapture-ready on init --- RollCapture/RollCapture.js | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index a7400fb26f..aa47e10e55 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -5,13 +5,14 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars 'use strict'; - const VERSION = '0.1.0'; + const SCRIPT_NAME = 'RollCapture'; + const SCRIPT_VERSION = '0.1.0'; const HANDOUT_MARKER = '---ROLLCAPTURE---'; const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'when:', 'default:']; const CMD = '!rollcapture'; let rules = []; - let callbacks = []; + let callbacks = new Map(); let pendingChoices = {}; // id → { captures, resolve info } // ─── Rule Parser ──────────────────────────────────────────────────────────── @@ -241,10 +242,8 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const emitCapture = (charName, rollName, captures, playerId, msg) => { const event = { charName, rollName, captures, playerId, msg }; - for (const { pattern, fn } of callbacks) { - if (pattern === '*' || Object.keys(captures).some(k => matchPattern(pattern, k))) { - fn(event); - } + for (const fn of callbacks.values()) { + fn(event); } fireAbility(charName, rollName, captures, playerId); }; @@ -278,14 +277,8 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars sendChat('player|' + playerId, cmd); }; - const matchPattern = (pattern, key) => { - if (pattern === '*') return true; - if (pattern.endsWith('*')) return key.startsWith(pattern.slice(0, -1)); - return pattern === key; - }; - - const onCapture = (pattern, fn) => { - callbacks.push({ pattern, fn }); + const onCapture = (sourceId, fn) => { + callbacks.set(sourceId, fn); }; // ─── Command Handling ─────────────────────────────────────────────────────── @@ -312,7 +305,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars } if (args[0] === 'status') { - whisper(`**RollCapture v${VERSION}**\nRules: ${rules.length}\nCallbacks: ${callbacks.length}\nPending choices: ${Object.keys(pendingChoices).length}`); + whisper(`**RollCapture v${SCRIPT_VERSION}**\nRules: ${rules.length}\nCallbacks: ${callbacks.size}\nPending choices: ${Object.keys(pendingChoices).length}`); return; } @@ -323,7 +316,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars return; } - whisper(`**RollCapture v${VERSION}** — Commands:\n` + + whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:\n` + `\`!rollcapture status\` — Show status\n` + `\`!rollcapture rules\` — List loaded rules\n` + `\`!rollcapture reload\` — Reload rules from handouts`); @@ -352,7 +345,8 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars on('ready', () => { loadRulesFromHandouts(); registerEventHandlers(); - log(`RollCapture v${VERSION} loaded.`); + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); + sendChat('', `!${SCRIPT_NAME.toLowerCase()}-ready`, null, { noarchive: true }); }); return { From 168ef7d1afa9c985ca36c8ea72b77c36aa0e5596 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:10:43 -0400 Subject: [PATCH 49/96] =?UTF-8?q?RollCapture:=20fix=20whisper=20splitting?= =?UTF-8?q?=20=E2=80=94=20use=20br=20instead=20of=20newlines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/RollCapture.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index aa47e10e55..dd51a2727c 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -305,7 +305,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars } if (args[0] === 'status') { - whisper(`**RollCapture v${SCRIPT_VERSION}**\nRules: ${rules.length}\nCallbacks: ${callbacks.size}\nPending choices: ${Object.keys(pendingChoices).length}`); + whisper(`**RollCapture v${SCRIPT_VERSION}**
      Rules: ${rules.length}
      Callbacks: ${callbacks.size}
      Pending choices: ${Object.keys(pendingChoices).length}`); return; } @@ -316,9 +316,9 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars return; } - whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:\n` + - `\`!rollcapture status\` — Show status\n` + - `\`!rollcapture rules\` — List loaded rules\n` + + whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:
      ` + + `\`!rollcapture status\` — Show status
      ` + + `\`!rollcapture rules\` — List loaded rules
      ` + `\`!rollcapture reload\` — Reload rules from handouts`); }; From 7551e1b996d740711faebd8ad7449416ac104d4e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:13:05 -0400 Subject: [PATCH 50/96] RollCapture: use code tags in help whisper --- RollCapture/RollCapture.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index dd51a2727c..d5e14158df 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -317,9 +317,9 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars } whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:
      ` + - `\`!rollcapture status\` — Show status
      ` + - `\`!rollcapture rules\` — List loaded rules
      ` + - `\`!rollcapture reload\` — Reload rules from handouts`); + `!rollcapture status — Show status
      ` + + `!rollcapture rules — List loaded rules
      ` + + `!rollcapture reload — Reload rules from handouts`); }; // ─── Utilities ────────────────────────────────────────────────────────────── From 68314f912d870d0b7443bdf3789231d431cd63e5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:15:19 -0400 Subject: [PATCH 51/96] RollCapture: fix rules newlines, prevent !rollcapture-ready from triggering command handler --- RollCapture/RollCapture.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index d5e14158df..78e9451f39 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -311,8 +311,8 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (args[0] === 'rules') { if (!rules.length) return whisper('No rules loaded.'); - const list = rules.map((r, i) => `${i + 1}. templates: ${r.templates.join(', ')} | name: ${r.nameField}`).join('\n'); - whisper(`**Loaded Rules:**\n${list}`); + const list = rules.map((r, i) => `${i + 1}. templates: ${r.templates.join(', ')} | name: ${r.nameField}`).join('
      '); + whisper(`**Loaded Rules:**
      ${list}`); return; } @@ -334,7 +334,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const registerEventHandlers = () => { on('chat:message', (msg) => { - if (msg.type === 'api' && msg.content.startsWith(CMD)) { + if (msg.type === 'api' && msg.content.split(' ')[0] === CMD) { handleCommand(msg); } else if (msg.rolltemplate) { processMessage(msg); From faea0956a975257838e506bf86f16c53a8d93f4e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:24:50 -0400 Subject: [PATCH 52/96] RollCapture: drop ---ROLLCAPTURE--- marker, rules shows handout links, add !rollcapture rule command --- RollCapture/RollCapture.js | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 78e9451f39..4afd6a5861 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -7,7 +7,6 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const SCRIPT_NAME = 'RollCapture'; const SCRIPT_VERSION = '0.1.0'; - const HANDOUT_MARKER = '---ROLLCAPTURE---'; const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'when:', 'default:']; const CMD = '!rollcapture'; @@ -18,7 +17,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars // ─── Rule Parser ──────────────────────────────────────────────────────────── const parseRules = (text) => { - const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && l !== HANDOUT_MARKER); + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const rule = { templates: [], nameField: '', charFields: [], blocks: [] }; let currentBlock = null; @@ -69,12 +68,12 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); - if (text.includes(HANDOUT_MARKER)) { - const rule = parseRules(text); - if (rule.templates.length) { - rules.push(rule); - loaded++; - } + const rule = parseRules(text); + if (rule.templates.length) { + rule.handoutId = h.get('id'); + rule.handoutName = h.get('name'); + rules.push(rule); + loaded++; } }); }); @@ -311,11 +310,25 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (args[0] === 'rules') { if (!rules.length) return whisper('No rules loaded.'); - const list = rules.map((r, i) => `${i + 1}. templates: ${r.templates.join(', ')} | name: ${r.nameField}`).join('
      '); + const list = rules.map((r, i) => `${i + 1}. [${r.handoutName}](http://journal.roll20.net/handout/${r.handoutId})`).join('
      '); whisper(`**Loaded Rules:**
      ${list}`); return; } + if (args[0] === 'rule') { + const name = args.slice(1).join(' '); + if (!name) return whisper('Usage: !rollcapture rule <name>'); + const tag = '[RC] ' + name; + let handout = findObjs({ type: 'handout', name: tag })[0] + || findObjs({ type: 'handout', name: '[RollCapture] ' + name })[0]; + if (!handout) { + handout = createObj('handout', { name: tag }); + handout.set('notes', 'template: \nname_field: \nchar_field: \ndefault:\nresult: r0'); + } + whisper(`[${handout.get('name')}](http://journal.roll20.net/handout/${handout.get('id')})`); + return; + } + whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:
      ` + `!rollcapture status — Show status
      ` + `!rollcapture rules — List loaded rules
      ` + From f743430503b525084b20c959682dce1af148064f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:28:33 -0400 Subject: [PATCH 53/96] =?UTF-8?q?RollCapture:=20fix=20handout=20links=20?= =?UTF-8?q?=E2=80=94=20use=20anchor=20tags=20instead=20of=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/RollCapture.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 4afd6a5861..b6a977b84a 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -310,7 +310,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (args[0] === 'rules') { if (!rules.length) return whisper('No rules loaded.'); - const list = rules.map((r, i) => `${i + 1}. [${r.handoutName}](http://journal.roll20.net/handout/${r.handoutId})`).join('
      '); + const list = rules.map((r, i) => `${i + 1}. ${r.handoutName}`).join('
      '); whisper(`**Loaded Rules:**
      ${list}`); return; } @@ -325,7 +325,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars handout = createObj('handout', { name: tag }); handout.set('notes', 'template: \nname_field: \nchar_field: \ndefault:\nresult: r0'); } - whisper(`[${handout.get('name')}](http://journal.roll20.net/handout/${handout.get('id')})`); + whisper(`${handout.get('name')}`); return; } From 4f4c16d840dcb0a00ed03d99a357a25d7fd5dc52 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:30:40 -0400 Subject: [PATCH 54/96] RollCapture: strip [RollCapture]/[RC] tag from display names in chat links --- RollCapture/RollCapture.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index b6a977b84a..5d61d01509 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -310,7 +310,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (args[0] === 'rules') { if (!rules.length) return whisper('No rules loaded.'); - const list = rules.map((r, i) => `${i + 1}. ${r.handoutName}`).join('
      '); + const list = rules.map((r, i) => `${i + 1}. ${stripTag(r.handoutName)}`).join('
      '); whisper(`**Loaded Rules:**
      ${list}`); return; } @@ -325,7 +325,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars handout = createObj('handout', { name: tag }); handout.set('notes', 'template: \nname_field: \nchar_field: \ndefault:\nresult: r0'); } - whisper(`${handout.get('name')}`); + whisper(`${stripTag(handout.get('name'))}`); return; } @@ -339,6 +339,8 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const whisper = (msg) => sendChat('RollCapture', `/w gm ${msg}`); + const stripTag = (name) => name.replace(/\[RollCapture\]\s*/i, '').replace(/\[RC\]\s*/i, '').trim(); + const generateUUID = () => { return 'rc_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); }; From 2285ab6a712b6cc9eaf1636b17fc33e159de09fc Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:36:03 -0400 Subject: [PATCH 55/96] RollCapture: comment lines (#), rule command shows created/found, new handouts include syntax guide --- RollCapture/RollCapture.js | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 5d61d01509..6b3ea20382 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -17,7 +17,7 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars // ─── Rule Parser ──────────────────────────────────────────────────────────── const parseRules = (text) => { - const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#')); const rule = { templates: [], nameField: '', charFields: [], blocks: [] }; let currentBlock = null; @@ -321,11 +321,39 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const tag = '[RC] ' + name; let handout = findObjs({ type: 'handout', name: tag })[0] || findObjs({ type: 'handout', name: '[RollCapture] ' + name })[0]; + let created = false; if (!handout) { handout = createObj('handout', { name: tag }); - handout.set('notes', 'template: \nname_field: \nchar_field: \ndefault:\nresult: r0'); + handout.set('notes', [ + '

      # RollCapture Rule: ' + name + '

      ', + '

      # Lines starting with # are comments.

      ', + '

      #

      ', + '

      # template: which roll template(s) to match (comma-separated)

      ', + '

      # name_field: template field containing the roll name (e.g. skill name)

      ', + '

      # char_field: template field(s) for character identification

      ', + '

      # when: {{flag=value}} — condition block, captures follow

      ', + '

      # default: — captures when no "when" matches

      ', + '

      # Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]

      ', + '

      # Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)

      ', + '

      # Missing fields are dropped from functions (not set to 0).

      ', + '

      # Empty capture (name: ) clears that value.

      ', + '

      #

      ', + '

      # To react to captures, add abilities to the character sheet:

      ', + '

      # rc_any — runs on every capture

      ', + '

      # rc_<rollname> — runs for that specific roll (e.g. rc_stealth)

      ', + '

      # rc_default — runs when no specific rc_<rollname> exists

      ', + '

      # Use ${rollname} and ${capturename} in ability actions.

      ', + '

      ', + '

      template: simple

      ', + '

      name_field: rname

      ', + '

      char_field: charname

      ', + '

      default:

      ', + '

      result: r1

      ', + ].join('')); + created = true; } - whisper(`${stripTag(handout.get('name'))}`); + const label = created ? 'Created' : 'Found'; + whisper(`${label}: ${stripTag(handout.get('name'))}`); return; } From 12de2440574edffb16396983e0554d0ab9689203 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:43:48 -0400 Subject: [PATCH 56/96] RollCapture: wrap new rule handout content in pre/code block --- RollCapture/RollCapture.js | 52 +++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 6b3ea20382..a8452f0ebc 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -324,32 +324,32 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars let created = false; if (!handout) { handout = createObj('handout', { name: tag }); - handout.set('notes', [ - '

      # RollCapture Rule: ' + name + '

      ', - '

      # Lines starting with # are comments.

      ', - '

      #

      ', - '

      # template: which roll template(s) to match (comma-separated)

      ', - '

      # name_field: template field containing the roll name (e.g. skill name)

      ', - '

      # char_field: template field(s) for character identification

      ', - '

      # when: {{flag=value}} — condition block, captures follow

      ', - '

      # default: — captures when no "when" matches

      ', - '

      # Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]

      ', - '

      # Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)

      ', - '

      # Missing fields are dropped from functions (not set to 0).

      ', - '

      # Empty capture (name: ) clears that value.

      ', - '

      #

      ', - '

      # To react to captures, add abilities to the character sheet:

      ', - '

      # rc_any — runs on every capture

      ', - '

      # rc_<rollname> — runs for that specific roll (e.g. rc_stealth)

      ', - '

      # rc_default — runs when no specific rc_<rollname> exists

      ', - '

      # Use ${rollname} and ${capturename} in ability actions.

      ', - '

      ', - '

      template: simple

      ', - '

      name_field: rname

      ', - '

      char_field: charname

      ', - '

      default:

      ', - '

      result: r1

      ', - ].join('')); + handout.set('notes', '
      '
      +                    + '# RollCapture Rule: ' + name + '\n'
      +                    + '# Lines starting with # are comments.\n'
      +                    + '#\n'
      +                    + '# template: which roll template(s) to match (comma-separated)\n'
      +                    + '# name_field: template field containing the roll name (e.g. skill name)\n'
      +                    + '# char_field: template field(s) for character identification\n'
      +                    + '# when: {{flag=value}} — condition block, captures follow\n'
      +                    + '# default: — captures when no "when" matches\n'
      +                    + '# Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]\n'
      +                    + '# Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)\n'
      +                    + '# Missing fields are dropped from functions (not set to 0).\n'
      +                    + '# Empty capture (name: ) clears that value.\n'
      +                    + '#\n'
      +                    + '# To react to captures, add abilities to the character sheet:\n'
      +                    + '#   rc_any — runs on every capture\n'
      +                    + '#   rc_<rollname> — runs for that specific roll (e.g. rc_stealth)\n'
      +                    + '#   rc_default — runs when no specific rc_<rollname> exists\n'
      +                    + '# Use ${rollname} and ${capturename} in ability actions.\n'
      +                    + '\n'
      +                    + 'template: simple\n'
      +                    + 'name_field: rname\n'
      +                    + 'char_field: charname\n'
      +                    + 'default:\n'
      +                    + 'result: r1\n'
      +                    + '
      '); created = true; } const label = created ? 'Created' : 'Found'; From f15b8896a0a294480ce65603d243e73a68307da2 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:46:12 -0400 Subject: [PATCH 57/96] RollCapture: use template literal for rule handout content --- RollCapture/RollCapture.js | 51 +++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index a8452f0ebc..43a756c02d 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -324,32 +324,31 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars let created = false; if (!handout) { handout = createObj('handout', { name: tag }); - handout.set('notes', '
      '
      -                    + '# RollCapture Rule: ' + name + '\n'
      -                    + '# Lines starting with # are comments.\n'
      -                    + '#\n'
      -                    + '# template: which roll template(s) to match (comma-separated)\n'
      -                    + '# name_field: template field containing the roll name (e.g. skill name)\n'
      -                    + '# char_field: template field(s) for character identification\n'
      -                    + '# when: {{flag=value}} — condition block, captures follow\n'
      -                    + '# default: — captures when no "when" matches\n'
      -                    + '# Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]\n'
      -                    + '# Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)\n'
      -                    + '# Missing fields are dropped from functions (not set to 0).\n'
      -                    + '# Empty capture (name: ) clears that value.\n'
      -                    + '#\n'
      -                    + '# To react to captures, add abilities to the character sheet:\n'
      -                    + '#   rc_any — runs on every capture\n'
      -                    + '#   rc_<rollname> — runs for that specific roll (e.g. rc_stealth)\n'
      -                    + '#   rc_default — runs when no specific rc_<rollname> exists\n'
      -                    + '# Use ${rollname} and ${capturename} in ability actions.\n'
      -                    + '\n'
      -                    + 'template: simple\n'
      -                    + 'name_field: rname\n'
      -                    + 'char_field: charname\n'
      -                    + 'default:\n'
      -                    + 'result: r1\n'
      -                    + '
      '); + handout.set('notes', `
      # RollCapture Rule: ${name}
      +# Lines starting with # are comments.
      +#
      +# template: which roll template(s) to match (comma-separated)
      +# name_field: template field containing the roll name (e.g. skill name)
      +# char_field: template field(s) for character identification
      +# when: {{flag=value}} — condition block, captures follow
      +# default: — captures when no "when" matches
      +# Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]
      +# Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)
      +# Missing fields are dropped from functions (not set to 0).
      +# Empty capture (name: ) clears that value.
      +#
      +# To react to captures, add abilities to the character sheet:
      +#   rc_any — runs on every capture
      +#   rc_<rollname> — runs for that specific roll (e.g. rc_stealth)
      +#   rc_default — runs when no specific rc_<rollname> exists
      +# Use \${rollname} and \${capturename} in ability actions.
      +
      +template: simple
      +name_field: rname
      +char_field: charname
      +default:
      +    result: r1
      +
      `); created = true; } const label = created ? 'Created' : 'Found'; From 975e9ed897ca9f3bfd834d784e6c98e9523c1239 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:48:11 -0400 Subject: [PATCH 58/96] RollCapture: add rule command to help message --- RollCapture/RollCapture.js | 1 + 1 file changed, 1 insertion(+) diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 43a756c02d..972599c756 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -359,6 +359,7 @@ default: whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:
      ` + `!rollcapture status — Show status
      ` + `!rollcapture rules — List loaded rules
      ` + + `!rollcapture rule <name> — Open or create a rule handout
      ` + `!rollcapture reload — Reload rules from handouts`); }; From a3f9714bca5470eda2d55ad6ccee9af858aa05b6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 11:54:16 -0400 Subject: [PATCH 59/96] =?UTF-8?q?RollCapture=20v0.1.0:=20PR-ready=20?= =?UTF-8?q?=E2=80=94=20script.json,=20README,=20versioned=20folder,=20TODO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RollCapture/0.1.0/RollCapture.js | 401 +++++++++++++++++++++++++++++++ RollCapture/DESIGN.md | 224 ----------------- RollCapture/README.md | 97 ++++++++ RollCapture/TODO.md | 9 + RollCapture/script.json | 17 ++ 5 files changed, 524 insertions(+), 224 deletions(-) create mode 100644 RollCapture/0.1.0/RollCapture.js delete mode 100644 RollCapture/DESIGN.md create mode 100644 RollCapture/README.md create mode 100644 RollCapture/TODO.md create mode 100644 RollCapture/script.json diff --git a/RollCapture/0.1.0/RollCapture.js b/RollCapture/0.1.0/RollCapture.js new file mode 100644 index 0000000000..972599c756 --- /dev/null +++ b/RollCapture/0.1.0/RollCapture.js @@ -0,0 +1,401 @@ +// RollCapture v0.1.0 — Generic roll result extraction/storage for Roll20 +// Detects rolls via chat:message, extracts values per configurable rules, +// and emits captured data to registered consumer callbacks. + +const RollCapture = (() => { // eslint-disable-line no-unused-vars + 'use strict'; + + const SCRIPT_NAME = 'RollCapture'; + const SCRIPT_VERSION = '0.1.0'; + const KEYWORDS = ['template:', 'name_field:', 'char_field:', 'when:', 'default:']; + const CMD = '!rollcapture'; + + let rules = []; + let callbacks = new Map(); + let pendingChoices = {}; // id → { captures, resolve info } + + // ─── Rule Parser ──────────────────────────────────────────────────────────── + + const parseRules = (text) => { + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#')); + const rule = { templates: [], nameField: '', charFields: [], blocks: [] }; + let currentBlock = null; + + for (const line of lines) { + if (line.startsWith('template:')) { + rule.templates = line.slice(9).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('name_field:')) { + rule.nameField = line.slice(11).trim(); + } else if (line.startsWith('char_field:')) { + rule.charFields = line.slice(11).split(',').map(s => s.trim()).filter(Boolean); + } else if (line.startsWith('when:')) { + currentBlock = { condition: line.slice(5).trim(), captures: {} }; + rule.blocks.push(currentBlock); + } else if (line.startsWith('default:')) { + currentBlock = { condition: null, captures: {} }; + rule.blocks.push(currentBlock); + } else if (currentBlock) { + // capture line: name: formula + const sep = line.indexOf(':'); + if (sep > 0) { + const name = line.slice(0, sep).trim(); + const formula = line.slice(sep + 1).trim(); + if (!KEYWORDS.some(k => line.startsWith(k))) { + currentBlock.captures[name] = formula; // empty string = clear + } + } + } + } + return rule; + }; + + // ─── Handout Loading ──────────────────────────────────────────────────────── + + const loadRulesFromHandouts = () => { + rules = []; + const handouts = findObjs({ type: 'handout' }).filter(h => + h.get('name').includes('[RollCapture]') || h.get('name').includes('[RC]') + ); + let loaded = 0; + handouts.forEach(h => { + h.get('notes', (notes) => { + if (!notes) return; + const text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + const rule = parseRules(text); + if (rule.templates.length) { + rule.handoutId = h.get('id'); + rule.handoutName = h.get('name'); + rules.push(rule); + loaded++; + } + }); + }); + setTimeout(() => log(`RollCapture: loaded ${loaded} rule(s) from ${handouts.length} handout(s)`), 500); + }; + + // ─── Field Resolution ─────────────────────────────────────────────────────── + + const buildFieldMap = (content, inlinerolls) => { + // Map field names to their inline roll totals + // {{r1=$[[2]]}} → fieldMap.r1 = inlinerolls[2].results.total + const map = {}; + const re = /\{\{(\w[\w-]*)=\$\[\[(\d+)\]\]\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + const idx = parseInt(m[2], 10); + if (inlinerolls[idx] && inlinerolls[idx].results) { + map[m[1]] = inlinerolls[idx].results.total; + } + } + return map; + }; + + const buildFlagMap = (content) => { + // Map fields with non-roll values: {{normal=1}}, {{attack=1}}, etc. + const map = {}; + const re = /\{\{(\w[\w-]*)=([^$}][^}]*)\}\}/g; + let m; + while ((m = re.exec(content)) !== null) { + map[m[1]] = m[2]; + } + // Also match empty fields: {{range=}} + const reEmpty = /\{\{(\w[\w-]*)=\}\}/g; + while ((m = reEmpty.exec(content)) !== null) { + map[m[1]] = ''; + } + return map; + }; + + // ─── Formula Evaluation ───────────────────────────────────────────────────── + + const evalFormula = (formula, fieldMap) => { + if (!formula) return undefined; // empty = clear + + // Function call: max(...), min(...), sum(...), choose(...) + const funcMatch = formula.match(/^(max|min|sum|choose)\((.+)\)$/); + if (funcMatch) { + const fn = funcMatch[1]; + const args = funcMatch[2].split(',').map(a => a.trim()); + const resolved = args.map(a => fieldMap[a]).filter(v => v !== undefined); + + if (resolved.length === 0) return undefined; + + switch (fn) { + case 'max': return Math.max(...resolved); + case 'min': return Math.min(...resolved); + case 'sum': return resolved.reduce((a, b) => a + b, 0); + case 'choose': { + const unique = [...new Set(resolved)]; + if (unique.length === 1) return unique[0]; + return { __choose: true, options: args.filter(a => fieldMap[a] !== undefined).map(a => ({ name: a, value: fieldMap[a] })) }; + } + } + } + + // Direct field reference + return fieldMap[formula] !== undefined ? fieldMap[formula] : undefined; + }; + + // ─── Condition Matching ───────────────────────────────────────────────────── + + const matchCondition = (condition, content) => { + // condition is something like "{{advantage=1}}" + return content.includes(condition); + }; + + // ─── Name Cleaning ────────────────────────────────────────────────────────── + + const cleanName = (raw) => { + if (!raw) return ''; + return raw + .replace(/^\^{/, '').replace(/}$/, '') // strip ^{ } + .replace(/-u$/, '') // strip -u suffix + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_'); // sanitize + }; + + // ─── Choose Prompt ────────────────────────────────────────────────────────── + + const promptChoose = (context, captureName, options) => { + const id = generateUUID(); + pendingChoices[id] = context; + const buttons = options.map(o => + `[${o.name}: ${o.value}](${CMD} --choose ${id} ${captureName} ${o.value})` + ).join(' '); + whisper(`**${context.charName} — ${context.rollName}** (${captureName}): ${buttons}`); + }; + + // ─── Core Processing ──────────────────────────────────────────────────────── + + const processMessage = (msg) => { + if (!msg.rolltemplate || !msg.inlinerolls) return; + + const template = msg.rolltemplate; + const content = msg.content; + const inlinerolls = msg.inlinerolls; + + for (const rule of rules) { + if (!rule.templates.includes(template)) continue; + + const fieldMap = buildFieldMap(content, inlinerolls); + const flagMap = buildFlagMap(content); + + // Resolve roll name + const rollName = flagMap[rule.nameField] || fieldMap[rule.nameField] || ''; + + // Resolve character name + let charName = ''; + for (const cf of rule.charFields) { + if (flagMap[cf]) { charName = flagMap[cf]; break; } + } + // Also check bare charname= at end of content + if (!charName) { + const bareMatch = content.match(/charname=(.+?)(?:\s*"|\s*$)/); + if (bareMatch) charName = bareMatch[1].replace(/\\"/g, '"').trim(); + } + + // Find matching block + let activeBlock = null; + for (const block of rule.blocks) { + if (block.condition === null) continue; // skip default for now + if (matchCondition(block.condition, content)) { + activeBlock = block; + break; + } + } + // Fallback to default + if (!activeBlock) { + activeBlock = rule.blocks.find(b => b.condition === null); + } + if (!activeBlock) continue; + + // Process captures + const results = {}; + let hasChoose = false; + + for (const [captureName, formula] of Object.entries(activeBlock.captures)) { + const value = evalFormula(formula, fieldMap); + if (value && value.__choose) { + hasChoose = true; + const context = { rule, rollName: cleanName(rollName), charName, playerId: msg.playerid, results, msg }; + promptChoose(context, captureName, value.options); + } else { + results[captureName] = value; // undefined = clear + } + } + + if (!hasChoose) { + emitCapture(charName, cleanName(rollName), results, msg.playerid, msg); + } + } + }; + + // ─── Callback Registry ────────────────────────────────────────────────────── + + const emitCapture = (charName, rollName, captures, playerId, msg) => { + const event = { charName, rollName, captures, playerId, msg }; + for (const fn of callbacks.values()) { + fn(event); + } + fireAbility(charName, rollName, captures, playerId); + }; + + // ─── Ability Firing ───────────────────────────────────────────────────────── + + const fireAbility = (charName, rollName, captures, playerId) => { + const chars = findObjs({ type: 'character', name: charName }); + if (chars.length === 0) return; + const charId = chars[0].get('id'); + + const abilities = findObjs({ type: 'ability', _characterid: charId }); + const specificName = 'rc_' + rollName; + + const any_abils = abilities.filter(a => a.get('name') === 'rc_any'); + const match_abils = abilities.filter(a => a.get('name') === specificName); + const default_abils = match_abils.length === 0 ? abilities.filter(a => a.get('name') === 'rc_default') : []; + for (const a of [...any_abils, ...match_abils, ...default_abils]) { + runAbility(a, captures, rollName, playerId); + } + }; + + const runAbility = (ability, captures, rollName, playerId) => { + const action = ability.get('action'); + if (!action) return; + let cmd = action.replace(/\$\{rollname\}/gi, rollName); + for (const [varName, value] of Object.entries(captures)) { + const captureName = varName.split('_').pop(); + cmd = cmd.replace(new RegExp('\\$\\{' + captureName + '\\}', 'gi'), value !== undefined ? value : ''); + } + sendChat('player|' + playerId, cmd); + }; + + const onCapture = (sourceId, fn) => { + callbacks.set(sourceId, fn); + }; + + // ─── Command Handling ─────────────────────────────────────────────────────── + + const handleCommand = (msg) => { + const args = msg.content.split(/\s+/); + args.shift(); // remove !rollcapture + + if (args[0] === '--choose') { + const [, id, captureName, value] = args; + const ctx = pendingChoices[id]; + if (!ctx) return whisper('Choice expired or invalid.'); + ctx.results[captureName] = parseInt(value, 10) || 0; + delete pendingChoices[id]; + emitCapture(ctx.charName, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + whisper(`Captured ${captureName} = ${value}`); + return; + } + + if (args[0] === 'reload') { + loadRulesFromHandouts(); + whisper('Rules reloaded.'); + return; + } + + if (args[0] === 'status') { + whisper(`**RollCapture v${SCRIPT_VERSION}**
      Rules: ${rules.length}
      Callbacks: ${callbacks.size}
      Pending choices: ${Object.keys(pendingChoices).length}`); + return; + } + + if (args[0] === 'rules') { + if (!rules.length) return whisper('No rules loaded.'); + const list = rules.map((r, i) => `${i + 1}. ${stripTag(r.handoutName)}`).join('
      '); + whisper(`**Loaded Rules:**
      ${list}`); + return; + } + + if (args[0] === 'rule') { + const name = args.slice(1).join(' '); + if (!name) return whisper('Usage: !rollcapture rule <name>'); + const tag = '[RC] ' + name; + let handout = findObjs({ type: 'handout', name: tag })[0] + || findObjs({ type: 'handout', name: '[RollCapture] ' + name })[0]; + let created = false; + if (!handout) { + handout = createObj('handout', { name: tag }); + handout.set('notes', `
      # RollCapture Rule: ${name}
      +# Lines starting with # are comments.
      +#
      +# template: which roll template(s) to match (comma-separated)
      +# name_field: template field containing the roll name (e.g. skill name)
      +# char_field: template field(s) for character identification
      +# when: {{flag=value}} — condition block, captures follow
      +# default: — captures when no "when" matches
      +# Captures reference template fields: {{r1=$[[N]]}} means r1 = inlinerolls[N]
      +# Formulas: fieldname, max(a,b), min(a,b), sum(a,b,...), choose(a,b)
      +# Missing fields are dropped from functions (not set to 0).
      +# Empty capture (name: ) clears that value.
      +#
      +# To react to captures, add abilities to the character sheet:
      +#   rc_any — runs on every capture
      +#   rc_<rollname> — runs for that specific roll (e.g. rc_stealth)
      +#   rc_default — runs when no specific rc_<rollname> exists
      +# Use \${rollname} and \${capturename} in ability actions.
      +
      +template: simple
      +name_field: rname
      +char_field: charname
      +default:
      +    result: r1
      +
      `); + created = true; + } + const label = created ? 'Created' : 'Found'; + whisper(`${label}: ${stripTag(handout.get('name'))}`); + return; + } + + whisper(`**RollCapture v${SCRIPT_VERSION}** — Commands:
      ` + + `!rollcapture status — Show status
      ` + + `!rollcapture rules — List loaded rules
      ` + + `!rollcapture rule <name> — Open or create a rule handout
      ` + + `!rollcapture reload — Reload rules from handouts`); + }; + + // ─── Utilities ────────────────────────────────────────────────────────────── + + const whisper = (msg) => sendChat('RollCapture', `/w gm ${msg}`); + + const stripTag = (name) => name.replace(/\[RollCapture\]\s*/i, '').replace(/\[RC\]\s*/i, '').trim(); + + const generateUUID = () => { + return 'rc_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4); + }; + + // ─── Public API ───────────────────────────────────────────────────────────── + + const registerEventHandlers = () => { + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content.split(' ')[0] === CMD) { + handleCommand(msg); + } else if (msg.rolltemplate) { + processMessage(msg); + } + }); + }; + + on('ready', () => { + loadRulesFromHandouts(); + registerEventHandlers(); + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); + sendChat('', `!${SCRIPT_NAME.toLowerCase()}-ready`, null, { noarchive: true }); + }); + + return { + onCapture, + getCapturedValue: () => null, // placeholder — consumers store their own way + getLastCapture: () => null, // placeholder + registerRule: (ruleObj) => rules.push(ruleObj), + }; +})(); diff --git a/RollCapture/DESIGN.md b/RollCapture/DESIGN.md deleted file mode 100644 index 4453689625..0000000000 --- a/RollCapture/DESIGN.md +++ /dev/null @@ -1,224 +0,0 @@ -# RollCapture — Design Document - -## Concept - -RollCapture monitors chat for roll results, extracts numeric values based on configurable rules, and stores them on tokens (gmnotes) or character attributes. It operates silently in the background — other scripts (like Gaslight) react to the stored values via change events. - -## Use Cases - -- Store stealth rolls per-token for perception-based visibility scripts -- Track damage dealt for combat logging -- Record saving throw results for automated status effects -- Feed roll results into conditional automation systems - -## Architecture - -### Capture Rules - -Rules are defined in handouts tagged `[RC]` (Roll Capture). Each handout defines one capture rule set. - -### Rule Format - -``` ----ROLLCAPTURE--- -template: npc, simple -name_field: rname -char_field: name, charname -when: {{advantage=1}} -attack: max(r1, r2) -damage: sum(dmg1, dmg2, globaldamage) -when: {{disadvantage=1}} -attack: min(r1, r2) -damage: sum(dmg1, dmg2, globaldamage) -when: {{always=1}} -attack: choose(r1, r2) -damage: sum(dmg1, dmg2, globaldamage) -default: -attack: r1 -damage: sum(dmg1, dmg2, globaldamage) -variable: gl_${rname}_${capture} -``` - -Fields: -- `template` — roll template name(s) to match (comma-separated) -- `name_field` — template field containing the roll name (e.g. skill/weapon name) -- `char_field` — template field(s) for character identification (comma-separated, tried in order) -- `when: ` — condition block: if pattern found in content, use the captures that follow -- `default:` — captures to use when no `when` condition matches -- `: ` — after a `when:` or `default:`, any non-keyword line is a capture -- `variable` — gl_ field name pattern. `${rname}` = roll name, `${capture}` = capture name - -Consumers (e.g. Gaslight) decide where to store captured values (token gmnotes, character attribute, or both) based on their own configuration. - -### Field Name Resolution - -Template content uses `{{field=$[[N]]}}` patterns. Formulas reference field names (e.g. `r1`, `dmg1`), which resolve to inline roll indices by parsing the content: -- `{{r1=$[[2]]}}` → `r1` = `inlinerolls[2].results.total` -- `{{dmg1=$[[6]]}}` → `dmg1` = `inlinerolls[6].results.total` -- Raw `rN` without a matching template field falls back to `inlinerolls[N].results.total` - -### Formula Language - -- `r1` — value of a single field -- `max(r1, r2, ...)` — highest value among fields -- `min(r1, r2, ...)` — lowest value among fields -- `sum(r1, r2, ...)` — total of all fields -- `choose(r1, r2, ...)` — whisper GM with buttons to pick - -**Missing fields:** If a field referenced in a formula doesn't exist in the content, it is dropped from the function (not set to 0). This prevents interference with min/max/choose. - -### Capture Semantics - -- `attack: r1` — store the resolved value -- `attack:` (empty formula) — clear/unset the captured variable -- Capture name not listed in a block — don't touch, leave previous value - -### Multi-Variable Capture - -One rule can capture multiple variables from a single roll. Each `when:`/`default:` block can define any number of captures. The `variable` pattern uses `${capture}` to differentiate: -- `variable: gl_${rname}_${capture}` with captures `attack` and `damage` and rname `Scimitar` -- Produces: `gl_scimitar_attack` and `gl_scimitar_damage` - -### Block Parsing (No Indentation Required) - -Lines after `when:` or `default:` are treated as captures until the next `when:`, `default:`, or known keyword (`template:`, `name_field:`, `char_field:`, `variable:`). No indentation sensitivity. - -### Extraction Logic - -1. Check `msg.rolltemplate` against rule's `template` list -2. Parse `msg.content` for `{{field=$[[N]]}}` pairs → builds field-to-index map -3. Parse `msg.content` for `{{field=value}}` pairs → builds flag map -4. Check `when:` conditions against flag map → select matching capture block -5. Resolve formula: field names map to `inlinerolls[N].results.total` via the index map. Missing fields are dropped from functions. -6. Determine variable name: substitute `${rname}`, `${capture}` with cleaned values -7. Emit via `onCapture` callback - -### Token Association - -1. Character lookup — if `char_field` matches a character name, find tokens representing that character on the current page -2. If multiple tokens represent same character → prompt GM or let consumer decide -3. Queue unresolved captures — whisper GM with clickable assignment buttons - -### Name Cleaning - -`${rname}` from `{{rname=^{stealth}}}` needs cleaning: -- Strip `^{` and `}` (translation key wrappers) -- Strip `-u` suffix (uppercase variant marker) -- Lowercase -- Result: `stealth` - -So `gl_${rname}_${capture}` → `gl_stealth_attack` - -### API - -```javascript -RollCapture.getCapturedValue(tokenId, fieldName) // read latest captured value -RollCapture.getLastCapture() // most recent capture result -RollCapture.registerRule(ruleObj) // programmatic rule registration -RollCapture.onCapture(pattern, callback) // register consumer callback -``` - -### Events / Integration - -After capturing a value: -- Expose a callback/hook that other scripts register for: `RollCapture.onCapture('gl_*', callback)` -- Gaslight registers: `RollCapture.onCapture('gl_*', evaluateTriggeredPins)` -- Consumer decides storage (gmnotes, attributes, or both) - -### Dependencies - -None required. Optional integration with: -- Gaslight (consumes captured values) -- Fetch (gl_ compProps for reading stored values) - -### Open Questions - -1. Should rules be hot-reloaded when handouts change, or require a `!rollcapture reload`? -2. How to handle sheets that don't use Roll20's standard `{{field=value}}` format? -3. Should there be a capture history (last N rolls per token)? -4. Command interface: `!rollcapture status`, `!rollcapture rules`, `!rollcapture clear`? -5. Should the script auto-detect which character sheet is in use and suggest rules? - -## Example Capture Rules - -### D&D 5E Skills -``` ----ROLLCAPTURE--- -template: npc, simple -name_field: rname -char_field: name, charname -when: {{advantage=1}} -result: max(r1, r2) -when: {{disadvantage=1}} -result: min(r1, r2) -when: {{always=1}} -result: choose(r1, r2) -default: -result: r1 -variable: gl_${rname}_${capture} -``` - -### D&D 5E Attack + Damage -``` ----ROLLCAPTURE--- -template: atkdmg -name_field: rname -char_field: charname -when: {{always=1}} -attack: choose(r1, r2) -damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) -when: {{advantage=1}} -attack: max(r1, r2) -damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) -when: {{disadvantage=1}} -attack: min(r1, r2) -damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) -default: -attack: r1 -damage: sum(dmg1, dmg2, crit1, crit2, globaldamage, globaldamagecrit) -variable: gl_${rname}_${capture} -``` - -### Savage Worlds -``` ----ROLLCAPTURE--- -template: roll -name_field: trait -char_field: name -default: -result: max(skill_roll, wild_die) -variable: gl_${trait}_${capture} -``` - -### Shadow of the Demon Lord -``` ----ROLLCAPTURE--- -template: sotdl -name_field: roll-label -char_field: name, title -default: -result: roll -variable: gl_${roll-label}_${capture} -``` - -### Warhammer Fantasy Roleplay 4E -``` ----ROLLCAPTURE--- -template: wfrp -name_field: roll_name -char_field: name -default: -result: roll -variable: gl_${roll_name}_${capture} -``` - -### Call of Cthulhu 7E -``` ----ROLLCAPTURE--- -template: coc-dice-roll -name_field: name -char_field: name -default: -result: roll -variable: gl_${name}_${capture} -``` diff --git a/RollCapture/README.md b/RollCapture/README.md new file mode 100644 index 0000000000..037167b14c --- /dev/null +++ b/RollCapture/README.md @@ -0,0 +1,97 @@ +# RollCapture + +Generic roll result extraction for Roll20. Listens to roll template messages, extracts values per configurable rules, and fires character abilities with captured values. + +## Features + +- Configurable capture rules via handouts tagged `[RollCapture]` or `[RC]` +- Conditional extraction (`when:`/`default:` blocks) +- Formulas: direct value, `max()`, `min()`, `sum()`, `choose()` (GM prompt) +- Character ability firing: `rc_any`, `rc_`, `rc_default` +- `onCapture` API for programmatic consumers (e.g. Gaslight) +- `!rollcapture-ready` event for load-order-safe registration + +## Quick Start + +1. Install RollCapture +2. Run `!rollcapture rule D&D 5E Skills` to create a rule handout +3. Edit the handout with your capture rules +4. Run `!rollcapture reload` +5. Roll a skill check — captured values fire matching abilities + +## Rule Format + +Create a handout with `[RC]` or `[RollCapture]` in the name. Lines starting with `#` are comments. + +``` +template: npc, simple +name_field: rname +char_field: name, charname +when: {{advantage=1}} +result: max(r1, r2) +when: {{disadvantage=1}} +result: min(r1, r2) +when: {{always=1}} +result: choose(r1, r2) +default: +result: r1 +``` + +### Fields + +- `template:` — roll template name(s) to match (comma-separated) +- `name_field:` — template field containing the roll name +- `char_field:` — template field(s) for character identification +- `when: ` — condition block: if pattern found in content, use captures below +- `default:` — captures when no `when` condition matches +- Capture lines: `name: formula` — extract a value + +### Field Resolution + +Template fields like `{{r1=$[[2]]}}` map field name `r1` to `inlinerolls[2].results.total`. Formulas reference these field names directly. + +### Formulas + +- `fieldname` — direct value +- `max(a, b, ...)` — highest among present fields +- `min(a, b, ...)` — lowest among present fields +- `sum(a, b, ...)` — total of present fields +- `choose(a, b, ...)` — whisper GM with buttons to pick (auto-resolves if all equal) + +Missing fields are dropped from functions, not set to 0. + +## Character Abilities + +After a capture, RollCapture checks the rolling character for abilities: + +1. **`rc_any`** — fires on every capture +2. **`rc_`** — fires for that specific roll (e.g. `rc_stealth`) +3. **`rc_default`** — fires only when no specific `rc_` exists + +Use `${rollname}` and `${capturename}` (e.g. `${result}`) in ability actions for value substitution. + +## Commands + +| Command | Description | +|---------|-------------| +| `!rollcapture` | Show help | +| `!rollcapture status` | Show status | +| `!rollcapture rules` | List loaded rules with links | +| `!rollcapture rule ` | Open or create a rule handout | +| `!rollcapture reload` | Reload rules from handouts | + +## API (for other scripts) + +```javascript +// Register for capture events (dedupes by sourceId) +RollCapture.onCapture('MyScript', function(event) { + // event = { charName, rollName, captures, playerId, msg } + log(event.rollName + ': ' + JSON.stringify(event.captures)); +}); +``` + +RollCapture emits `!rollcapture-ready` on init for load-order-safe registration. + +## License + +MIT diff --git a/RollCapture/TODO.md b/RollCapture/TODO.md new file mode 100644 index 0000000000..edff6e209e --- /dev/null +++ b/RollCapture/TODO.md @@ -0,0 +1,9 @@ +# RollCapture TODO + +## Future +- [ ] Hot-reload rules when handouts are edited (vs requiring `!rollcapture reload`) +- [ ] Handle sheets that don't use standard `{{field=value}}` format +- [ ] Capture history (last N rolls per character) +- [ ] Auto-detect character sheet and suggest appropriate rules +- [ ] `!rollcapture clear` — remove pending choices +- [ ] Help handout on init diff --git a/RollCapture/script.json b/RollCapture/script.json new file mode 100644 index 0000000000..bdb514f653 --- /dev/null +++ b/RollCapture/script.json @@ -0,0 +1,17 @@ +{ + "name": "RollCapture", + "script": "RollCapture.js", + "version": "0.1.0", + "previousversions": [], + "description": "Generic roll result extraction for Roll20. Listens to roll template messages, extracts values per configurable rules stored in handouts, and fires character abilities with captured values.\n\nFeatures:\n- Configurable capture rules via handouts tagged [RollCapture] or [RC]\n- Conditional extraction (when/default blocks)\n- Formulas: direct value, max(), min(), sum(), choose() (GM prompt)\n- Character ability firing: rc_any, rc_, rc_default\n- onCapture API for programmatic consumers\n\nCommands:\n- `!rollcapture` -- Show help\n- `!rollcapture status` -- Show status\n- `!rollcapture rules` -- List loaded rules with links\n- `!rollcapture rule ` -- Open or create a rule handout\n- `!rollcapture reload` -- Reload rules from handouts", + "authors": "Kenan Millet", + "roll20userid": "2614613", + "dependencies": [], + "modifies": { + "character": "read", + "ability": "read", + "handout": "read, write" + }, + "conflicts": [], + "useroptions": [] +} From c799cfeed3d0f4ae469c6e843f955798b526bbe6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 12:18:04 -0400 Subject: [PATCH 60/96] Gaslight scripting: prevent relaying of scripted commands via --script-lock/unlock sandwich --- Gaslight/Gaslight.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index ca3013af59..da1fcdc812 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -49,6 +49,7 @@ var Gaslight = Gaslight || (() => { }; var relaying = new Set(); + var scripting = false; const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); @@ -2027,8 +2028,9 @@ var Gaslight = Gaslight || (() => { var gmPlayer = findObjs({ _type: 'player' }).find(function(p) { return playerIsGM(p.get('_id')); }); if (gmPlayer) senderId = gmPlayer.get('_id'); } - log(SCRIPT_NAME + ': SENDING: ' + JSON.stringify(fullCmd)); + sendChat('', CMD + ' --script-lock', null, { noarchive: true }); sendChat(getPlayerName(senderId), fullCmd); + sendChat('', CMD + ' --script-unlock', null, { noarchive: true }); } } }; @@ -2170,6 +2172,8 @@ var Gaslight = Gaslight || (() => { case 'config': doConfig(msg, args); break; case 'eval': doEval(msg, args); break; case 'status': doStatus(msg); break; + case '--script-lock': scripting = true; return; + case '--script-unlock': scripting = false; return; case '--echo': { // Internal: dry-run echo. Format: !gaslight --echo var echoRaw = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); @@ -2359,6 +2363,7 @@ var Gaslight = Gaslight || (() => { */ const viewInterceptor = (msg) => { if (msg.type !== 'api') return; + if (scripting) return; var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; var content = msg.content.trim(); From 8b1f59fc5ad535d7e4632f87c061e1e115706e5b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 12:49:25 -0400 Subject: [PATCH 61/96] RollCapture: emit charId, fire for all matching characters, warn on duplicates --- RollCapture/0.1.0/RollCapture.js | 37 ++++++++++++++++++++++---------- RollCapture/RollCapture.js | 37 ++++++++++++++++++++++---------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/RollCapture/0.1.0/RollCapture.js b/RollCapture/0.1.0/RollCapture.js index 972599c756..142c040f8c 100644 --- a/RollCapture/0.1.0/RollCapture.js +++ b/RollCapture/0.1.0/RollCapture.js @@ -201,6 +201,12 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (bareMatch) charName = bareMatch[1].replace(/\\"/g, '"').trim(); } + // Resolve character ID(s) + const chars = charName ? findObjs({ type: 'character', name: charName }) : []; + if (chars.length > 1) { + whisper(`⚠️ Multiple character sheets named ${charName} — capturing for all matches.`); + } + // Find matching block let activeBlock = null; for (const block of rule.blocks) { @@ -224,35 +230,39 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const value = evalFormula(formula, fieldMap); if (value && value.__choose) { hasChoose = true; - const context = { rule, rollName: cleanName(rollName), charName, playerId: msg.playerid, results, msg }; + const charId = chars.length > 0 ? chars[0].get('id') : null; + const context = { rule, rollName: cleanName(rollName), charName, charId, playerId: msg.playerid, results, msg, chars }; promptChoose(context, captureName, value.options); } else { - results[captureName] = value; // undefined = clear + results[captureName] = value; } } if (!hasChoose) { - emitCapture(charName, cleanName(rollName), results, msg.playerid, msg); + chars.forEach(c => { + emitCapture(charName, c.get('id'), cleanName(rollName), results, msg.playerid, msg); + }); + if (chars.length === 0) { + emitCapture(charName, null, cleanName(rollName), results, msg.playerid, msg); + } } } }; // ─── Callback Registry ────────────────────────────────────────────────────── - const emitCapture = (charName, rollName, captures, playerId, msg) => { - const event = { charName, rollName, captures, playerId, msg }; + const emitCapture = (charName, charId, rollName, captures, playerId, msg) => { + const event = { charName, charId, rollName, captures, playerId, msg }; for (const fn of callbacks.values()) { fn(event); } - fireAbility(charName, rollName, captures, playerId); + fireAbility(charId, rollName, captures, playerId); }; // ─── Ability Firing ───────────────────────────────────────────────────────── - const fireAbility = (charName, rollName, captures, playerId) => { - const chars = findObjs({ type: 'character', name: charName }); - if (chars.length === 0) return; - const charId = chars[0].get('id'); + const fireAbility = (charId, rollName, captures, playerId) => { + if (!charId) return; const abilities = findObjs({ type: 'ability', _characterid: charId }); const specificName = 'rc_' + rollName; @@ -292,7 +302,12 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (!ctx) return whisper('Choice expired or invalid.'); ctx.results[captureName] = parseInt(value, 10) || 0; delete pendingChoices[id]; - emitCapture(ctx.charName, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + (ctx.chars || []).forEach(c => { + emitCapture(ctx.charName, c.get('id'), ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + }); + if (!ctx.chars || ctx.chars.length === 0) { + emitCapture(ctx.charName, ctx.charId, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + } whisper(`Captured ${captureName} = ${value}`); return; } diff --git a/RollCapture/RollCapture.js b/RollCapture/RollCapture.js index 972599c756..142c040f8c 100644 --- a/RollCapture/RollCapture.js +++ b/RollCapture/RollCapture.js @@ -201,6 +201,12 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (bareMatch) charName = bareMatch[1].replace(/\\"/g, '"').trim(); } + // Resolve character ID(s) + const chars = charName ? findObjs({ type: 'character', name: charName }) : []; + if (chars.length > 1) { + whisper(`⚠️ Multiple character sheets named ${charName} — capturing for all matches.`); + } + // Find matching block let activeBlock = null; for (const block of rule.blocks) { @@ -224,35 +230,39 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars const value = evalFormula(formula, fieldMap); if (value && value.__choose) { hasChoose = true; - const context = { rule, rollName: cleanName(rollName), charName, playerId: msg.playerid, results, msg }; + const charId = chars.length > 0 ? chars[0].get('id') : null; + const context = { rule, rollName: cleanName(rollName), charName, charId, playerId: msg.playerid, results, msg, chars }; promptChoose(context, captureName, value.options); } else { - results[captureName] = value; // undefined = clear + results[captureName] = value; } } if (!hasChoose) { - emitCapture(charName, cleanName(rollName), results, msg.playerid, msg); + chars.forEach(c => { + emitCapture(charName, c.get('id'), cleanName(rollName), results, msg.playerid, msg); + }); + if (chars.length === 0) { + emitCapture(charName, null, cleanName(rollName), results, msg.playerid, msg); + } } } }; // ─── Callback Registry ────────────────────────────────────────────────────── - const emitCapture = (charName, rollName, captures, playerId, msg) => { - const event = { charName, rollName, captures, playerId, msg }; + const emitCapture = (charName, charId, rollName, captures, playerId, msg) => { + const event = { charName, charId, rollName, captures, playerId, msg }; for (const fn of callbacks.values()) { fn(event); } - fireAbility(charName, rollName, captures, playerId); + fireAbility(charId, rollName, captures, playerId); }; // ─── Ability Firing ───────────────────────────────────────────────────────── - const fireAbility = (charName, rollName, captures, playerId) => { - const chars = findObjs({ type: 'character', name: charName }); - if (chars.length === 0) return; - const charId = chars[0].get('id'); + const fireAbility = (charId, rollName, captures, playerId) => { + if (!charId) return; const abilities = findObjs({ type: 'ability', _characterid: charId }); const specificName = 'rc_' + rollName; @@ -292,7 +302,12 @@ const RollCapture = (() => { // eslint-disable-line no-unused-vars if (!ctx) return whisper('Choice expired or invalid.'); ctx.results[captureName] = parseInt(value, 10) || 0; delete pendingChoices[id]; - emitCapture(ctx.charName, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + (ctx.chars || []).forEach(c => { + emitCapture(ctx.charName, c.get('id'), ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + }); + if (!ctx.chars || ctx.chars.length === 0) { + emitCapture(ctx.charName, ctx.charId, ctx.rollName, ctx.results, ctx.playerId, ctx.msg); + } whisper(`Captured ${captureName} = ${value}`); return; } From ee9c647782562ea940cd000462794b25dcb8de30 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 13:03:16 -0400 Subject: [PATCH 62/96] =?UTF-8?q?Gaslight=20scripting:=20RollCapture=20int?= =?UTF-8?q?egration=20=E2=80=94=20onCapture=20writes=20attributes,=20token?= =?UTF-8?q?=20assignment=20with=20charId=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 106 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index da1fcdc812..a7e3e23bc1 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2174,6 +2174,39 @@ var Gaslight = Gaslight || (() => { case 'status': doStatus(msg); break; case '--script-lock': scripting = true; return; case '--script-unlock': scripting = false; return; + case '--assign-capture': { + // Format: --assign-capture ... + var acRollName = args[0]; + var acCharId = args[1]; + var acCaptures = {}; + args.slice(2).forEach(function(a) { + var eq = a.indexOf('='); + if (eq > 0) acCaptures[a.slice(0, eq)] = a.slice(eq + 1); + }); + var acTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === acCharId; + }); + if (acTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + acTokens.forEach(function(t) { writeCapturesToToken(t, acRollName, acCaptures); }); + reply(msg, 'Capture', 'Assigned ' + acRollName + ' to ' + acTokens.length + ' token(s).'); + return; + } + case '--clear-capture': { + // Format: --clear-capture + var ccRollName = args[0]; + var ccCharId = args[1]; + var ccTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === ccCharId; + }); + if (ccTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + ccTokens.forEach(function(t) { + var gmnotes = decodeURIComponent(t.get('gmnotes') || ''); + gmnotes = gmnotes.replace(new RegExp('(^|\\n)gl_' + ccRollName + '_[^=]+=([^\\n]*)', 'g'), ''); + t.set('gmnotes', gmnotes.trim()); + }); + reply(msg, 'Capture', 'Cleared ' + ccRollName + ' overrides from ' + ccTokens.length + ' token(s).'); + return; + } case '--echo': { // Internal: dry-run echo. Format: !gaslight --echo var echoRaw = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); @@ -2235,6 +2268,70 @@ var Gaslight = Gaslight || (() => { } }; + // ========================================================================= + // RollCapture Integration + // ========================================================================= + + const registerWithRollCapture = () => { + if (typeof RollCapture === 'undefined' || !RollCapture.onCapture) return; + RollCapture.onCapture(SCRIPT_NAME, onCaptureReceived); + }; + + const onCaptureReceived = (event) => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + + var { charName, charId, rollName, captures, playerId, msg } = event; + var selected = (msg && msg.selected) || []; + + // Always write to character attribute + if (charId) { + Object.entries(captures).forEach(function(entry) { + var attrName = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var attr = findObjs({ type: 'attribute', _characterid: charId, name: attrName })[0]; + if (val === undefined) { + if (attr) attr.remove(); + } else { + if (attr) attr.set('current', String(val)); + else createObj('attribute', { _characterid: charId, name: attrName, current: String(val) }); + } + }); + } + + // Token assignment — only count tokens representing this character + var tokens = selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === charId; + }); + + if (tokens.length === 1) { + writeCapturesToToken(tokens[0], rollName, captures); + } else if (tokens.length > 1) { + // Build simple space-separated args: rollName charId captureName=value ... + var captureArgs = Object.entries(captures).map(function(e) { return e[0] + '=' + e[1]; }).join(' '); + whisper('**' + charName + '** rolled **' + rollName + '**: ' + captureArgs + + '
      [Assign to selected](' + CMD + ' --assign-capture ' + rollName + ' ' + charId + ' ' + captureArgs + ')' + + ' [Clear overrides](' + CMD + ' --clear-capture ' + rollName + ' ' + charId + ')'); + } + }; + + const writeCapturesToToken = (token, rollName, captures) => { + var gmnotes = decodeURIComponent(token.get('gmnotes') || ''); + Object.entries(captures).forEach(function(entry) { + var field = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var rx = new RegExp('(^|\\n)' + field + '=[^\\n]*'); + if (val === undefined) { + gmnotes = gmnotes.replace(rx, ''); + } else if (gmnotes.match(rx)) { + gmnotes = gmnotes.replace(rx, '$1' + field + '=' + val); + } else { + gmnotes = gmnotes.trim() + '\n' + field + '=' + val; + } + }); + token.set('gmnotes', gmnotes); + }; + // ========================================================================= // Initialization // ========================================================================= @@ -2458,14 +2555,9 @@ var Gaslight = Gaslight || (() => { on('chat:message', handleInput); on('chat:message', viewInterceptor); on('chat:message', function(msg) { - if (msg.rolltemplate && msg.inlinerolls) { - log(SCRIPT_NAME + ' [ROLL]: template=' + msg.rolltemplate + ', inlinerolls count=' + msg.inlinerolls.length); - msg.inlinerolls.forEach(function(r, i) { - log(SCRIPT_NAME + ' [' + i + ']: total=' + (r.results ? r.results.total : 'N/A')); - }); - log(SCRIPT_NAME + ' content=' + msg.content.slice(0, 200)); - } + if (msg.type === 'api' && msg.content === '!rollcapture-ready') registerWithRollCapture(); }); + registerWithRollCapture(); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); on('change:attribute', onAttributeChanged); From 45dafd7f9fb04b27672bf20e8e87185a7b9cfa1a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 14:42:45 -0400 Subject: [PATCH 63/96] Gaslight scripting: viewer aggregation (any/all/max/min), quote-aware parsing, bare viewer error check --- Gaslight/Gaslight.js | 192 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index a7e3e23bc1..351f2c18a9 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1976,6 +1976,185 @@ var Gaslight = Gaslight || (() => { * Evaluate a script for a specific target token and viewer. * Resolves target to the linked copy on the viewer's page. */ + // ─── Viewer Aggregation ──────────────────────────────────────────────────── + + const OPS = ['>=', '<=', '!=', '!~', '=', '~', '>', '<']; + + /** + * Find `search` in `str` starting at `startIdx`, skipping quoted regions. + * Returns index or -1. + */ + const findUnquoted = (str, search, startIdx) => { + var inQuote = null; + for (var i = startIdx || 0; i <= str.length - search.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (str.slice(i, i + search.length) === search) return i; + } + return -1; + }; + + /** + * Find the matching close paren for an open paren at `start`. + * Skips quoted strings. Returns index of closing paren or -1. + */ + const findCloseParen = (str, start) => { + var depth = 0; + var inQuote = null; + for (var i = start; i < str.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (ch === '(') depth++; + else if (ch === ')') { depth--; if (depth === 0) return i; } + } + return -1; + }; + + /** + * Extract operator and RHS starting at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced ), ||, &&, or }. + * Returns { op, rhs, end } or null. + */ + const extractOpRhs = (str, pos) => { + var rest = str.slice(pos).replace(/^\s*/, ''); + var offset = pos + (str.slice(pos).length - rest.length); + for (var i = 0; i < OPS.length; i++) { + if (rest.startsWith(OPS[i])) { + var afterOp = rest.slice(OPS[i].length).replace(/^\s*/, ''); + var opEnd = offset + OPS[i].length + (rest.slice(OPS[i].length).length - afterOp.length); + var rhs = ''; + var depth = 0; + var inQ = null; + var j = 0; + for (; j < afterOp.length; j++) { + var c = afterOp[j]; + if (inQ) { if (c === inQ) inQ = null; rhs += c; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; rhs += c; continue; } + if (c === '(') { depth++; rhs += c; continue; } + if (c === ')') { if (depth === 0) break; depth--; rhs += c; continue; } + if (depth === 0 && j + 1 < afterOp.length && (afterOp.slice(j, j + 2) === '||' || afterOp.slice(j, j + 2) === '&&')) break; + if (c === '}') break; + rhs += c; + } + return { op: OPS[i], rhs: rhs.trim(), end: opEnd + j }; + } + } + return null; + }; + + /** + * Extract operator and LHS ending at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced (, ||, &&, or {&. + * Returns { op, lhs, start } or null. + */ + const extractOpLhs = (str, pos) => { + var before = str.slice(0, pos).replace(/\s*$/, ''); + for (var i = 0; i < OPS.length; i++) { + if (before.endsWith(OPS[i])) { + var beforeOp = before.slice(0, -OPS[i].length).replace(/\s*$/, ''); + var lhs = ''; + var depth = 0; + var inQ = null; + var j = beforeOp.length - 1; + for (; j >= 0; j--) { + var c = beforeOp[j]; + if (inQ) { if (c === inQ) inQ = null; lhs = c + lhs; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; lhs = c + lhs; continue; } + if (c === ')') { depth++; lhs = c + lhs; continue; } + if (c === '(') { if (depth === 0) break; depth--; lhs = c + lhs; continue; } + if (j > 0 && (beforeOp.slice(j - 1, j + 1) === '||' || beforeOp.slice(j - 1, j + 1) === '&&')) { j--; break; } + lhs = c + lhs; + } + return { op: OPS[i], lhs: lhs.trim(), start: j + 1 }; + } + } + return null; + }; + + /** + * Expand any()/all()/max()/min() viewer aggregates. + * Sweep 1: any (LHS then RHS) + * Sweep 2: all (LHS then RHS) + * Sweep 3: max/min (resolve to literal) + */ + const expandAggregates = (content, viewerIds) => { + if (viewerIds.length === 0) return content; + + // Sweep: any + content = expandAggregate(content, 'any', '||', viewerIds); + // Sweep: all + content = expandAggregate(content, 'all', '&&', viewerIds); + // Sweep: max/min + content = resolveMaxMin(content, viewerIds); + + return content; + }; + + const expandAggregate = (content, funcName, joiner, viewerIds) => { + var search = funcName + '('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + // Skip if part of a longer word + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + funcName.length); + if (closeIdx === -1) break; + + var inner = content.slice(idx + funcName.length + 1, closeIdx); + var beforeAgg = content.slice(0, idx); + var afterAgg = content.slice(closeIdx + 1); + + // Check for op + RHS after the aggregate + var opRhs = extractOpRhs(afterAgg, 0); + if (opRhs) { + var expanded = '(' + viewerIds.map(function(id) { + return inner.replace(/@\(viewer\./g, '@(' + id + '.') + ' ' + opRhs.op + ' ' + opRhs.rhs; + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg.slice(opRhs.end); + } else { + // Check for LHS + op before the aggregate + var opLhs = extractOpLhs(beforeAgg, beforeAgg.length); + if (opLhs) { + var expanded = '(' + viewerIds.map(function(id) { + return opLhs.lhs + ' ' + opLhs.op + ' ' + inner.replace(/@\(viewer\./g, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg.slice(0, opLhs.start) + expanded + afterAgg; + } else { + // No operator context — just expand inner with joiner (bare boolean) + var expanded = '(' + viewerIds.map(function(id) { + return inner.replace(/@\(viewer\./g, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg; + } + } + + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + + const resolveMaxMin = (content, viewerIds) => { + ['max', 'min'].forEach(function(fn) { + var search = fn + '('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + fn.length); + if (closeIdx === -1) break; + var inner = content.slice(idx + fn.length + 1, closeIdx); + if (inner.indexOf('@(viewer.') !== -1) { + var expanded = '{& math ' + fn + '(' + viewerIds.map(function(id) { + return inner.replace(/@\(viewer\./g, '@(' + id + '.'); + }).join(', ') + ')}'; + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + }); + return content; + }; + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, viewerPageId, config, msg, dryRun) => { // Find the linked token on the viewer's page var viewerTarget = findLinkedTokenOnPage(targetToken, viewerPageId); @@ -1998,7 +2177,7 @@ var Gaslight = Gaslight || (() => { }); // Replace remaining @(target.*) with token ID — Fetch resolves native props content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); - // Replace @(viewer.*) with viewer's controlled token ID (first found) + // Resolve viewer tokens for aggregation var viewerTokens = findObjs({ _type: 'graphic', _pageid: viewerPageId, _subtype: 'token' }).filter(function(t) { var cid = t.get('represents'); if (!cid) return false; @@ -2007,8 +2186,15 @@ var Gaslight = Gaslight || (() => { var cb = c.get('controlledby') || ''; return cb === 'all' || cb.split(',').indexOf(viewerPlayerId) !== -1; }); - if (viewerTokens.length > 0) { - content = content.replace(/@\(viewer\./g, '@(' + viewerTokens[0].get('id') + '.'); + var viewerIds = viewerTokens.map(function(t) { return t.get('id'); }); + + // Expand any()/all() aggregates, then resolve max()/min() + content = expandAggregates(content, viewerIds); + + // Error check: bare @(viewer.*) without aggregate + if (content.indexOf('@(viewer.') !== -1) { + whisper('⚠️ Script error: @(viewer.*) must be inside any(), all(), max(), or min()'); + return; } var lines = content.split('\n').filter(function(l) { From 5646ee518be5baa7dc804010cdfea425c2341e53 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 15:14:20 -0400 Subject: [PATCH 64/96] Gaslight scripting: fix HTML entity decoding in handout content, gl_ field fallback to character attribute --- Gaslight/Gaslight.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 351f2c18a9..3a025c1e89 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1827,7 +1827,16 @@ var Gaslight = Gaslight || (() => { var handout = getObj('handout', handoutId); if (!handout) { callback(null); return; } handout.get('notes', function(notes) { - callback(notes || ''); + if (!notes) { callback(''); return; } + var text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + callback(text); }); }; @@ -2168,12 +2177,15 @@ var Gaslight = Gaslight || (() => { var content = scriptContent; // Resolve @(target.gl_*) ourselves since Fetch compProps don't fire for sendChat messages content = content.replace(/@\(target\.(gl_[a-zA-Z0-9_]+)\)/g, function(match, field) { + var val = ''; if (config.scope === 'token') { - return readGlField(viewerTarget.get('gmnotes'), field); - } else { + val = readGlField(viewerTarget.get('gmnotes'), field); + } + if (!val) { var charId = viewerTarget.get('represents'); - return charId ? (getAttrByName(charId, field) || '') : ''; + val = charId ? (getAttrByName(charId, field) || '') : ''; } + return val; }); // Replace remaining @(target.*) with token ID — Fetch resolves native props content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); From 13cbfe169336733e510fc097d92208254f2a31d4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 16:35:20 -0400 Subject: [PATCH 65/96] Gaslight scripting: fix extractOpLhs || boundary, HTML entity decoding, gl_ fallback, restore script-lock, remove debug --- Gaslight/Gaslight.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 3a025c1e89..c6b7a165b2 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2073,7 +2073,7 @@ var Gaslight = Gaslight || (() => { if (c === '"' || c === "'" || c === '`') { inQ = c; lhs = c + lhs; continue; } if (c === ')') { depth++; lhs = c + lhs; continue; } if (c === '(') { if (depth === 0) break; depth--; lhs = c + lhs; continue; } - if (j > 0 && (beforeOp.slice(j - 1, j + 1) === '||' || beforeOp.slice(j - 1, j + 1) === '&&')) { j--; break; } + if (j > 0 && (beforeOp.slice(j - 1, j + 1) === '||' || beforeOp.slice(j - 1, j + 1) === '&&')) { break; } lhs = c + lhs; } return { op: OPS[i], lhs: lhs.trim(), start: j + 1 }; @@ -2105,7 +2105,6 @@ var Gaslight = Gaslight || (() => { var search = funcName + '('; var idx = findUnquoted(content, search, 0); while (idx !== -1) { - // Skip if part of a longer word if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } var closeIdx = findCloseParen(content, idx + funcName.length); if (closeIdx === -1) break; @@ -2114,7 +2113,6 @@ var Gaslight = Gaslight || (() => { var beforeAgg = content.slice(0, idx); var afterAgg = content.slice(closeIdx + 1); - // Check for op + RHS after the aggregate var opRhs = extractOpRhs(afterAgg, 0); if (opRhs) { var expanded = '(' + viewerIds.map(function(id) { @@ -2122,7 +2120,6 @@ var Gaslight = Gaslight || (() => { }).join(' ' + joiner + ' ') + ')'; content = beforeAgg + expanded + afterAgg.slice(opRhs.end); } else { - // Check for LHS + op before the aggregate var opLhs = extractOpLhs(beforeAgg, beforeAgg.length); if (opLhs) { var expanded = '(' + viewerIds.map(function(id) { @@ -2130,7 +2127,6 @@ var Gaslight = Gaslight || (() => { }).join(' ' + joiner + ' ') + ')'; content = beforeAgg.slice(0, opLhs.start) + expanded + afterAgg; } else { - // No operator context — just expand inner with joiner (bare boolean) var expanded = '(' + viewerIds.map(function(id) { return inner.replace(/@\(viewer\./g, '@(' + id + '.'); }).join(' ' + joiner + ' ') + ')'; From 677127a9a5b0abb0533aa3a021956e1e4a2f380c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 16:38:37 -0400 Subject: [PATCH 66/96] =?UTF-8?q?Gaslight=20scripting:=20add=20gm.*=20name?= =?UTF-8?q?space=20=E2=80=94=20same=20aggregation=20as=20viewer.*=20but=20?= =?UTF-8?q?for=20GM=20tokens=20on=20master=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 66 ++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index c6b7a165b2..cec04201fb 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2088,21 +2088,17 @@ var Gaslight = Gaslight || (() => { * Sweep 2: all (LHS then RHS) * Sweep 3: max/min (resolve to literal) */ - const expandAggregates = (content, viewerIds) => { - if (viewerIds.length === 0) return content; - - // Sweep: any - content = expandAggregate(content, 'any', '||', viewerIds); - // Sweep: all - content = expandAggregate(content, 'all', '&&', viewerIds); - // Sweep: max/min - content = resolveMaxMin(content, viewerIds); - + const expandAggregates = (content, ids, namespace) => { + if (ids.length === 0) return content; + content = expandAggregate(content, 'any', '||', ids, namespace); + content = expandAggregate(content, 'all', '&&', ids, namespace); + content = resolveMaxMin(content, ids, namespace); return content; }; - const expandAggregate = (content, funcName, joiner, viewerIds) => { + const expandAggregate = (content, funcName, joiner, ids, namespace) => { var search = funcName + '('; + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); var idx = findUnquoted(content, search, 0); while (idx !== -1) { if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } @@ -2110,25 +2106,28 @@ var Gaslight = Gaslight || (() => { if (closeIdx === -1) break; var inner = content.slice(idx + funcName.length + 1, closeIdx); + // Only expand if this aggregate contains our namespace + if (inner.indexOf('@(' + namespace + '.') === -1) { idx = findUnquoted(content, search, idx + 1); continue; } + var beforeAgg = content.slice(0, idx); var afterAgg = content.slice(closeIdx + 1); var opRhs = extractOpRhs(afterAgg, 0); if (opRhs) { - var expanded = '(' + viewerIds.map(function(id) { - return inner.replace(/@\(viewer\./g, '@(' + id + '.') + ' ' + opRhs.op + ' ' + opRhs.rhs; + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.') + ' ' + opRhs.op + ' ' + opRhs.rhs; }).join(' ' + joiner + ' ') + ')'; content = beforeAgg + expanded + afterAgg.slice(opRhs.end); } else { var opLhs = extractOpLhs(beforeAgg, beforeAgg.length); if (opLhs) { - var expanded = '(' + viewerIds.map(function(id) { - return opLhs.lhs + ' ' + opLhs.op + ' ' + inner.replace(/@\(viewer\./g, '@(' + id + '.'); + var expanded = '(' + ids.map(function(id) { + return opLhs.lhs + ' ' + opLhs.op + ' ' + inner.replace(nsRx, '@(' + id + '.'); }).join(' ' + joiner + ' ') + ')'; content = beforeAgg.slice(0, opLhs.start) + expanded + afterAgg; } else { - var expanded = '(' + viewerIds.map(function(id) { - return inner.replace(/@\(viewer\./g, '@(' + id + '.'); + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); }).join(' ' + joiner + ' ') + ')'; content = beforeAgg + expanded + afterAgg; } @@ -2139,7 +2138,9 @@ var Gaslight = Gaslight || (() => { return content; }; - const resolveMaxMin = (content, viewerIds) => { + const resolveMaxMin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; ['max', 'min'].forEach(function(fn) { var search = fn + '('; var idx = findUnquoted(content, search, 0); @@ -2148,9 +2149,9 @@ var Gaslight = Gaslight || (() => { var closeIdx = findCloseParen(content, idx + fn.length); if (closeIdx === -1) break; var inner = content.slice(idx + fn.length + 1, closeIdx); - if (inner.indexOf('@(viewer.') !== -1) { - var expanded = '{& math ' + fn + '(' + viewerIds.map(function(id) { - return inner.replace(/@\(viewer\./g, '@(' + id + '.'); + if (inner.indexOf(nsCheck) !== -1) { + var expanded = '{& math ' + fn + '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); }).join(', ') + ')}'; content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); } @@ -2196,14 +2197,31 @@ var Gaslight = Gaslight || (() => { }); var viewerIds = viewerTokens.map(function(t) { return t.get('id'); }); - // Expand any()/all() aggregates, then resolve max()/min() - content = expandAggregates(content, viewerIds); + // Resolve GM tokens on master page for gm.* aggregation + var masterPageId = targetToken.get('_pageid'); + var gmTokens = findObjs({ _type: 'graphic', _pageid: masterPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return !cb || cb.split(',').every(function(id) { return id.trim() === '' || playerIsGM(id.trim()); }); + }); + var gmIds = gmTokens.map(function(t) { return t.get('id'); }); + + // Expand any()/all()/max()/min() aggregates for viewer.* and gm.* + content = expandAggregates(content, viewerIds, 'viewer'); + content = expandAggregates(content, gmIds, 'gm'); - // Error check: bare @(viewer.*) without aggregate + // Error check: bare @(viewer.*) or @(gm.*) without aggregate if (content.indexOf('@(viewer.') !== -1) { whisper('⚠️ Script error: @(viewer.*) must be inside any(), all(), max(), or min()'); return; } + if (content.indexOf('@(gm.') !== -1) { + whisper('⚠️ Script error: @(gm.*) must be inside any(), all(), max(), or min()'); + return; + } var lines = content.split('\n').filter(function(l) { l = l.trim(); From 9afef8da898bce5f603ed2a4351e20dee344e96f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 17:00:23 -0400 Subject: [PATCH 67/96] Gaslight scripting: always show assign button when no single token auto-resolved, remove debug logs --- Gaslight/Gaslight.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index cec04201fb..d5175b076b 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2518,8 +2518,8 @@ var Gaslight = Gaslight || (() => { if (tokens.length === 1) { writeCapturesToToken(tokens[0], rollName, captures); - } else if (tokens.length > 1) { - // Build simple space-separated args: rollName charId captureName=value ... + } else { + // 0 or multiple — prompt GM with assign button var captureArgs = Object.entries(captures).map(function(e) { return e[0] + '=' + e[1]; }).join(' '); whisper('**' + charName + '** rolled **' + rollName + '**: ' + captureArgs + '
      [Assign to selected](' + CMD + ' --assign-capture ' + rollName + ' ' + charId + ' ' + captureArgs + ')' + From 122f2d02d5593931561e5e83e15a67892133a4b9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 17:06:34 -0400 Subject: [PATCH 68/96] Gaslight scripting: fallback token resolution from charId on master page when msg.selected is empty --- Gaslight/Gaslight.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d5175b076b..32b2f8d0ac 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2516,14 +2516,27 @@ var Gaslight = Gaslight || (() => { return t && t.get('represents') === charId; }); + // Fallback: if no selection, find tokens of this character on master pages + if (tokens.length === 0 && charId) { + var masterPageIds = Object.values(s.activeGroups).map(function(g) { return g.masterPageId; }); + tokens = findObjs({ _type: 'graphic', _subtype: 'token', represents: charId }).filter(function(t) { + return masterPageIds.indexOf(t.get('_pageid')) !== -1; + }); + } + if (tokens.length === 1) { writeCapturesToToken(tokens[0], rollName, captures); } else { - // 0 or multiple — prompt GM with assign button - var captureArgs = Object.entries(captures).map(function(e) { return e[0] + '=' + e[1]; }).join(' '); - whisper('**' + charName + '** rolled **' + rollName + '**: ' + captureArgs + - '
      [Assign to selected](' + CMD + ' --assign-capture ' + rollName + ' ' + charId + ' ' + captureArgs + ')' + - ' [Clear overrides](' + CMD + ' --clear-capture ' + rollName + ' ' + charId + ')'); + // Only prompt if any captured field is referenced by an active script + var hasRelevantTrigger = Object.keys(captures).some(function(cap) { + return triggerMap['gl_' + rollName + '_' + cap]; + }); + if (hasRelevantTrigger) { + var captureArgs = Object.entries(captures).map(function(e) { return e[0] + '=' + e[1]; }).join(' '); + whisper('**' + charName + '** rolled **' + rollName + '**: ' + captureArgs + + '
      [Assign to selected](' + CMD + ' --assign-capture ' + rollName + ' ' + charId + ' ' + captureArgs + ')' + + ' [Clear overrides](' + CMD + ' --clear-capture ' + rollName + ' ' + charId + ')'); + } } }; From 0995a3e4fed3ce7f23b6fea6b35e7b00ed99d49e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 17:16:08 -0400 Subject: [PATCH 69/96] Gaslight scripting: fix pin type (revert to 'pin'), fix gmnotes gl_ regex to match = separator --- Gaslight/Gaslight.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 32b2f8d0ac..49828c1e97 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1771,7 +1771,7 @@ var Gaslight = Gaslight || (() => { try { newNotes = decodeURIComponent(newNotes); } catch(e) {} // Parse gl_ fields from old and new - var glRx = /gl_([a-zA-Z0-9_]+)\s*:\s*(.+)/g; + var glRx = /gl_([a-zA-Z0-9_]+)\s*[=:]\s*(.+)/g; var oldFields = {}; var newFields = {}; var m; From 37106fed0ef126ddeb648022c77041cbc6ec9c63 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 17:24:05 -0400 Subject: [PATCH 70/96] Gaslight scripting: manually trigger pin evaluation after capture write, collect pins then evaluate once --- Gaslight/Gaslight.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 49828c1e97..3a38ff66fb 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2538,6 +2538,18 @@ var Gaslight = Gaslight || (() => { ' [Clear overrides](' + CMD + ' --clear-capture ' + rollName + ' ' + charId + ')'); } } + + // Manually trigger pin evaluation for changed capture fields + var fakeMsg = { playerid: playerId || 'API', who: 'API', type: 'api' }; + var pins = Object.keys(captures).reduce(function(acc, cap) { + var entries = triggerMap['gl_' + rollName + '_' + cap] || []; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (pin && acc.indexOf(pin) === -1) acc.push(pin); + }); + return acc; + }, []); + if (pins.length > 0) evaluatePins(pins, fakeMsg, false); }; const writeCapturesToToken = (token, rollName, captures) => { From cfedbde7c9c735bbc918bd5fc312f3e8efd29fa3 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 18:03:24 -0400 Subject: [PATCH 71/96] Gaslight scripting: support // comments in scripts (full-line and inline) --- Gaslight/Gaslight.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 3a38ff66fb..9d95eb2302 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2223,8 +2223,10 @@ var Gaslight = Gaslight || (() => { return; } - var lines = content.split('\n').filter(function(l) { - l = l.trim(); + var lines = content.split('\n').map(function(l) { + var ci = l.indexOf('//'); + return (ci !== -1 ? l.slice(0, ci) : l).trim(); + }).filter(function(l) { return l && (l.startsWith('!') || l.startsWith('{&')); }); From 201e858c4a4590669ab8dcda82d3bbf7c830162d Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 18:24:43 -0400 Subject: [PATCH 72/96] Gaslight scripting: add join() aggregate for viewer/gm token IDs in commands, optional delimiter --- Gaslight/Gaslight.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 9d95eb2302..7e16878264 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2093,6 +2093,7 @@ var Gaslight = Gaslight || (() => { content = expandAggregate(content, 'any', '||', ids, namespace); content = expandAggregate(content, 'all', '&&', ids, namespace); content = resolveMaxMin(content, ids, namespace); + content = resolveJoin(content, ids, namespace); return content; }; @@ -2161,6 +2162,35 @@ var Gaslight = Gaslight || (() => { return content; }; + const resolveJoin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; + var search = 'join('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + 4); + if (closeIdx === -1) break; + var inner = content.slice(idx + 5, closeIdx); + if (inner.indexOf(nsCheck) !== -1) { + // Check for optional delimiter: join(@(viewer.field), ",") + var parts = inner.split(','); + var field = parts[0].trim(); + var delim = ' '; + if (parts.length > 1) { + var rawDelim = parts.slice(1).join(',').trim(); + delim = rawDelim.replace(/^['"`]|['"`]$/g, ''); + } + var expanded = ids.map(function(id) { + return field.replace(nsRx, '@(' + id + '.'); + }).join(delim); + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, viewerPageId, config, msg, dryRun) => { // Find the linked token on the viewer's page var viewerTarget = findLinkedTokenOnPage(targetToken, viewerPageId); From 2a2dd71e03849fea943b8abc80b0c0f98779f5f5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 18:57:38 -0400 Subject: [PATCH 73/96] Gaslight scripting: add scripting section to help handout, eval command in commands list, remove SCRIPTING_DESIGN.md --- Gaslight/Gaslight.js | 23 +++ Gaslight/SCRIPTING_DESIGN.md | 276 ----------------------------------- 2 files changed, 23 insertions(+), 276 deletions(-) delete mode 100644 Gaslight/SCRIPTING_DESIGN.md diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7e16878264..95fc714800 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2637,6 +2637,7 @@ var Gaslight = Gaslight || (() => { '

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

      ', '

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

      ', '

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

      ', + '

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

      ', '

      !gaslight status — Show state

      ', '

      Auto-Relay

      ', '

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

      ', @@ -2665,6 +2666,28 @@ var Gaslight = Gaslight || (() => { '

      Staging

      ', '

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

      ', '

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

      ', + '

      Scripting

      ', + '

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

      ', + '

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

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

      Script syntax:

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

      Variables:

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

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

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

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

      ', + '

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

      ', + '

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

      ', ].join('')); }; diff --git a/Gaslight/SCRIPTING_DESIGN.md b/Gaslight/SCRIPTING_DESIGN.md deleted file mode 100644 index e17b1c183c..0000000000 --- a/Gaslight/SCRIPTING_DESIGN.md +++ /dev/null @@ -1,276 +0,0 @@ -# Gaslight Scripting — Design Document - -## Concept - -A reactive automation layer within Gaslight that evaluates conditions per-player and applies per-player actions (show/hide/set properties). Scripts are stored in handouts, activated per-page via pins, and triggered automatically when referenced values change. - -## Motivating Example - -A stealthing creature should be invisible to players whose passive perception is below the creature's stealth roll, and visible to those who meet or exceed it: - -``` -if target.stealth_result > viewer.passive_perception: - set target.baseOpacity = 0 -else: - set target.baseOpacity = 1 -``` - -## Architecture - -### Storage - -- **Handout** (notes or gmnotes) = the reusable script logic -- **Pin** on a page = "this script is active here" - - `link` → handout ID (or empty for self-contained pin scripts) - - `gmNotes` → pin-specific configuration (scope, filter, trigger rules). Inherits from linked handout's GM notes by default unless desynced. - - Pin `notes` can contain the script itself for self-contained one-off scripts (no handout needed) - -### Pin Placement - -- **Pin on master page** → script evaluates for ALL viewers (normal case) -- **Pin on a player page** → script evaluates for ONLY that player (per-player override/special effect) - - Use case: hallucinations, player-specific illusions, per-player narrative moments - - Consistent with Gaslight's master/player-page distinction - -### Scope (configured per-pin) - -The pin's gmNotes declares how the script iterates: - -- `scope: token` — runs for each individual token on the page. Per-token data stored in token gmnotes. -- `scope: character` — runs once per character, applies to all tokens of that character. Data stored as character sheet attributes. - -### Targets (configured per-pin) - -Filter which tokens/characters the script evaluates against: - -- `filter: npc` — only tokens not controlled by any player -- `filter: has ` — only tokens/characters with a specific field set -- `filter: tag ` — only characters with a specific tag -- `filter: all` — every token on the page -- Custom filter expressions (v2) - -### Variables - -Two primary namespaces resolved by Gaslight: - -- `target.*` — the token/character being evaluated - - Resolved from gmnotes (scope: token) or character attribute (scope: character) - - Also includes standard token properties and character attributes -- `viewer.*` — the player whose page we're evaluating - - Represents the viewing PLAYER, not a single token - - A player may control multiple tokens on their page - - `viewer.*` attribute references iterate over each controlled token by default ("each" semantics) - - If ANY viewer token's evaluation passes, the action applies (most permissive wins) - - Aggregation functions available: `max(viewer.passive_perception)`, `min(...)`, `any(...)`, `all(...)` - - `all(...)` requires every viewer token to pass - - Player-level properties (viewer.id, viewer.name, viewer.page) are singular, not iterated - - Party-tagged tokens may be used as a narrowing hint but do NOT guarantee a single token -- `gm.*` — targets the master page (opt-in) - - If the script references `gm.*`, the script also evaluates on master page - - If only `viewer.*` is referenced, master page is untouched - - Use case: GM-side indicators (e.g. transparent overlay to show stealth status) - -### Integration with Meta-Toolbox - -**Required dependencies:** -- ZeroFrame — ensures processing order -- Fetch — attribute/property resolution; extended by Gaslight via compProps -- Muler — context variable injection (viewer identity) - -**Optional:** -- APILogic — if/elseif/else conditionals -- MathOps — inline math - -**Fetch extension:** -Gaslight registers computed properties on `Fetch.CustomPropsByType.graphic.compProps` when script handouts are created or modified. Properties use the `gl_` prefix as a namespace. Resolution depends on evaluation context (scope): - -- `scope: token` → reads from token gmnotes field `gl_: ` -- `scope: character` → reads from character attribute named `gl_` - -Convention: the `gl_` prefix is used consistently everywhere — in gmnotes, character attributes, AND script references. No stripping. - -```javascript -// Registered dynamically per gl_ field found in active scripts -Fetch.CustomPropsByType.graphic.compProps['gl_stealth_result'] = { - nicks: [], - val: (o) => { - if (evaluationContext.scope === 'token') { - return readGmNotesField(o.gmnotes, 'gl_stealth_result'); - } else { - return getAttrByName(o.represents, 'gl_stealth_result'); - } - } -}; -``` - -Script reference: `@(target.gl_stealth_result)` - -CompProps are registered at handout creation/modification time — Gaslight watches `change:handout` and `add:handout`, scans the content for `gl_*` references, and registers any new compProps. This ensures they're ready before any evaluation fires. - -**Muler injection:** -Before each evaluation pass, Gaslight sends a Muler set command to establish viewer context variables (viewer.id, viewer.name, viewer.page, etc.). - -### Triggers - -**Auto-detection (default):** -Gaslight parses the script and identifies references inside conditional blocks (`{& if}` ... `{& end}`). Only condition inputs are watched — action outputs (inside `!` command lines) are NOT triggers. This prevents infinite loops. - -**Manual override (pin gmNotes):** -``` -trigger: auto ← default, derive from conditions -trigger: manual only ← only fires via !gaslight eval -trigger: on change gl_stealth_result ← explicit field watch (additive) -trigger: on roll "Stealth" ← chat roll capture -trigger: ignore passive_perception ← exclude from auto-detection -``` - -Multiple trigger lines are additive. `manual only` disables all auto-triggers. - -**Manual evaluation:** -- `!gaslight eval` (with pins selected) — evaluate selected pins -- `!gaslight eval ` — evaluate all pins linked to that handout -- `!gaslight eval --all` — re-evaluate all active pins - -### Chat Roll Capture - -**Trigger rule** (in pin gmNotes): -``` -trigger: roll "Stealth" → stealth_result -``` - -**Resolution order:** -1. Selected token at time of roll → store result on that token -2. Ambiguous (none/multiple selected, not enough rolls) → queue and prompt GM with clickable buttons -3. Future: apply to all tokens of that character (configurable) - -**Storage:** -- `scope: token` → writes to token gmnotes: `stealth_result: ` -- `scope: character` → writes to character attribute: `stealth_result = ` - -### Evaluation Flow - -For each trigger event: -1. Identify which scripts are affected (which pins reference the changed field) -2. For each affected script, for each player page: - a. Set module-level `evaluationContext` (target token, viewer player) - b. Inject viewer context via Muler - c. `sendChat` the script content through ZeroFrame/Fetch/APILogic pipeline - d. Target script (e.g. token-mod) executes the resulting command - -### Pin gmNotes Format - -``` ----GASLIGHT-SCRIPT--- -scope: token -filter: has stealth_result -trigger: roll "Stealth" → stealth_result -``` - -### Handout Format - -The handout notes/gmnotes contain commands using standard Meta-Toolbox syntax: - -``` -{& if @(target.stealth_result) > @(viewer.passive_perception)} -!token-mod --ids @(target.token_id) --set baseOpacity|0 -{& else} -!token-mod --ids @(target.token_id) --set baseOpacity|1 -{& end} -``` - -## Resolved Questions - -1. **Field detection for auto-triggers:** Regex parse `@(target.gl_*)` and `@(viewer.*)` patterns inside `{& if}` blocks. Basic bracket matching to distinguish conditions from actions. - -2. **Multi-line scripts:** Yes. Multiple commands per evaluation, executed sequentially. APILogic likely handles this natively. - -3. **Error handling:** Try/catch around our resolution/sendChat phase. Whisper GM on errors (missing attributes, bad pin config, missing handout). Downstream script errors are outside our control. - -4. **Dry run:** `!gaslight eval --dry` (pins selected). Shows resolved commands and affected tokens without applying. - -5. **Performance:** Single `on('change:attribute')` handler with a trigger map for O(1) lookup: - ``` - triggerMap = { - 'gl_stealth_result': [{ pinId, handoutId, scope }], - 'passive_perception': [{ pinId, handoutId, scope }] - }; - ``` - Built at handout parse time. Rebuilt on handout change. Debounce per-script (100ms). - -6. **Script composition:** Deferred to v3. Scripts are self-contained for now. - -7. **Standard token properties:** Fetch handles natively. We only register `gl_*` compProps. - -## Roll Capture - -Roll capture is handled by the separate **RollCapture** script (see `RollCapture/DESIGN.md`). RollCapture monitors chat for roll results, extracts values, and stores them in `gl_*` fields. Gaslight integrates via `RollCapture.onCapture()` callback to trigger script re-evaluation when values change. - -**Dependency:** RollCapture is optional. Without it, `gl_*` values can be set manually (via gmnotes editing or chat commands). With it, rolls are automatically captured and stored. - -See `RollCapture/DESIGN.md` for full architecture, rule format, and D&D 5E default configuration. - -### D&D 5E Roll Message Structure (Observed) - -**NPCs** (template: `npc`): -``` -content: {{name=Blackguard}} {{rname=^{stealth}}} {{mod=1}} {{r1=$[[0]]}} {{query=1}} {{normal=1}} {{r2=$[[1]]}} {{type=Skill}} -inlinerolls: [0]=total, [1]=total (always 2 rolls) -``` - -**PCs** (template: `simple`): -``` -content: {{rname=^{stealth-u}}} {{mod=12}} {{r1=$[[0]]}} {{query=1}} {{normal=1}} {{r2=$[[1]]}} {{global=}} {{charname=Leilah "Obscura"}} -inlinerolls: [0]=total, [1]=total (always 2 rolls) -``` - -**Advantage flags** (mutually exclusive): -- `{{normal=1}}` → use r1 (index 0) -- `{{advantage=1}}` → use max(r1, r2) -- `{{disadvantage=1}}` → use min(r1, r2) -- `{{always=1}}` → ambiguous; default to max(r1, r2) - -**Character identification:** -- NPC: `{{name=X}}` (when "add name to template" is on) -- PC: `{{charname=X}}` or bare `charname=X` at end of content - -**Skill name:** -- NPC: `{{rname=^{stealth}}}` (translation key format) -- PC: `{{rname=^{stealth-u}}}` (with `-u` suffix) - -### Proposed Capture Rule Format - -``` ----GLS-CAPTURE--- -template: npc, simple -name_field: rname -char_field: name, charname -value: r1=0, r2=1 -advantage: {{advantage=1}} → max(r1,r2) -disadvantage: {{disadvantage=1}} → min(r1,r2) -normal: {{normal=1}} → r1 -always: {{always=1}} → max(r1,r2) -variable: gl_${rname} -``` - -Fields: -- `template` — which roll templates to match (comma-separated) -- `name_field` — which template field contains the skill/ability name -- `char_field` — which template field(s) contain the character name (for identification) -- `value` — maps symbolic names to inline roll indices -- `advantage/disadvantage/normal/always` — condition → extraction rule -- `variable` — gl_ field name pattern (`${rname}` substitutes the matched skill name) - -### Roll Capture Open Questions (Continued) - -1. Should capture rules be active only on gaslit pages, or always active (so rolls captured before split are ready)? -2. How to handle roll results that arrive before any script references the field (pre-capture)? -3. Should there be a `!gaslight captures` command to list active capture rules and recent captured values? -4. Can we support ScriptCards output as a capture source? - -## Future Ideas - -- Visual script editor (handout with structured format) -- Script debugging/logging mode -- Conditional FX (play effects based on script results) -- Script templates (pre-built stealth, darkvision, illusion scripts) -- Event history (log of what changed and why) From 3a9c26294531a5d628e98dbf46eaf9ff2c91480a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 19:02:53 -0400 Subject: [PATCH 74/96] =?UTF-8?q?Gaslight=20v2.0.0:=20scripting=20engine?= =?UTF-8?q?=20=E2=80=94=20reactive=20per-player=20automation,=20RollCaptur?= =?UTF-8?q?e=20integration,=20aggregation,=20versioned=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/2.0.0/Gaslight.js | 1313 ++++++++++++++++++++++++++++++------ Gaslight/Gaslight.js | 3 +- Gaslight/README.md | 49 ++ Gaslight/script.json | 2 +- 4 files changed, 1144 insertions(+), 223 deletions(-) diff --git a/Gaslight/2.0.0/Gaslight.js b/Gaslight/2.0.0/Gaslight.js index fe448bc19d..63648f7284 100644 --- a/Gaslight/2.0.0/Gaslight.js +++ b/Gaslight/2.0.0/Gaslight.js @@ -1,23 +1,30 @@ // ============================================================================= // Gaslight v2.0.0 -// Last Updated: 2026-06-14 +// Last Updated: 2026-06-25 // Author: Kenan Millet // // Description: // Per-player map perception. Split players onto individual copies of a page -// with tokens synchronized via Anchor. Each player can see different things -// while token movement stays consistent across all copies. +// with tokens synchronized via Anchor and Mirror. Each player can see +// different things while token movement stays consistent across all copies. +// Commands auto-relay to all player pages transparently. // -// Dependencies: Anchor +// Dependencies: Anchor, Mirror, SelectManager, RollCapture (optional) // // Commands: -// !gaslight split Activate a prepared gaslight group +// !gaslight setup Quick-configure from duplicates +// !gaslight split [--force] Activate a prepared group // !gaslight merge [group] Tear down links, return players // !gaslight test Dry-run linking resolution // !gaslight link [|new] [ids...] Set gaslight_link on tokens -// !gaslight unlink [ids...] Remove gaslight_link from tokens -// !gaslight group Assign page to group -// !gaslight master Designate page as group master +// !gaslight unlink [ids...|--group ] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight ungroup Remove page from group +// !gaslight stage [players...] Propagate tokens to player pages +// !gaslight view [player|master] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight eval [--dry-run] [--all|] Evaluate script pins // !gaslight status Show current state // !gaslight --help Command reference // ============================================================================= @@ -32,6 +39,20 @@ var Gaslight = Gaslight || (() => { const CMD = '!gaslight'; const CONFIG_HEADER = '---GASLIGHT---'; const LINK_KEY = 'gaslight_link'; + const GLS_TAG = '[GLS]'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const stripGlsTag = (name) => { + return (name || '').replace(/^\[GLS\]\s*/i, '').trim(); + }; + + var relaying = new Set(); + var scripting = false; + + const relayKey = (content, sender, selectedIds) => content + '\x01' + sender + '\x01' + selectedIds.sort().join(','); // ========================================================================= // Helpers @@ -261,7 +282,8 @@ var Gaslight = Gaslight || (() => { const getLinkId = (token) => { var notes = token.get('gmnotes') || ''; try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } - const match = notes.match(/gaslight_link:\s*(.+)/); + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_link:\s*(\S+)/); return match ? match[1].trim() : null; }; @@ -947,6 +969,10 @@ var Gaslight = Gaslight || (() => { summary += formatWarnings(globalWarnings); reply(msg, 'Split', summary); + // Build trigger map and register Fetch compProps for scripting engine + buildTriggerMap(); + registerAllCompProps(); + // Focus-ping each player to their character token on their page setTimeout(function() { Object.entries(groupInfo.players).forEach(function(entry) { @@ -1223,163 +1249,59 @@ var Gaslight = Gaslight || (() => { }; /** - * Shared relay execution: sends command to linked tokens on target pages. - * Returns number of tokens relayed to. - */ - /** - * Find all Roll20 IDs (starting with -) in a command string that match linked tokens. - * Returns { found: [{id, linkedIds}], hasIds: bool } - */ - const findLinkedIdsInCommand = (command, activeGroups) => { - var idRx = /-[A-Za-z0-9_-]{19}/g; - var matches = command.match(idRx) || []; - var found = []; - matches.forEach(function(id) { - var linkedIds = []; - Object.values(activeGroups).forEach(function(active) { - var allLinked = active.linkedTokens[id] || []; - Object.entries(active.linkedTokens).forEach(function(entry) { - if (entry[1].indexOf(id) !== -1) { - allLinked = allLinked.concat([entry[0]]).concat(entry[1]); - } - }); - allLinked = allLinked.filter(function(lid, i) { return allLinked.indexOf(lid) === i && lid !== id; }); - linkedIds = linkedIds.concat(allLinked); - }); - if (linkedIds.length > 0) found.push({ id: id, linkedIds: linkedIds }); - }); - return { found: found, hasIds: found.length > 0 }; - }; - - /** - * Path 2: Replace token IDs in command with linked counterparts per target page, emit immediately. + * Relay execution: replaces token IDs in command with linked counterparts per + * target page and appends {& select} for SelectManager cross-page targeting. */ - const relayByIdReplacement = (sender, command, activeGroups, targetPlayerIds) => { - var idInfo = findLinkedIdsInCommand(command, activeGroups); - if (!idInfo.hasIds) return 0; - - var relayed = 0; - targetPlayerIds.forEach(function(playerId) { - var newCmd = command; - idInfo.found.forEach(function(entry) { - // Find the linked token that's on this player's page - var targetId = null; - Object.values(activeGroups).forEach(function(active) { - if (targetId) return; - var playerPage = active.playerPages[playerId]; - if (!playerPage) return; - entry.linkedIds.forEach(function(lid) { - if (targetId) return; - var obj = getObj('graphic', lid); - if (obj && obj.get('_pageid') === playerPage.pageId) targetId = lid; - }); - }); - if (targetId) newCmd = newCmd.replace(entry.id, targetId); - }); - if (newCmd !== command) { - sendChat(sender, newCmd); - relayed++; - } - }); - return relayed; - }; - - /** - * Path 1: Queue commands for execution when GM visits the target page. - */ - const queueRelay = (sender, tokens, command, targetPlayerIds) => { + const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { var s = state[SCRIPT_NAME]; - if (!s.relayQueue) s.relayQueue = {}; + var relayed = 0; var tokenIds = tokens.map(function(t) { return t.get('id'); }); - var newlyQueued = 0; + + if (includeMaster) { + relaying.add(relayKey(command, sender, tokenIds)); + sendChat(sender, command + ' {& select ' + tokenIds.join(', ') + '}'); + relayed += tokenIds.length; + } targetPlayerIds.forEach(function(playerId) { - // Find the linked token IDs for this player page var linkedIds = []; + var newCmd = command; + Object.values(s.activeGroups).forEach(function(active) { var playerPage = active.playerPages[playerId]; if (!playerPage) return; + tokenIds.forEach(function(tokenId) { - var allLinked = active.linkedTokens[tokenId] || []; + // Find all linked counterparts + var allLinked = (active.linkedTokens[tokenId] || []).slice(); Object.entries(active.linkedTokens).forEach(function(entry) { - if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + if (entry[1].indexOf(tokenId) !== -1) { + allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + } }); - allLinked.filter(function(id) { + // Filter to ones on this player's page + var onPage = allLinked.filter(function(id, i, arr) { + if (arr.indexOf(id) !== i || id === tokenId) return false; var obj = getObj('graphic', id); return obj && obj.get('_pageid') === playerPage.pageId; - }).forEach(function(id) { + }); + onPage.forEach(function(id) { + newCmd = newCmd.split(tokenId).join(id); if (linkedIds.indexOf(id) === -1) linkedIds.push(id); }); }); }); if (linkedIds.length > 0) { - // Queue for when GM visits the page - var pageId = null; - Object.values(s.activeGroups).forEach(function(active) { - var pp = active.playerPages[playerId]; - if (pp) pageId = pp.pageId; - }); - if (pageId) { - if (!s.relayQueue[pageId]) s.relayQueue[pageId] = []; - s.relayQueue[pageId].push({ sender: sender, command: command, selectIds: linkedIds }); - newlyQueued++; - } + relaying.add(relayKey(newCmd, sender, linkedIds)); + sendChat(sender, newCmd + ' {& select ' + linkedIds.join(', ') + '}'); + relayed += linkedIds.length; } }); - if (newlyQueued > 0) { - var totalPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }).length; - sendChat(SCRIPT_NAME, '/w gm Queued for ' + newlyQueued + ' page(s). Total pending: ' + totalPages + '. Navigate to player pages to execute.'); - } - }; - - const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { - var s = state[SCRIPT_NAME]; - var relayed = 0; - - if (includeMaster) { - var masterIds = tokens.map(function(t) { return t.get('id'); }); - sendChat(sender, command + ' {& select ' + masterIds.join(', ') + '}'); - relayed += masterIds.length; - } - - if (targetPlayerIds.length > 0) { - // Path 2: try ID replacement first (works cross-page) - var idRelayed = relayByIdReplacement(sender, command, s.activeGroups, targetPlayerIds); - if (idRelayed > 0) { - relayed += idRelayed; - } else { - // Path 1: queue for when GM visits page (selection-based) - queueRelay(sender, tokens, command, targetPlayerIds); - } - } - return relayed; }; - /** - * Poll _lastpage to fire queued relay commands when GM arrives on a target page. - */ - const pollRelayQueue = () => { - var s = state[SCRIPT_NAME]; - if (!s.relayQueue) return; - - var gmPlayers = findObjs({ _type: 'player' }).filter(function(p) { return playerIsGM(p.get('_id')); }); - gmPlayers.forEach(function(gm) { - var lastPage = gm.get('_lastpage'); - if (!lastPage) return; - var queue = s.relayQueue[lastPage]; - if (!queue || queue.length === 0) return; - - // Fire all queued commands for this page - queue.forEach(function(entry) { - sendChat(entry.sender, entry.command + ' {& select ' + entry.selectIds.join(', ') + '}'); - }); - delete s.relayQueue[lastPage]; - }); - }; - /** * Stage selected tokens: duplicate to player pages and link. * !gaslight stage [playerName1 playerName2 ...] @@ -1653,6 +1575,247 @@ var Gaslight = Gaslight || (() => { + '' + CMD + ' status -- Show state
      ' + '' + CMD + ' --help -- This help
      '; + // ========================================================================= + // Scripting Engine — Fetch Integration + // ========================================================================= + + // Module-level evaluation context for Fetch compProp resolution + var evaluationContext = { scope: 'token', targetId: null, viewerPlayerId: null }; + + /** + * Read a gl_ field from a token's gmnotes. + */ + const readGlField = (gmnotes, fieldName) => { + var notes = gmnotes || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + var rx = new RegExp(fieldName + '\\s*:\\s*(\\S+)'); + var match = notes.match(rx); + return match ? match[1] : ''; + }; + + /** + * Register a gl_ field as a Fetch compProp on the graphic type. + * Resolution depends on evaluationContext.scope. + */ + const registerGlCompProp = (fieldName) => { + if (typeof Fetch === 'undefined' || !Fetch.CustomPropsByType) return; + if (Fetch.CustomPropsByType.graphic.compProps[fieldName]) return; + + var valFn = function(o) { + if (evaluationContext.scope === 'token') { + return readGlField(o.gmnotes, fieldName); + } else { + var charId = o.represents; + if (!charId) return ''; + return getAttrByName(charId, fieldName) || ''; + } + }; + + Fetch.CustomPropsByType.graphic.compProps[fieldName] = { nicks: [], val: valFn }; + // Also inject into the cached PropContainers so Fetch uses it immediately + if (Fetch.PropContainers && Fetch.PropContainers.graphic) { + Fetch.PropContainers.graphic[fieldName] = valFn; + } + log(SCRIPT_NAME + ': registered Fetch compProp "' + fieldName + '"'); + }; + + /** + * Scan a script for gl_ references and register compProps for each. + */ + const registerCompPropsFromScript = (content) => { + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + var rx = /@\([^)]*\.(gl_[a-zA-Z0-9_]+)\)/g; + var match; + while ((match = rx.exec(text)) !== null) { + registerGlCompProp(match[1]); + } + }; + + /** + * Scan all active script handouts and register compProps. + * Called on split and when handouts change. + */ + const registerAllCompProps = () => { + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + getPinScript(pin, function(content) { + if (content) registerCompPropsFromScript(content); + }); + }); + }); + }); + }; + + // ========================================================================= + // Scripting Engine — Trigger Map + // ========================================================================= + + // triggerMap: attributeName → [{ pinId, pageId }] + var triggerMap = {}; + + /** + * Parse a script's conditional blocks to find referenced attributes for auto-triggering. + * Looks for @(target.gl_*) and @(viewer.*) inside {& if} blocks. + */ + const parseTriggersFromScript = (content) => { + var triggers = []; + // Strip HTML for parsing + var text = content.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + // Find content inside {& if ...} blocks (simple regex — catches most cases) + var ifRx = /\{&\s*if\s+(.+?)\}/gi; + var match; + while ((match = ifRx.exec(text)) !== null) { + var condition = match[1]; + // Find @(target.*) and @(viewer.*) references in the condition + var refRx = /@\((?:target|viewer)\.([^)]+)\)/g; + var refMatch; + while ((refMatch = refRx.exec(condition)) !== null) { + var field = refMatch[1]; + if (triggers.indexOf(field) === -1) triggers.push(field); + } + } + return triggers; + }; + + /** + * Build the trigger map for all active script pins. + * Called on split, and when handouts change. + */ + const buildTriggerMap = () => { + triggerMap = {}; + var s = state[SCRIPT_NAME]; + + Object.values(s.activeGroups).forEach(function(group) { + var allPageIds = [group.masterPageId].concat(Object.values(group.playerPages).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pageId) { + var pins = findScriptPins(pageId); + pins.forEach(function(pin) { + parsePinConfig(pin, function(config) { + if (!config) return; + var explicitTriggers = config.triggers.filter(function(t) { return t.startsWith('on change '); }).map(function(t) { return t.slice(10).trim(); }); + var manualOnly = config.triggers.some(function(t) { return t === 'manual only'; }); + + if (manualOnly) return; + + if (explicitTriggers.length > 0) { + explicitTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + } else { + getPinScript(pin, function(content) { + if (!content) return; + var autoTriggers = parseTriggersFromScript(content); + var ignored = config.triggers.filter(function(t) { return t.startsWith('ignore '); }).map(function(t) { return t.slice(7).trim(); }); + autoTriggers = autoTriggers.filter(function(t) { return ignored.indexOf(t) === -1; }); + + autoTriggers.forEach(function(field) { + if (!triggerMap[field]) triggerMap[field] = []; + triggerMap[field].push({ pinId: pin.get('_id'), pageId: pageId }); + }); + }); + } + }); + }); + }); + }); + }; + + /** + * Handle attribute changes — check trigger map and re-evaluate affected pins. + */ + const onAttributeChanged = (obj) => { + var attrName = obj.get('name'); + var entries = triggerMap[attrName]; + if (!entries || entries.length === 0) return; + + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }; + + /** + * Handle token property changes — check trigger map for graphic properties. + */ + const onGraphicPropChanged = (obj, prev) => { + var changed = Object.keys(prev).filter(function(k) { return !k.startsWith('_') && prev[k] !== obj.get(k) && k !== 'gmnotes'; }); + if (changed.length === 0) return; + + var triggered = false; + changed.forEach(function(prop) { + var entries = triggerMap[prop]; + if (!entries || entries.length === 0) return; + if (triggered) return; // only evaluate once per change event + triggered = true; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false); + }); + }); + }; + const onGmNotesChanged = (obj, prev) => { + if (!prev || !prev.gmnotes) return; + var oldNotes = prev.gmnotes || ''; + var newNotes = obj.get('gmnotes') || ''; + try { oldNotes = decodeURIComponent(oldNotes); } catch(e) {} + try { newNotes = decodeURIComponent(newNotes); } catch(e) {} + + // Parse gl_ fields from old and new + var glRx = /gl_([a-zA-Z0-9_]+)\s*[=:]\s*(.+)/g; + var oldFields = {}; + var newFields = {}; + var m; + while ((m = glRx.exec(oldNotes)) !== null) oldFields['gl_' + m[1]] = m[2].trim(); + glRx.lastIndex = 0; + while ((m = glRx.exec(newNotes)) !== null) newFields['gl_' + m[1]] = m[2].trim(); + + // Find changed fields + var changedFields = Object.keys(newFields).filter(function(k) { return oldFields[k] !== newFields[k]; }); + // Also check removed fields + Object.keys(oldFields).forEach(function(k) { if (!(k in newFields) && changedFields.indexOf(k) === -1) changedFields.push(k); }); + + changedFields.forEach(function(field) { + var entries = triggerMap[field]; + if (!entries || entries.length === 0) return; + // Find the master page counterpart of this token + var tokenId = obj.get('id'); + var masterTokenId = null; + var s = state[SCRIPT_NAME]; + Object.values(s.activeGroups).forEach(function(active) { + // Check if this token is linked; find the master copy + var allLinked = active.linkedTokens[tokenId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked = allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i; }); + allLinked.forEach(function(id) { + var t = getObj('graphic', id); + if (t && t.get('_pageid') === active.masterPageId) masterTokenId = id; + }); + // If the token itself is on master + if (obj.get('_pageid') === active.masterPageId) masterTokenId = tokenId; + }); + + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (!pin) return; + var fakeMsg = { playerid: 'API', who: 'API', type: 'api' }; + evaluatePins([pin], fakeMsg, false, masterTokenId, obj.get('_pageid')); + }); + }); + }; + // ========================================================================= // Scripting Engine // ========================================================================= @@ -1665,29 +1828,77 @@ var Gaslight = Gaslight || (() => { var handout = getObj('handout', handoutId); if (!handout) { callback(null); return; } handout.get('notes', function(notes) { - callback(notes || ''); + if (!notes) { callback(''); return; } + var text = decodeURIComponent(notes) + .replace(/<\/p>\s*]*>/gi, '\n') + .replace(//gi, '\n') + .replace(/<\/?[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + callback(text); }); }; /** - * Find pins on a page that are gaslight script pins (linked to a handout). + * Find pins on a page that are gaslight script pins. + * A pin is a script pin if: + * - It links to a handout (script in handout notes, config in handout gmNotes or pin gmNotes) + * - OR it has ---GASLIGHT-SCRIPT--- in its own gmNotes (self-contained) */ const findScriptPins = (pageId) => { var pins = findObjs({ _type: 'pin', _pageid: pageId }); return pins.filter(function(pin) { - return pin.get('link') && pin.get('linkType') === 'handout'; + if (pin.get('link') && pin.get('linkType') === 'handout') return true; + var notes = pin.get('gmNotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf('---GASLIGHT-SCRIPT---') !== -1; }); }; /** - * Parse pin gmNotes for script configuration. + * Parse pin configuration. Checks pin gmNotes first, falls back to linked handout gmNotes. */ - const parsePinConfig = (pin) => { + const parsePinConfig = (pin, callback) => { var notes = pin.get('gmNotes') || ''; try { notes = decodeURIComponent(notes); } catch(e) {} + + // If pin has its own config, use it + if (notes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(notes)); + return; + } + + // Fall back to linked handout's gmNotes + var handoutId = pin.get('link'); + if (handoutId) { + var handout = getObj('handout', handoutId); + if (handout) { + handout.get('gmnotes', function(gmnotes) { + gmnotes = gmnotes || ''; + try { gmnotes = decodeURIComponent(gmnotes); } catch(e) {} + if (gmnotes.indexOf('---GASLIGHT-SCRIPT---') !== -1) { + callback(parseConfigText(gmnotes)); + } else { + // No config found, use defaults + callback({ scope: 'token', filter: 'all', triggers: [] }); + } + }); + return; + } + } + callback(null); + }; + + /** + * Parse config text into structured object. + */ + const parseConfigText = (text) => { var config = { scope: 'token', filter: 'all', triggers: [] }; - if (!notes.includes('---GASLIGHT-SCRIPT---')) return null; - notes.split('\n').forEach(function(line) { + // Strip HTML and normalize line breaks + text = text.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + text.split('\n').forEach(function(line) { line = line.trim(); if (line.startsWith('scope:')) config.scope = line.slice(6).trim(); else if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); @@ -1696,6 +1907,22 @@ var Gaslight = Gaslight || (() => { return config; }; + /** + * Get the script content for a pin. + * Linked pin: from handout notes. Self-contained: from pin notes. + */ + const getPinScript = (pin, callback) => { + var handoutId = pin.get('link'); + if (handoutId) { + getHandoutContent(handoutId, callback); + } else { + // Self-contained: script in pin notes + var notes = pin.get('notes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + callback(notes); + } + }; + /** * Get target tokens for evaluation based on pin config filter. */ @@ -1716,53 +1943,349 @@ var Gaslight = Gaslight || (() => { if (filter.startsWith('has ')) { var field = filter.slice(4).trim(); return tokens.filter(function(t) { + // Check gmnotes var notes = t.get('gmnotes') || ''; try { notes = decodeURIComponent(notes); } catch(e) {} - return notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1; + if (notes.indexOf(field + ':') !== -1 || notes.indexOf(field + ' :') !== -1) return true; + // Check character attribute + var charId = t.get('represents'); + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: field })[0]; + if (attr) return true; + } + return false; }); } return tokens; }; + /** + * Find the linked counterpart of a token on a specific page. + */ + const findLinkedTokenOnPage = (sourceToken, targetPageId) => { + var s = state[SCRIPT_NAME]; + var sourceId = sourceToken.get('id'); + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = active.linkedTokens[sourceId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(sourceId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i && id !== sourceId; }).forEach(function(id) { + linkedIds.push(id); + }); + }); + for (var i = 0; i < linkedIds.length; i++) { + var obj = getObj('graphic', linkedIds[i]); + if (obj && obj.get('_pageid') === targetPageId) return obj; + } + return null; + }; + /** * Evaluate a script for a specific target token and viewer. - * Sends the script content through the meta-script pipeline via sendChat. + * Resolves target to the linked copy on the viewer's page. + */ + // ─── Viewer Aggregation ──────────────────────────────────────────────────── + + const OPS = ['>=', '<=', '!=', '!~', '=', '~', '>', '<']; + + /** + * Find `search` in `str` starting at `startIdx`, skipping quoted regions. + * Returns index or -1. + */ + const findUnquoted = (str, search, startIdx) => { + var inQuote = null; + for (var i = startIdx || 0; i <= str.length - search.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (str.slice(i, i + search.length) === search) return i; + } + return -1; + }; + + /** + * Find the matching close paren for an open paren at `start`. + * Skips quoted strings. Returns index of closing paren or -1. + */ + const findCloseParen = (str, start) => { + var depth = 0; + var inQuote = null; + for (var i = start; i < str.length; i++) { + var ch = str[i]; + if (inQuote) { if (ch === inQuote) inQuote = null; continue; } + if (ch === '"' || ch === "'" || ch === '`') { inQuote = ch; continue; } + if (ch === '(') depth++; + else if (ch === ')') { depth--; if (depth === 0) return i; } + } + return -1; + }; + + /** + * Extract operator and RHS starting at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced ), ||, &&, or }. + * Returns { op, rhs, end } or null. + */ + const extractOpRhs = (str, pos) => { + var rest = str.slice(pos).replace(/^\s*/, ''); + var offset = pos + (str.slice(pos).length - rest.length); + for (var i = 0; i < OPS.length; i++) { + if (rest.startsWith(OPS[i])) { + var afterOp = rest.slice(OPS[i].length).replace(/^\s*/, ''); + var opEnd = offset + OPS[i].length + (rest.slice(OPS[i].length).length - afterOp.length); + var rhs = ''; + var depth = 0; + var inQ = null; + var j = 0; + for (; j < afterOp.length; j++) { + var c = afterOp[j]; + if (inQ) { if (c === inQ) inQ = null; rhs += c; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; rhs += c; continue; } + if (c === '(') { depth++; rhs += c; continue; } + if (c === ')') { if (depth === 0) break; depth--; rhs += c; continue; } + if (depth === 0 && j + 1 < afterOp.length && (afterOp.slice(j, j + 2) === '||' || afterOp.slice(j, j + 2) === '&&')) break; + if (c === '}') break; + rhs += c; + } + return { op: OPS[i], rhs: rhs.trim(), end: opEnd + j }; + } + } + return null; + }; + + /** + * Extract operator and LHS ending at `pos` in `str`. + * Respects quotes and balanced parens. Stops at unbalanced (, ||, &&, or {&. + * Returns { op, lhs, start } or null. */ - const evaluateScript = (scriptContent, targetToken, viewerPlayerId, config, msg, dryRun) => { - // TODO: Set evaluation context for Fetch compProp resolution - // TODO: Inject Muler variables for viewer context + const extractOpLhs = (str, pos) => { + var before = str.slice(0, pos).replace(/\s*$/, ''); + for (var i = 0; i < OPS.length; i++) { + if (before.endsWith(OPS[i])) { + var beforeOp = before.slice(0, -OPS[i].length).replace(/\s*$/, ''); + var lhs = ''; + var depth = 0; + var inQ = null; + var j = beforeOp.length - 1; + for (; j >= 0; j--) { + var c = beforeOp[j]; + if (inQ) { if (c === inQ) inQ = null; lhs = c + lhs; continue; } + if (c === '"' || c === "'" || c === '`') { inQ = c; lhs = c + lhs; continue; } + if (c === ')') { depth++; lhs = c + lhs; continue; } + if (c === '(') { if (depth === 0) break; depth--; lhs = c + lhs; continue; } + if (j > 0 && (beforeOp.slice(j - 1, j + 1) === '||' || beforeOp.slice(j - 1, j + 1) === '&&')) { break; } + lhs = c + lhs; + } + return { op: OPS[i], lhs: lhs.trim(), start: j + 1 }; + } + } + return null; + }; + + /** + * Expand any()/all()/max()/min() viewer aggregates. + * Sweep 1: any (LHS then RHS) + * Sweep 2: all (LHS then RHS) + * Sweep 3: max/min (resolve to literal) + */ + const expandAggregates = (content, ids, namespace) => { + if (ids.length === 0) return content; + content = expandAggregate(content, 'any', '||', ids, namespace); + content = expandAggregate(content, 'all', '&&', ids, namespace); + content = resolveMaxMin(content, ids, namespace); + content = resolveJoin(content, ids, namespace); + return content; + }; + + const expandAggregate = (content, funcName, joiner, ids, namespace) => { + var search = funcName + '('; + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + funcName.length); + if (closeIdx === -1) break; + + var inner = content.slice(idx + funcName.length + 1, closeIdx); + // Only expand if this aggregate contains our namespace + if (inner.indexOf('@(' + namespace + '.') === -1) { idx = findUnquoted(content, search, idx + 1); continue; } + + var beforeAgg = content.slice(0, idx); + var afterAgg = content.slice(closeIdx + 1); + + var opRhs = extractOpRhs(afterAgg, 0); + if (opRhs) { + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.') + ' ' + opRhs.op + ' ' + opRhs.rhs; + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg.slice(opRhs.end); + } else { + var opLhs = extractOpLhs(beforeAgg, beforeAgg.length); + if (opLhs) { + var expanded = '(' + ids.map(function(id) { + return opLhs.lhs + ' ' + opLhs.op + ' ' + inner.replace(nsRx, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg.slice(0, opLhs.start) + expanded + afterAgg; + } else { + var expanded = '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); + }).join(' ' + joiner + ' ') + ')'; + content = beforeAgg + expanded + afterAgg; + } + } + + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + + const resolveMaxMin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; + ['max', 'min'].forEach(function(fn) { + var search = fn + '('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + fn.length); + if (closeIdx === -1) break; + var inner = content.slice(idx + fn.length + 1, closeIdx); + if (inner.indexOf(nsCheck) !== -1) { + var expanded = '{& math ' + fn + '(' + ids.map(function(id) { + return inner.replace(nsRx, '@(' + id + '.'); + }).join(', ') + ')}'; + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + }); + return content; + }; + + const resolveJoin = (content, ids, namespace) => { + var nsRx = new RegExp('@\\(' + namespace + '\\.', 'g'); + var nsCheck = '@(' + namespace + '.'; + var search = 'join('; + var idx = findUnquoted(content, search, 0); + while (idx !== -1) { + if (idx > 0 && /\w/.test(content[idx - 1])) { idx = findUnquoted(content, search, idx + 1); continue; } + var closeIdx = findCloseParen(content, idx + 4); + if (closeIdx === -1) break; + var inner = content.slice(idx + 5, closeIdx); + if (inner.indexOf(nsCheck) !== -1) { + // Check for optional delimiter: join(@(viewer.field), ",") + var parts = inner.split(','); + var field = parts[0].trim(); + var delim = ' '; + if (parts.length > 1) { + var rawDelim = parts.slice(1).join(',').trim(); + delim = rawDelim.replace(/^['"`]|['"`]$/g, ''); + } + var expanded = ids.map(function(id) { + return field.replace(nsRx, '@(' + id + '.'); + }).join(delim); + content = content.slice(0, idx) + expanded + content.slice(closeIdx + 1); + } + idx = findUnquoted(content, search, idx + 1); + } + return content; + }; + + const evaluateScript = (scriptContent, targetToken, viewerPlayerId, viewerPageId, config, msg, dryRun) => { + // Find the linked token on the viewer's page + var viewerTarget = findLinkedTokenOnPage(targetToken, viewerPageId); + if (!viewerTarget) return; + + // Set evaluation context for Fetch compProp resolution + evaluationContext.scope = config.scope || 'token'; + evaluationContext.targetId = viewerTarget.get('id'); + evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page - // For now, basic string replacement of known patterns var content = scriptContent; - content = content.replace(/@\(target\.token_id\)/g, targetToken.get('id')); - content = content.replace(/@\(target\.name\)/g, targetToken.get('name') || ''); + // Resolve @(target.gl_*) ourselves since Fetch compProps don't fire for sendChat messages + content = content.replace(/@\(target\.(gl_[a-zA-Z0-9_]+)\)/g, function(match, field) { + var val = ''; + if (config.scope === 'token') { + val = readGlField(viewerTarget.get('gmnotes'), field); + } + if (!val) { + var charId = viewerTarget.get('represents'); + val = charId ? (getAttrByName(charId, field) || '') : ''; + } + return val; + }); + // Replace remaining @(target.*) with token ID — Fetch resolves native props + content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); + // Resolve viewer tokens for aggregation + var viewerTokens = findObjs({ _type: 'graphic', _pageid: viewerPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return cb === 'all' || cb.split(',').indexOf(viewerPlayerId) !== -1; + }); + var viewerIds = viewerTokens.map(function(t) { return t.get('id'); }); + + // Resolve GM tokens on master page for gm.* aggregation + var masterPageId = targetToken.get('_pageid'); + var gmTokens = findObjs({ _type: 'graphic', _pageid: masterPageId, _subtype: 'token' }).filter(function(t) { + var cid = t.get('represents'); + if (!cid) return false; + var c = getObj('character', cid); + if (!c) return false; + var cb = c.get('controlledby') || ''; + return !cb || cb.split(',').every(function(id) { return id.trim() === '' || playerIsGM(id.trim()); }); + }); + var gmIds = gmTokens.map(function(t) { return t.get('id'); }); - // Split into lines, send each command - var lines = content.split('\n').filter(function(l) { - l = l.trim(); + // Expand any()/all()/max()/min() aggregates for viewer.* and gm.* + content = expandAggregates(content, viewerIds, 'viewer'); + content = expandAggregates(content, gmIds, 'gm'); + + // Error check: bare @(viewer.*) or @(gm.*) without aggregate + if (content.indexOf('@(viewer.') !== -1) { + whisper('⚠️ Script error: @(viewer.*) must be inside any(), all(), max(), or min()'); + return; + } + if (content.indexOf('@(gm.') !== -1) { + whisper('⚠️ Script error: @(gm.*) must be inside any(), all(), max(), or min()'); + return; + } + + var lines = content.split('\n').map(function(l) { + var ci = l.indexOf('//'); + return (ci !== -1 ? l.slice(0, ci) : l).trim(); + }).filter(function(l) { return l && (l.startsWith('!') || l.startsWith('{&')); }); if (dryRun) { - var out = 'Dry run (target: ' + (targetToken.get('name') || targetToken.get('id')) + ', viewer: ' + viewerPlayerId + '):
      '; - lines.forEach(function(l) { out += '' + l + '
      '; }); - reply(msg, 'Eval', out); + lines.forEach(function(l) { + sendChat('player|' + msg.playerid, CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' + l); + }); } else { - // Combine into single message for ZeroFrame to process var fullCmd = lines.join('\n'); - if (fullCmd) sendChat('player|' + msg.playerid, fullCmd); + if (fullCmd) { + var senderId = msg.playerid; + if (senderId === 'API') { + var gmPlayer = findObjs({ _type: 'player' }).find(function(p) { return playerIsGM(p.get('_id')); }); + if (gmPlayer) senderId = gmPlayer.get('_id'); + } + sendChat('', CMD + ' --script-lock', null, { noarchive: true }); + sendChat(getPlayerName(senderId), fullCmd); + sendChat('', CMD + ' --script-unlock', null, { noarchive: true }); + } } }; /** * Evaluate all scripts on pins for a given page. */ - const evaluatePins = (pins, msg, dryRun) => { + const evaluatePins = (pins, msg, dryRun, targetTokenId, sourcePageId) => { var s = state[SCRIPT_NAME]; pins.forEach(function(pin) { - var config = parsePinConfig(pin); - if (!config) return; - var handoutId = pin.get('link'); var pageId = pin.get('_pageid'); // Find the active group for this page @@ -1772,18 +2295,53 @@ var Gaslight = Gaslight || (() => { if (!activeEntry) return; var groupInfo = activeEntry[1]; - var targets = getTargetTokens(pageId, config, s.activeGroups); - - getHandoutContent(handoutId, function(content) { - if (!content) return; - // Strip HTML tags from handout content - content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); - - // Evaluate for each viewer + target combination - Object.entries(groupInfo.playerPages).forEach(function(entry) { - var viewerPlayerId = entry[0]; - targets.forEach(function(target) { - evaluateScript(content, target, viewerPlayerId, config, msg, dryRun); + + // Determine which viewers to evaluate for based on pin placement + var viewers; + if (pageId === groupInfo.masterPageId) { + viewers = Object.entries(groupInfo.playerPages); + } else { + var playerEntry = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === pageId; }); + viewers = playerEntry ? [playerEntry] : []; + } + // If triggered from a specific player page, narrow to that viewer only + if (sourcePageId && sourcePageId !== groupInfo.masterPageId) { + var sourceViewer = Object.entries(groupInfo.playerPages).find(function(e) { return e[1].pageId === sourcePageId; }); + if (sourceViewer) viewers = [sourceViewer]; + } + if (viewers.length === 0) return; + + // Get targets from master page (source of truth for token list) + var targets = getTargetTokens(groupInfo.masterPageId, { filter: 'all' }, s.activeGroups); + + parsePinConfig(pin, function(config) { + if (!config) return; + // Re-filter targets based on config + targets = getTargetTokens(groupInfo.masterPageId, config, s.activeGroups); + // If triggered by a specific token, only evaluate that one + if (targetTokenId) { + targets = targets.filter(function(t) { return t.get('id') === targetTokenId; }); + if (targets.length === 0) return; + } + + getPinScript(pin, function(content) { + if (!content) return; + // Strip HTML tags from content + content = content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + + if (dryRun) { + var handout = pin.get('link') ? getObj('handout', pin.get('link')) : null; + var pinTitle = stripGlsTag(pin.get('title') || (handout && handout.get('name')) || pin.get('_id')); + sendChat('player|' + msg.playerid, CMD + ' --echo-header ' + pinTitle); + } + + // Evaluate for each viewer + target combination + viewers.forEach(function(entry) { + var viewerPlayerId = entry[0]; + var viewerPageId = entry[1].pageId; + targets.forEach(function(target) { + evaluateScript(content, target, viewerPlayerId, viewerPageId, config, msg, dryRun); + }); }); }); }); @@ -1797,8 +2355,8 @@ var Gaslight = Gaslight || (() => { * With handout name: evaluate all pins linked to that handout. */ const doEval = (msg, args) => { - var dryRun = args.indexOf('--dry') !== -1; - args = args.filter(function(a) { return a !== '--dry'; }); + var dryRun = args.indexOf('--dry-run') !== -1; + args = args.filter(function(a) { return a !== '--dry-run'; }); var pins = []; @@ -1858,27 +2416,285 @@ var Gaslight = Gaslight || (() => { case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; case 'eval': doEval(msg, args); break; - case 'test-relay': { - // Temporary: test sendChat with {& select} - var testId = args[0] || ''; - if (!testId) { reply(msg, 'Error', 'Provide a token ID'); break; } - var testCmd = '!token-mod --set bar1_value|42 {& select ' + testId + '}'; - log(SCRIPT_NAME + ': test-relay sending: ' + testCmd); - sendChat(getPlayerName(msg.playerid), testCmd); + case 'status': doStatus(msg); break; + case '--script-lock': scripting = true; return; + case '--script-unlock': scripting = false; return; + case '--assign-capture': { + // Format: --assign-capture ... + var acRollName = args[0]; + var acCharId = args[1]; + var acCaptures = {}; + args.slice(2).forEach(function(a) { + var eq = a.indexOf('='); + if (eq > 0) acCaptures[a.slice(0, eq)] = a.slice(eq + 1); + }); + var acTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === acCharId; + }); + if (acTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + acTokens.forEach(function(t) { writeCapturesToToken(t, acRollName, acCaptures); }); + reply(msg, 'Capture', 'Assigned ' + acRollName + ' to ' + acTokens.length + ' token(s).'); + return; + } + case '--clear-capture': { + // Format: --clear-capture + var ccRollName = args[0]; + var ccCharId = args[1]; + var ccTokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === ccCharId; + }); + if (ccTokens.length === 0) return reply(msg, 'Error', 'Select token(s) representing this character.'); + ccTokens.forEach(function(t) { + var gmnotes = decodeURIComponent(t.get('gmnotes') || ''); + gmnotes = gmnotes.replace(new RegExp('(^|\\n)gl_' + ccRollName + '_[^=]+=([^\\n]*)', 'g'), ''); + t.set('gmnotes', gmnotes.trim()); + }); + reply(msg, 'Capture', 'Cleared ' + ccRollName + ' overrides from ' + ccTokens.length + ' token(s).'); + return; + } + case '--echo': { + // Internal: dry-run echo. Format: !gaslight --echo + var echoRaw = msg.content.slice(msg.content.indexOf('--echo') + 6).trim(); + var [echoViewerId, echoTargetId] = echoRaw.split(' '); + var echoCmd = echoRaw.slice(echoViewerId.length + 1 + echoTargetId.length + 1); + var echoViewer = getObj('player', echoViewerId); + var echoTarget = getObj('graphic', echoTargetId); + var viewerName = echoViewer ? echoViewer.get('_displayname') : echoViewerId; + var echoTargetName = echoTarget ? echoTarget.get('name') : ''; + var targetDisplay = echoTargetName ? echoTargetName + ' ' + echoTargetId + '' : '' + echoTargetId + ''; + reply(msg, 'Eval', 'Dry run
      Target: ' + targetDisplay + '
      Viewer: ' + viewerName + ' ' + echoViewerId + '
      ' + echoCmd + ''); + break; + } + case '--echo-header': { + // Internal: dry-run pin header + var headerContent = msg.content.slice(msg.content.indexOf('--echo-header') + 13).trim(); + reply(msg, 'Eval', 'Pin: ' + headerContent); + break; + } + case '--dump-html': { + // Debug: dump raw content to console for selected pins/tokens or named character + if (args.length > 0) { + var charName = args.join(' '); + var charObj = findObjs({ _type: 'character', name: charName })[0]; + if (charObj) { + charObj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char "' + charName + '" bio]: ' + JSON.stringify(bio)); }); + charObj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char "' + charName + '" gmnotes]: ' + JSON.stringify(gn)); }); + } else { + reply(msg, 'Error', 'Character "' + charName + '" not found.'); + } + break; + } + var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + sel.forEach(function(obj) { + var type = obj.get('_type') || obj.get('type'); + if (type === 'pin') { + var handoutId = obj.get('link'); + if (handoutId) { + var ho = getObj('handout', handoutId); + if (ho) { + ho.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [handout gmnotes]: ' + JSON.stringify(gn)); }); + ho.get('notes', function(n) { log(SCRIPT_NAME + ' [handout notes]: ' + JSON.stringify(n)); }); + } + } else { + log(SCRIPT_NAME + ' [pin gmNotes]: ' + JSON.stringify(obj.get('gmNotes'))); + log(SCRIPT_NAME + ' [pin notes]: ' + JSON.stringify(obj.get('notes'))); + } + } else if (type === 'graphic') { + log(SCRIPT_NAME + ' [token ' + (obj.get('name') || obj.get('id')) + ' gmnotes]: ' + JSON.stringify(obj.get('gmnotes'))); + } else if (type === 'character') { + obj.get('bio', function(bio) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' bio]: ' + JSON.stringify(bio)); }); + obj.get('gmnotes', function(gn) { log(SCRIPT_NAME + ' [char ' + obj.get('name') + ' gmnotes]: ' + JSON.stringify(gn)); }); + } + }); break; } - case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; } }; + // ========================================================================= + // RollCapture Integration + // ========================================================================= + + const registerWithRollCapture = () => { + if (typeof RollCapture === 'undefined' || !RollCapture.onCapture) return; + RollCapture.onCapture(SCRIPT_NAME, onCaptureReceived); + }; + + const onCaptureReceived = (event) => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + + var { charName, charId, rollName, captures, playerId, msg } = event; + var selected = (msg && msg.selected) || []; + + // Always write to character attribute + if (charId) { + Object.entries(captures).forEach(function(entry) { + var attrName = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var attr = findObjs({ type: 'attribute', _characterid: charId, name: attrName })[0]; + if (val === undefined) { + if (attr) attr.remove(); + } else { + if (attr) attr.set('current', String(val)); + else createObj('attribute', { _characterid: charId, name: attrName, current: String(val) }); + } + }); + } + + // Token assignment — only count tokens representing this character + var tokens = selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(function(t) { + return t && t.get('represents') === charId; + }); + + // Fallback: if no selection, find tokens of this character on master pages + if (tokens.length === 0 && charId) { + var masterPageIds = Object.values(s.activeGroups).map(function(g) { return g.masterPageId; }); + tokens = findObjs({ _type: 'graphic', _subtype: 'token', represents: charId }).filter(function(t) { + return masterPageIds.indexOf(t.get('_pageid')) !== -1; + }); + } + + if (tokens.length === 1) { + writeCapturesToToken(tokens[0], rollName, captures); + } else { + // Only prompt if any captured field is referenced by an active script + var hasRelevantTrigger = Object.keys(captures).some(function(cap) { + return triggerMap['gl_' + rollName + '_' + cap]; + }); + if (hasRelevantTrigger) { + var captureArgs = Object.entries(captures).map(function(e) { return e[0] + '=' + e[1]; }).join(' '); + whisper('**' + charName + '** rolled **' + rollName + '**: ' + captureArgs + + '
      [Assign to selected](' + CMD + ' --assign-capture ' + rollName + ' ' + charId + ' ' + captureArgs + ')' + + ' [Clear overrides](' + CMD + ' --clear-capture ' + rollName + ' ' + charId + ')'); + } + } + + // Manually trigger pin evaluation for changed capture fields + var fakeMsg = { playerid: playerId || 'API', who: 'API', type: 'api' }; + var pins = Object.keys(captures).reduce(function(acc, cap) { + var entries = triggerMap['gl_' + rollName + '_' + cap] || []; + entries.forEach(function(entry) { + var pin = getObj('pin', entry.pinId); + if (pin && acc.indexOf(pin) === -1) acc.push(pin); + }); + return acc; + }, []); + if (pins.length > 0) evaluatePins(pins, fakeMsg, false); + }; + + const writeCapturesToToken = (token, rollName, captures) => { + var gmnotes = decodeURIComponent(token.get('gmnotes') || ''); + Object.entries(captures).forEach(function(entry) { + var field = 'gl_' + rollName + '_' + entry[0]; + var val = entry[1]; + var rx = new RegExp('(^|\\n)' + field + '=[^\\n]*'); + if (val === undefined) { + gmnotes = gmnotes.replace(rx, ''); + } else if (gmnotes.match(rx)) { + gmnotes = gmnotes.replace(rx, '$1' + field + '=' + val); + } else { + gmnotes = gmnotes.trim() + '\n' + field + '=' + val; + } + }); + token.set('gmnotes', gmnotes); + }; + // ========================================================================= // Initialization // ========================================================================= + const HANDOUT_NAME = 'Help: Gaslight'; + const HANDOUT_AVATAR = 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385'; + + const createHelpHandout = () => { + var existing = findObjs({ type: 'handout', name: HANDOUT_NAME }); + var h = existing.length > 0 ? existing[0] : createObj('handout', { name: HANDOUT_NAME, avatar: HANDOUT_AVATAR }); + if (HANDOUT_AVATAR) h.set('avatar', HANDOUT_AVATAR); + h.set('notes', [ + '

      Gaslight v' + SCRIPT_VERSION + '

      ', + '

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

      ', + '

      Quick Start

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

      Commands

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

      !gaslight test <group> — Dry-run linking

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

      !gaslight status — Show state

      ', + '

      Auto-Relay

      ', + '

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

      ', + '

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

      ', + '

      Selective Relay

      ', + '

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

      ', + '

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

      ', + '

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

      ', + '

      Token Linking

      ', + '

      Tokens are linked across pages automatically by:

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

      Sync Control

      ', + '

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

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

      Staging

      ', + '

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

      ', + '

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

      ', + '

      Scripting

      ', + '

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

      ', + '

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

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

      Script syntax:

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

      Variables:

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

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

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

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

      ', + '

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

      ', + '

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

      ', + ].join('')); + }; + const checkInstall = () => { ensureState(); + createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); }; @@ -1930,61 +2746,116 @@ var Gaslight = Gaslight || (() => { }; /** - * In any active view mode, intercept non-gaslight API commands and re-emit - * with linked player tokens as selection via SelectManager. - * Master view: relay to ALL player pages. - * Player view: relay to that player's page only. + * Universal relay interceptor. Automatically relays commands to linked tokens: + * - If selected tokens or IDs in command reference master-page linked tokens + * AND no player-page tokens are selected/referenced → relay to all player pages. + * - If player-page tokens are involved → only relay if command is in relayCommands. */ const viewInterceptor = (msg) => { if (msg.type !== 'api') return; + if (scripting) return; var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; - var firstWord = msg.content.split(' ')[0]; + var content = msg.content.trim(); + if (!content) return; + var firstWord = content.split(' ')[0]; if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; - if (!msg.selected || msg.selected.length === 0) return; - if (msg.content.indexOf('{& select') !== -1) return; - var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); - if (tokens.length === 0) return; + // Check relaying set to prevent loops + var selectedIds = (msg.selected || []).map(function(sel) { return sel._id; }); + var key = relayKey(content, 'player|' + msg.playerid, selectedIds); + if (relaying.delete(key)) return; + if (content.indexOf('{& select') !== -1) return; - var pageId = tokens[0].get('_pageid'); - var isGM = playerIsGM(msg.playerid); + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); - // Case 1: GM on master page — relay based on view - if (isGM) { - var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); - if (!activeEntry) return; + // Scan command for token IDs that belong to linked groups + var idRx = /-[A-Za-z0-9_-]{19}/g; + var idsInCommand = (content.match(idRx) || []).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); + // Classify: which IDs/tokens are on master pages vs player pages? + var masterTokens = []; + var hasPlayerPageRef = false; + var activeEntry = null; + + // Check selected tokens + tokens.forEach(function(t) { + var pid = t.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + masterTokens.push(t); + if (!activeEntry) activeEntry = entry; + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + // Check IDs in command text + idsInCommand.forEach(function(id) { + // Skip IDs already accounted for by selection + if (tokens.some(function(t) { return t.get('id') === id; })) return; + var obj = getObj('graphic', id); + if (!obj) return; + var pid = obj.get('_pageid'); + var entry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pid; }); + if (entry) { + // Check if this token is actually linked + var linked = entry[1].linkedTokens[id] || []; + var isLinked = linked.length > 0 || Object.values(entry[1].linkedTokens).some(function(arr) { return arr.indexOf(id) !== -1; }); + if (isLinked) { + masterTokens.push(obj); + if (!activeEntry) activeEntry = entry; + } + } else { + var playerEntry = Object.entries(s.activeGroups).find(function(e) { + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pid; }); + }); + if (playerEntry) hasPlayerPageRef = true; + } + }); + + if (masterTokens.length === 0 && !hasPlayerPageRef) return; + + // Universal relay: master-page refs, no player-page refs + if (masterTokens.length > 0 && !hasPlayerPageRef) { var viewPlayerId = s.view; var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); - executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); + executeRelay('player|' + msg.playerid, masterTokens, content, targetPlayerIds, false); return; } - // Case 2: Player on their page — relay if command is in relay-commands list - if (s.config.relayCommands.indexOf(firstWord) === -1) return; - - // Find which group/player this page belongs to - var activeEntry = null; - var sourcePlayerId = null; - Object.entries(s.activeGroups).forEach(function(e) { - Object.entries(e[1].playerPages).forEach(function(pp) { - if (pp[1].pageId === pageId) { activeEntry = e; sourcePlayerId = pp[0]; } + // Player-page involved: only relay if relayCommands allows it + if (hasPlayerPageRef && s.config.relayCommands.indexOf(firstWord) !== -1) { + // Find source player page + var sourcePlayerId = null; + var entry = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + var srcToken = tokens.find(function(t) { return t.get('_pageid') === pp[1].pageId; }); + if (srcToken) { entry = e; sourcePlayerId = pp[0]; } + }); }); - }); - if (!activeEntry) return; - - // Relay to all OTHER player pages + master - var targetPlayerIds = Object.keys(activeEntry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); - executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, true); + if (!entry) return; + var targetPlayerIds = Object.keys(entry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, content, targetPlayerIds, true); + } }; const registerEventHandlers = () => { on('chat:message', handleInput); on('chat:message', viewInterceptor); + on('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!rollcapture-ready') registerWithRollCapture(); + }); + registerWithRollCapture(); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); - setInterval(pollRelayQueue, 500); + on('change:attribute', onAttributeChanged); + on('change:graphic', onGraphicPropChanged); + on('change:graphic:gmnotes', onGmNotesChanged); }; return { checkInstall, registerEventHandlers }; diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 95fc714800..63648f7284 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -9,7 +9,7 @@ // different things while token movement stays consistent across all copies. // Commands auto-relay to all player pages transparently. // -// Dependencies: Anchor, Mirror, SelectManager +// Dependencies: Anchor, Mirror, SelectManager, RollCapture (optional) // // Commands: // !gaslight setup Quick-configure from duplicates @@ -24,6 +24,7 @@ // !gaslight view [player|master] Switch relay view target // !gaslight relay Manually relay command to views // !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight eval [--dry-run] [--all|] Evaluate script pins // !gaslight status Show current state // !gaslight --help Command reference // ============================================================================= diff --git a/Gaslight/README.md b/Gaslight/README.md index f45912d330..f4e291a980 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -8,6 +8,7 @@ Per-player map perception for Roll20. Split players onto individual copies of a - [Anchor](https://github.com/Roll20/roll20-api-scripts/tree/master/Anchor) (spatial sync) - [Mirror](https://github.com/Roll20/roll20-api-scripts/tree/master/Mirror) (property sync) - [SelectManager](https://github.com/Roll20/roll20-api-scripts/tree/master/SelectManager) (command relay) +- [RollCapture](https://github.com/Roll20/roll20-api-scripts/tree/master/RollCapture) (optional, roll value extraction for scripting) ## Use Cases @@ -91,6 +92,54 @@ group: mygroup player: GM ``` +## Scripting + +Reactive per-player automation. Scripts stored in handouts evaluate per-viewer per-target, firing API commands conditionally based on captured roll values or token properties. + +### Setup + +1. Create a handout with your script +2. Place a pin on the master page and link it to the handout +3. Add config to the pin's GM notes: +``` +---GASLIGHT-SCRIPT--- +scope: token +filter: has gl_stealth_result +``` + +### Script Example + +``` +// Hide NPC from players who can't beat its stealth +!token-mod --ids @(target.token_id) --set {& if (any(@(viewer.passive_wisdom)) >= @(target.gl_stealth_result))} layer|objects {& else} layer|gmlayer {& end} +``` + +### Variables + +- `@(target.*)` — the NPC token being evaluated (resolved per viewer page) +- `@(target.gl_*)` — captured values (token gmnotes override, character attribute fallback) + +### Aggregate Functions (required for viewer.*/gm.*) + +- `any(@(viewer.field)) op value` — true if any viewer token passes +- `all(@(viewer.field)) op value` — true if all pass +- `max(@(viewer.field))` — highest value (via MathOps) +- `min(@(viewer.field))` — lowest value (via MathOps) +- `join(@(viewer.token_id))` — space-separated IDs for `--ids` targeting + +### Triggers + +Scripts auto-detect triggers from `@(target.gl_*)` references. Override in pin GM notes: +- `trigger: on change gl_stealth_result` — explicit trigger +- `trigger: manual only` — only fires via `!gaslight eval` + +### Evaluation + +- `!gaslight eval` — evaluate selected pins +- `!gaslight eval --all` — all pins in active groups +- `!gaslight eval ` — all pins linked to that handout +- Add `--dry-run` to preview without executing + ## License MIT diff --git a/Gaslight/script.json b/Gaslight/script.json index dc223c8438..2e9ccf90eb 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -6,7 +6,7 @@ "description": "Per-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", - "dependencies": ["Anchor", "Mirror", "SelectManager"], + "dependencies": ["Anchor", "Mirror", "SelectManager", "RollCapture"], "modifies": { "graphic": "read, write", "text": "read, write", From 10d2df6f789912d881f93b4c15147b52d4f47579 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 09:53:19 -0400 Subject: [PATCH 75/96] =?UTF-8?q?Anchor=20v2.2.0:=20selection-based=20pare?= =?UTF-8?q?nting=20=E2=80=94=20first=20selected=20=3D=20parent=20when=20mu?= =?UTF-8?q?ltiple=20selected,=20'new'=20flag=20for=20auto-create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Anchor/2.2.0/anchor.js | 2531 ++++++++++++++++++++++++++++++++++++++++ Anchor/anchor.js | 12 +- 2 files changed, 2540 insertions(+), 3 deletions(-) create mode 100644 Anchor/2.2.0/anchor.js diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js new file mode 100644 index 0000000000..4060e8b907 --- /dev/null +++ b/Anchor/2.2.0/anchor.js @@ -0,0 +1,2531 @@ +// ============================================================================= +// Anchor v2.1.0 +// Last Updated: 2026-06-12 +// Author: Kenan Millet +// +// Description: +// Attach child graphics to an anchor graphic so they automatically mirror +// the anchor's transform (position, rotation, scale, flip). +// Supports arbitrary chains: a child can itself be an anchor to grandchildren. +// +// Dependencies: MatrixMath +// +// Commands: +// !anchor [] [flags...] [ignore-selected] [...] +// Anchor selected token(s) (and any listed IDs) to anchor_id. +// By default anchors all transform components (position, rotation, scale, +// flipv, fliph). +// If anchor_id is omitted or not a valid token ID, an invisible anchor +// token is automatically created at the first child's position and +// auto-destroyed when its last child is removed. Add persist to keep it: +// !anchor persist [anchor_id] [flags] [child_id...] +// +// Component flags — long form (anchor-) or short alias (-): +// anchor-all / -all = every component including z-order +// anchor / (no flags) = default: pos+rot+scale+flip (no layer or z-order) +// anchor-position / -pos = x + y +// anchor-x / -x = x position only +// anchor-y / -y = y position only +// anchor-rotation / -rot = rotation only +// anchor-scale / -scale = width + height +// anchor-width / -w = width only +// anchor-height / -h = height only +// anchor-layer / -layer = layer only +// anchor-flip / -flip = flipv + fliph +// anchor-flipv / -flipv = vertical flip only +// anchor-fliph / -fliph = horizontal flip only +// anchor-z / -z = z-order (relative stacking, use Anchor.updateZOrder() after moving anchor) +// +// !anchor remove [ignore-selected] [...] +// Remove anchor from selected/listed tokens. +// +// !anchor lock [component flags] [ignore-selected] [...] +// Lock components for child(ren). Locked components are re-enforced every +// poll tick — manual moves are undone, anchor changes are ignored for those +// components. With no component flags, locks ALL components. +// Components not yet tracked are stored as "pre-locked" and will be locked +// automatically if/when tracking is added via !anchor track. +// +// !anchor unlock [component flags] [ignore-selected] [...] +// Unlock components. With no component flags, unlocks everything. +// +// !anchor track [component flags] [ignore-selected] [...] +// Add component tracking to existing anchor relationships, recording the +// current relative state as the new stored offset. Respects any pre-locks. +// +// !anchor untrack [component flags] [ignore-selected] [...] +// Remove component tracking. Does not affect locked state. +// +// !anchor retrack [component flags] [ignore-selected] [...] +// Replace the tracked component set entirely. No flags = default set. +// +// !anchor center [ignore-selected] [...] +// Snap child(ren) to anchor center (offset 0,0, rotation 0, scale 1:1). +// +// !anchor update [ignore-selected] [...] +// Force an immediate position/transform sync for the child(ren). +// +// !anchor info [ignore-selected] [...] +// Whisper current anchor state for the given token(s) to the caller. +// +// !anchor config +// Show current configuration values. +// +// !anchor config +// Set a config value at runtime (persists in state across restarts). +// Keys and their defaults: +// poll-interval — polling interval (default: 1000, min: 100) +// default-anchor-layer — gmlayer | objects | map (default: gmlayer) +// default-anchor-size — token size in pixels (default: 35) +// default-anchor-name — token name (default: Anchor) +// default-anchor-aura-color <#hex> — GM aura colour (default: #00ffff) +// default-anchor-aura-visible — show GM aura (default: true) +// allow-player-use — let players use lock/unlock/info/update/center (default: false) +// +// !anchor config reset +// Reset all runtime config overrides; reverts to globalconfig / DEFAULTS. +// +// !anchor --help +// Whisper this help text to the caller. +// +// Configuration priority (lowest → highest): +// Hardcoded DEFAULTS → useroptions (API Scripts page) → !anchor config (runtime state) +// ============================================================================= + +/* global state, on, sendChat, getObj, createObj, Campaign, playerIsGM, toFront, toBack, log, _, setInterval, setTimeout, MatrixMath */ + +var Anchor = Anchor || (() => { + 'use strict'; + + // ------------------------------------------------------------------------- + // Constants + // ------------------------------------------------------------------------- + + const SCRIPT_NAME = 'Anchor'; + const SCRIPT_VERSION = '2.1.0'; + const CMD_TOKEN = '!anchor'; + + const DEFAULTS = { + // Polling + pollIntervalMs: 1000, + // Auto-created anchor token appearance + defaultAnchorLayer: 'gmlayer', + defaultAnchorSize: 35, + defaultAnchorName: 'Anchor', + defaultAnchorAuraColor: '#00ffff', + defaultAnchorAuraVisible: true, + // Roll20's built-in default character token image — a relative path that + // is available to all users without any library upload required. + // Users can override this via !anchor config default-anchor-imgsrc or + // the API Scripts page useroptions field. + defaultAnchorImgsrc: 'https://s3.amazonaws.com/files.d20.io/images/58010319/4S4xdTsHxQGVttCDSPsmnw/thumb.png?1531339299', + // Permissions + allowPlayerUse: false, + }; + + // All anchored transform components. + // 'zorder' is special — it has no single graphic attribute but is managed + // via toFront/toBack and stored as front/back ordered lists on the anchor. + const COMPONENTS = { + left: 'left', + top: 'top', + rotation: 'rotation', + width: 'width', + height: 'height', + layer: 'layer', + flipv: 'flipv', + fliph: 'fliph', + zorder: null, // managed separately, not a graphic attribute + }; + + // Components included in the default set (everything except zorder). + const DEFAULT_COMPONENTS = ['left','top','rotation','width','height','flipv','fliph']; + + // All components including layer and zorder. + const ALL_COMPONENTS = [...DEFAULT_COMPONENTS, 'layer', 'zorder']; + + // Long-form command flags that expand to component sets. + // Short aliases (e.g. -x, -rot) map to the same expansions via ALIAS_MAP below. + const FLAG_EXPANSIONS = { + // Explicit component flags (long form: anchor-, short form: -) + 'anchor-all': ALL_COMPONENTS, + 'anchor': DEFAULT_COMPONENTS, + 'anchor-position': ['left', 'top'], + 'anchor-x': ['left'], + 'anchor-y': ['top'], + 'anchor-rotation': ['rotation'], + 'anchor-scale': ['width', 'height'], + 'anchor-width': ['width'], + 'anchor-height': ['height'], + 'anchor-layer': ['layer'], + 'anchor-flip': ['flipv', 'fliph'], + 'anchor-flipv': ['flipv'], + 'anchor-fliph': ['fliph'], + 'anchor-z': ['zorder'], + }; + + // Short alias → canonical long-form flag + const ALIAS_MAP = { + '-all': 'anchor-all', + '-pos': 'anchor-position', + '-x': 'anchor-x', + '-y': 'anchor-y', + '-rot': 'anchor-rotation', + '-scale': 'anchor-scale', + '-w': 'anchor-width', + '-h': 'anchor-height', + '-layer': 'anchor-layer', + '-flip': 'anchor-flip', + '-flipv': 'anchor-flipv', + '-fliph': 'anchor-fliph', + '-z': 'anchor-z', + }; + + const ALL_COMMAND_FLAGS = [ + ...Object.keys(FLAG_EXPANSIONS), + ...Object.keys(ALIAS_MAP), + 'remove', 'lock', 'unlock', 'center', 'update', 'info', + 'track', 'untrack', 'retrack', + 'chain', 'unchain', + 'ignore-selected', 'persist', 'new', + 'config', + '--help', + ]; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + const cfg = () => state[SCRIPT_NAME].config; + + /** + * Whisper a message back to the sender. Optionally include a tag label. + * @param {object} msg - Roll20 chat message object + * @param {string} tagOrText - If `text` omitted, used as the message body. + * Otherwise used as a bracketed tag prefix. + * @param {string} [text] + */ + const reply = (msg, tagOrText, text) => { + const tag = text !== undefined ? ` [${tagOrText}]` : ''; + const body = text !== undefined ? text : tagOrText; + const recipient = msg.who.split(' ')[0]; + sendChat(`${SCRIPT_NAME}${tag}`, `/w ${recipient} ${body}`); + }; + + const isValidGraphic = (objId) => !!getObj('graphic', objId); + + /** Degrees to radians. */ + const toRad = (deg) => deg * Math.PI / 180; + + /** Normalise an angle to [0, 360). */ + const normDeg = (deg) => ((deg % 360) + 360) % 360; + + /** + * Build a 3×3 transform matrix for a graphic's current position + rotation. + */ + const buildTransform = (left, top, rotationDeg) => { + let m = MatrixMath.identity(3); + m = MatrixMath.multiply(m, MatrixMath.translate([left, top])); + m = MatrixMath.multiply(m, MatrixMath.rotate(toRad(rotationDeg))); + return m; + }; + + // ------------------------------------------------------------------------- + // State helpers + // ------------------------------------------------------------------------- + + /** + * Derive the set of currently tracked components from an anchorInfo object. + * Returns an object like { left: true, rotation: true, ... }. + */ + const getTrackedComponentsFromInfo = (info) => { + const tracked = {}; + if ('left' in info) tracked.left = true; + if ('top' in info) tracked.top = true; + if ('rotation' in info) tracked.rotation = true; + if ('widthRatio' in info) tracked.width = true; + if ('heightRatio' in info) tracked.height = true; + if ('layerOffset' in info) tracked.layer = true; + if ('flipv' in info) tracked.flipv = true; + if ('fliph' in info) tracked.fliph = true; + if ('zorder' in info) tracked.zorder = true; + return tracked; + }; + + /** + * Read an object's current graphic attrs into a plain snapshot object. + */ + const snapshotObj = (obj) => ({ + left: obj.get('left'), + top: obj.get('top'), + rotation: obj.get('rotation'), + width: obj.get('width'), + height: obj.get('height'), + layer: obj.get('layer'), + flipv: obj.get('flipv'), + fliph: obj.get('fliph'), + }); + + /** + * Ensure an object has an entry in objectStates and return it. + * If the entry doesn't exist, create it from the live graphic. + */ + const ensureObjState = (objId) => { + if (!objId) return undefined; + const s = state[SCRIPT_NAME]; + if (!s.objectStates[objId]) { + const obj = getObj('graphic', objId); + if (!obj) return undefined; + s.objectStates[objId] = snapshotObj(obj); + } + return s.objectStates[objId]; + }; + + /** + * Overwrite an object's state snapshot with current live values. + */ + const refreshObjState = (obj) => { + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + if (s.objectStates[id]) { + Object.assign(s.objectStates[id], snapshotObj(obj)); + } + }; + + // ------------------------------------------------------------------------- + // Anchor relationship management + // ------------------------------------------------------------------------- + + /** + * Compute a child's relative transform values at the time anchoring is + * established. Returns an anchorInfo object containing only the components + * that are being anchored. + * + * Position (left/top) is stored relative to the anchor's local frame so + * that it survives the anchor rotating. + */ + const computeAnchorInfo = (anchorId, childId, components) => { + const anchor = getObj('graphic', anchorId); + const child = getObj('graphic', childId); + if (!anchor || !child) return undefined; + + const aLeft = anchor.get('left'); + const aTop = anchor.get('top'); + const aRot = anchor.get('rotation'); + const cLeft = child.get('left'); + const cTop = child.get('top'); + const cRot = child.get('rotation'); + const aW = anchor.get('width'); + const aH = anchor.get('height'); + + const info = { id: childId, anchor_id: anchorId }; + + if (components.left || components.top) { + // Express child position in anchor-local frame (undo anchor rotation) + const relTransform = MatrixMath.multiply( + MatrixMath.rotate(toRad(-aRot)), + MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + ); + if (components.left) info.left = relTransform[2][0]; + if (components.top) info.top = relTransform[2][1]; + } + + if (components.rotation) info.rotation = normDeg(cRot - aRot); + if (components.width) info.widthRatio = aW > 0 ? child.get('width') / aW : 1; + if (components.height) info.heightRatio = aH > 0 ? child.get('height') / aH : 1; + if (components.layer) info.layerOffset = 0; // always same layer as anchor + if (components.flipv) info.flipv = child.get('flipv') !== anchor.get('flipv'); // true = flipped relative to parent + if (components.fliph) info.fliph = child.get('fliph') !== anchor.get('fliph'); + + // Z-order is not stored in anchorInfo per-child; instead the anchor + // maintains front/back ordered lists. We flag it here so setAnchor + // knows to register the child into those lists. + if (components.zorder) info.zorder = true; + + return info; + }; + + /** + * Remove stale entries for a child from anchorChildrenByAnchorId and clean + * up the anchor's objectState if it's no longer needed. + */ + const detachChildFromAnchor = (childId, anchorId) => { + const s = state[SCRIPT_NAME]; + if (!anchorId || !(anchorId in s.anchorChildrenByAnchorId)) return; + + delete s.anchorChildrenByAnchorId[anchorId][childId]; + + // Remove child from z-order lists if present + if (s.anchorZOrder && s.anchorZOrder[anchorId]) { + s.anchorZOrder[anchorId].front = s.anchorZOrder[anchorId].front.filter(id => id !== childId); + s.anchorZOrder[anchorId].back = s.anchorZOrder[anchorId].back.filter(id => id !== childId); + if (s.anchorZOrder[anchorId].front.length === 0 && s.anchorZOrder[anchorId].back.length === 0) { + delete s.anchorZOrder[anchorId]; + } + } + + if (Object.keys(s.anchorChildrenByAnchorId[anchorId]).length === 0) { + delete s.anchorChildrenByAnchorId[anchorId]; + // Clean up anchor's own objectState if it's not also a child + if (!(anchorId in s.anchorInfoByChildId)) { + delete s.objectStates[anchorId]; + } + // Auto-destroy auto-created anchor tokens that have lost all children + maybeDestroyAutoAnchor(anchorId); + } + }; + + /** + * If anchorId is an auto-created anchor token that now has no children, + * remove it from the map and from state. + * Called automatically; safe to call even if anchorId is not auto-created. + */ + const maybeDestroyAutoAnchor = (anchorId) => { + const s = state[SCRIPT_NAME]; + if (!(anchorId in s.autoCreatedAnchors)) return; + // Don't destroy if there's a pending setup waiting for add:graphic + if (anchorId in s.pendingAnchors) return; + if (anchorId in s.anchorChildrenByAnchorId) return; + delete s.autoCreatedAnchors[anchorId]; + delete s.objectStates[anchorId]; + const obj = getObj('graphic', anchorId); + if (obj) obj.remove(); + }; + + // ------------------------------------------------------------------------- + // Lock helpers + // ------------------------------------------------------------------------- + + /** + * Return the Set of locked components for a child, or an empty Set if none. + * Does NOT create an entry — use getOrCreateLockedSet for mutation. + */ + const getLockedComponents = (childId) => { + const entry = state[SCRIPT_NAME].lockedObjects[childId]; + if (!entry) return new Set(); + // Migrate old flat-value format (childId: childId) to Set on first access + if (!(entry instanceof Set)) { + const migrated = new Set(Object.keys(entry)); + state[SCRIPT_NAME].lockedObjects[childId] = migrated; + return migrated; + } + return entry; + }; + + const getOrCreateLockedSet = (childId) => { + const s = state[SCRIPT_NAME]; + if (!s.lockedObjects[childId] || !(s.lockedObjects[childId] instanceof Set)) { + s.lockedObjects[childId] = new Set(); + } + return s.lockedObjects[childId]; + }; + + /** + * Lock the given components for a child. + * If components is empty/undefined, locks all components (tracked + all possible). + * Components that aren't currently tracked are stored as "pre-locked". + */ + const lockComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + const locked = getOrCreateLockedSet(childId); + const toAdd = (components && Object.keys(components).length > 0) + ? Object.keys(components) + : ALL_COMPONENTS; + toAdd.forEach(c => locked.add(c)); + }; + + /** + * Unlock the given components for a child. + * If components is empty/undefined, unlocks everything (clears the entry). + */ + const unlockComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + if (!components || Object.keys(components).length === 0) { + delete s.lockedObjects[childId]; + return; + } + const locked = getLockedComponents(childId); + Object.keys(components).forEach(c => locked.delete(c)); + if (locked.size === 0) delete s.lockedObjects[childId]; + }; + + /** + * Return true if the given component is locked for this child. + */ + const isComponentLocked = (childId, component) => + getLockedComponents(childId).has(component); + + /** + * Return true if ANY component is locked for this child. + */ + const isAnyComponentLocked = (childId) => + getLockedComponents(childId).size > 0; + + // ------------------------------------------------------------------------- + // Tracking helpers (add/remove/replace tracked components) + // ------------------------------------------------------------------------- + + /** + * Add component tracking to an existing child relationship. + * Records the current live relative state for each new component. + * Preserves all existing tracked component offsets. + */ + const addTrackedComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + const existing = s.anchorInfoByChildId[childId]; + if (!existing) return; // not anchored + + // Compute fresh info for just the new components + const freshInfo = computeAnchorInfo(existing.anchor_id, childId, components); + if (!freshInfo) return; + + // Merge new component data into existing info + Object.keys(components).forEach(c => { + // Map component name to the actual key(s) stored in anchorInfo + switch(c) { + case 'left': if ('left' in freshInfo) existing.left = freshInfo.left; break; + case 'top': if ('top' in freshInfo) existing.top = freshInfo.top; break; + case 'rotation': if ('rotation' in freshInfo) existing.rotation = freshInfo.rotation; break; + case 'width': if ('widthRatio' in freshInfo) existing.widthRatio = freshInfo.widthRatio; break; + case 'height': if ('heightRatio' in freshInfo) existing.heightRatio = freshInfo.heightRatio; break; + case 'layer': if ('layerOffset' in freshInfo) existing.layerOffset = freshInfo.layerOffset; break; + case 'flipv': if ('flipv' in freshInfo) existing.flipv = freshInfo.flipv; break; + case 'fliph': if ('fliph' in freshInfo) existing.fliph = freshInfo.fliph; break; + case 'zorder': + existing.zorder = true; + registerChildZOrder(existing.anchor_id, childId); + break; + } + }); + }; + + /** + * Remove component tracking from an existing child relationship. + * Deletes the stored offset data for those components. + */ + const removeTrackedComponents = (childId, components) => { + const s = state[SCRIPT_NAME]; + const existing = s.anchorInfoByChildId[childId]; + if (!existing) return; + + Object.keys(components).forEach(c => { + switch(c) { + case 'left': delete existing.left; break; + case 'top': delete existing.top; break; + case 'rotation': delete existing.rotation; break; + case 'width': delete existing.widthRatio; break; + case 'height': delete existing.heightRatio; break; + case 'layer': delete existing.layerOffset; break; + case 'flipv': delete existing.flipv; break; + case 'fliph': delete existing.fliph; break; + case 'zorder': + delete existing.zorder; + // Remove from z-order lists + if (s.anchorZOrder && s.anchorZOrder[existing.anchor_id]) { + const lists = s.anchorZOrder[existing.anchor_id]; + lists.front = lists.front.filter(id => id !== childId); + lists.back = lists.back.filter(id => id !== childId); + } + break; + } + }); + }; + + /** + * Set or remove the anchor relationship for a single child. + * Pass anchorId = undefined to remove. + * Pass components = undefined when removing (ignored in that case). + */ + const setAnchor = (childId, anchorId, components) => { + if (!childId) return; + const s = state[SCRIPT_NAME]; + + // Detach from any previous anchor + const existingInfo = s.anchorInfoByChildId[childId]; + if (existingInfo) { + detachChildFromAnchor(childId, existingInfo.anchor_id); + } + + if (!anchorId || childId === anchorId) { + // Remove relationship entirely + delete s.anchorInfoByChildId[childId]; + if (!(childId in s.anchorChildrenByAnchorId)) { + delete s.objectStates[childId]; + } + delete s.lockedObjects[childId]; + return; + } + + const info = computeAnchorInfo(anchorId, childId, components); + if (!info) return; + + s.anchorInfoByChildId[childId] = info; + s.anchorChildrenByAnchorId[anchorId] = s.anchorChildrenByAnchorId[anchorId] || {}; + s.anchorChildrenByAnchorId[anchorId][childId] = childId; + + // If z-order tracking is requested, register child into the anchor's + // front/back lists based on current live z-position. + if (components.zorder) { + registerChildZOrder(anchorId, childId); + } + }; + + /** + * Read the live _zorder for the anchor's page and insert childId into + * the anchor's front[] or back[] list at the correct position relative + * to the anchor and any already-registered z-ordered children. + */ + const registerChildZOrder = (anchorId, childId) => { + const s = state[SCRIPT_NAME]; + const anchor = getObj('graphic', anchorId); + const child = getObj('graphic', childId); + if (!anchor || !child) return; + + const pageId = anchor.get('_pageid'); + const page = getObj('page', pageId); + if (!page) return; + + const zorder = page.get('_zorder').split(','); + const aIdx = zorder.indexOf(anchorId); + const cIdx = zorder.indexOf(childId); + if (aIdx === -1 || cIdx === -1) return; + + // Ensure the anchor has z-order lists + s.anchorZOrder = s.anchorZOrder || {}; + s.anchorZOrder[anchorId] = s.anchorZOrder[anchorId] || { front: [], back: [] }; + const lists = s.anchorZOrder[anchorId]; + + // Remove from both lists in case of re-registration + lists.front = lists.front.filter(id => id !== childId); + lists.back = lists.back.filter(id => id !== childId); + + if (cIdx > aIdx) { + // Child is in front of anchor — insert into front[] maintaining order + // front[] is ordered front-to-back (highest index first) + let inserted = false; + for (let i = 0; i < lists.front.length; i++) { + const existingIdx = zorder.indexOf(lists.front[i]); + if (cIdx > existingIdx) { + lists.front.splice(i, 0, childId); + inserted = true; + break; + } + } + if (!inserted) lists.front.push(childId); + } else { + // Child is behind anchor — insert into back[] maintaining order + // back[] is ordered front-to-back (highest index first) + let inserted = false; + for (let i = 0; i < lists.back.length; i++) { + const existingIdx = zorder.indexOf(lists.back[i]); + if (cIdx > existingIdx) { + lists.back.splice(i, 0, childId); + inserted = true; + break; + } + } + if (!inserted) lists.back.push(childId); + } + }; + + /** + * Establish anchor for multiple children at once, also ensuring objectStates + * are initialised for anchor and all children. + */ + const setAnchors = (anchorId, childIds, components) => { + ensureObjState(anchorId); + childIds.forEach(id => ensureObjState(id)); + childIds.forEach(id => setAnchor(id, anchorId, components)); + }; + + // ------------------------------------------------------------------------- + + // ------------------------------------------------------------------------- + // Auto-created anchor token + // ------------------------------------------------------------------------- + + /** + * Create a new invisible anchor graphic at the position of referenceObj + * (or the centre of the current player page if referenceObj is undefined). + * + * Made invisible via tint_color:"transparent" and isdrawing:true so it + * stays out of the turn tracker and targeting systems. A GM-only cyan + * aura (radius 0, square) gives the GM a visible click target. + * + * No imgsrc is set — Roll20 renders a plain square hidden by the tint. + * Portable: works for any user without requiring an image upload. + * + * Returns the new graphic object, or undefined on failure. + */ + const createAnchorToken = (referenceObj) => { + const c = cfg(); + const pageId = referenceObj + ? referenceObj.get('_pageid') + : Campaign().get('playerpageid'); + const left = referenceObj ? referenceObj.get('left') : 0; + const top = referenceObj ? referenceObj.get('top') : 0; + + // Create with only the properties Roll20 reliably accepts at creation time. + // Aura and visibility properties are applied via .set() immediately after + // to avoid silent creation failures from unrecognised attributes. + const token = createObj('graphic', { + pageid: pageId, + left: left, + top: top, + width: c.defaultAnchorSize, + height: c.defaultAnchorSize, + layer: c.defaultAnchorLayer, + name: c.defaultAnchorName, + // A transparent 1×1 PNG from the script author's Roll20 library. + // Roll20 requires a valid imgsrc to render the token at all — + // without it the token exists in the data model but is invisible + // and unselectable. The tint_color below then hides the image. + // Per Roll20 docs, images from the author's library work for all users. + imgsrc: c.defaultAnchorImgsrc, + }); + + if (!token) { + log(`${SCRIPT_NAME}: createObj failed — could not create anchor token on page ${pageId}`); + return undefined; + } + + // Apply additional properties after creation. + // NOTE: isdrawing intentionally omitted for now — it prevents token + // selection with the token tool and hides it from Ctrl+A on token layer. + token.set({ + tint_color: 'transparent', + // isdrawing intentionally omitted — marks token as drawing which + // prevents selection with the token tool and hides it from Ctrl+A + showname: false, + controlledby: '', + aura1_radius: c.defaultAnchorAuraVisible ? '0' : '', + aura1_color: c.defaultAnchorAuraColor, + aura1_square: true, + showplayers_aura1: false, + playersedit_aura1: false, + }); + + return token; + }; + + // Transform application + // ------------------------------------------------------------------------- + + /** + * Apply the anchor's current transform to a single child. + * If onlyComponents is provided, only those components are applied. + * Otherwise, all tracked but UNLOCKED components are applied. + */ + const applyAnchorToChild = (childId, onlyComponents, visited) => { + if (!visited) visited = new Set(); + if (visited.has(childId)) return; + visited.add(childId); + + const s = state[SCRIPT_NAME]; + const info = s.anchorInfoByChildId[childId]; + if (!info) { setAnchor(childId, undefined); return; } + + const child = getObj('graphic', childId); + const anchor = getObj('graphic', info.anchor_id); + + if (!anchor) { setAnchor(childId, undefined); return; } + if (!child) { setAnchor(childId, undefined); return; } + + // Determine which components to apply: + // If onlyComponents provided, use that. Otherwise apply all tracked + // components that are not locked. + const locked = getLockedComponents(childId); + const shouldApply = (component) => { + if (onlyComponents) return component in onlyComponents; + return !locked.has(component); + }; + + const updates = {}; + + if (('left' in info || 'top' in info) && (shouldApply('left') || shouldApply('top'))) { + const anchorTransform = buildTransform( + anchor.get('left'), anchor.get('top'), anchor.get('rotation') + ); + + // Mirror offsets when flip components are tracked and anchor is flipped. + // fliph flips the anchor horizontally → mirror the x (left) offset. + // flipv flips the anchor vertically → mirror the y (top) offset. + const aFlipH = anchor.get('fliph'); + const aFlipV = anchor.get('flipv'); + const localLeft = ('left' in info) + ? (('fliph' in info) && aFlipH ? -(info.left) : info.left) + : 0; + const localTop = ('top' in info) + ? (('flipv' in info) && aFlipV ? -(info.top) : info.top) + : 0; + + const childWorld = MatrixMath.multiply( + anchorTransform, + MatrixMath.translate([localLeft, localTop]) + ); + if ('left' in info && shouldApply('left')) updates.left = childWorld[2][0]; + if ('top' in info && shouldApply('top')) updates.top = childWorld[2][1]; + } + + if ('rotation' in info && shouldApply('rotation')) { + updates.rotation = normDeg(anchor.get('rotation') + info.rotation); + } + + if ('widthRatio' in info && shouldApply('width')) { + updates.width = anchor.get('width') * info.widthRatio; + } + + if ('heightRatio' in info && shouldApply('height')) { + updates.height = anchor.get('height') * info.heightRatio; + } + + if ('layerOffset' in info && shouldApply('layer')) { + // Only propagate layer when it has actually changed on the anchor + // (not on every positional update). This prevents auto-created anchors + // on the GM layer from pulling children to that layer. + const prevState = s.objectStates[info.anchor_id]; + const anchorLayer = anchor.get('layer'); + if (prevState && prevState.layer !== anchorLayer) { + updates.layer = anchorLayer; + } + } + + if ('flipv' in info && shouldApply('flipv')) { + updates.flipv = info.flipv ? !anchor.get('flipv') : anchor.get('flipv'); + } + + if ('fliph' in info && shouldApply('fliph')) { + updates.fliph = info.fliph ? !anchor.get('fliph') : anchor.get('fliph'); + } + + child.set(updates); + + // Update snapshot to reflect the new position + if (s.objectStates[childId]) { + Object.assign(s.objectStates[childId], updates); + } + + // Propagate to this child's own children (if it is also an anchor) + if (childId in s.anchorChildrenByAnchorId) { + Object.keys(s.anchorChildrenByAnchorId[childId]) + .forEach(grandchildId => applyAnchorToChild(grandchildId, undefined, visited)); + } + }; + + /** + * Restack all z-order-tracked children relative to the anchor. + * Call this after moving the anchor in z-order (e.g. via EasyReZorder). + * + * Stack order built by calling toFront back-to-front: + * last of back[], ..., first of back[], anchor, last of front[], ..., first of front[] + * Result from front to back: front[0], front[1], ..., anchor, back[0], back[1], ... + */ + const applyZOrderToChildren = (anchorId) => { + const s = state[SCRIPT_NAME]; + if (!s.anchorZOrder || !s.anchorZOrder[anchorId]) return; + + const { front, back } = s.anchorZOrder[anchorId]; + const anchor = getObj('graphic', anchorId); + if (!anchor) return; + + // Call toFront in back-to-front build order: + // deepest back children first, then shallower back, then anchor, then front + const buildOrder = [...back].reverse() + .concat([anchorId]) + .concat([...front].reverse()); + + buildOrder.forEach(id => { + const obj = id === anchorId ? anchor : getObj('graphic', id); + if (obj) toFront(obj); + }); + }; + + /** + * Called when a graphic changes. Handles two cases: + * + * 1. The changed object is an ANCHOR — propagate its new transform to + * all unlocked children. Locked children are skipped here; pollUpdates + * enforces their position every tick instead. + * + * 2. The changed object is an UNLOCKED CHILD — the GM has manually + * repositioned it, so re-record its new offset relative to its anchor. + * Locked children are intentionally ignored here; pollUpdates will + * undo any manual move on the next tick. + * + * childImmediateUpdate: when true, offset re-recording happens synchronously + * (used by the public API after programmatic moves). When false it is deferred + * via setTimeout so Roll20's own position-settling can complete first. + */ + const onObjectChanged = (obj, _prev, childImmediateUpdate = false) => { + if (!obj) return; + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + + // Case 1: changed object is a child — re-record offsets for unlocked components. + // If ALL tracked components are locked, skip entirely (poll handles enforcement). + if (id in s.anchorInfoByChildId) { + const info = s.anchorInfoByChildId[id]; + const locked = getLockedComponents(id); + // Determine which tracked components are not locked + const trackedComponents = getTrackedComponentsFromInfo(info); + const unlockedTracked = Object.fromEntries( + Object.keys(trackedComponents).filter(c => !locked.has(c)).map(c => [c, true]) + ); + if (Object.keys(unlockedTracked).length > 0) { + const recordOffset = () => { + const newInfo = computeAnchorInfo(info.anchor_id, id, unlockedTracked); + if (newInfo) { + // Merge only unlocked component data back in + Object.assign(s.anchorInfoByChildId[id], newInfo); + } + }; + childImmediateUpdate ? recordOffset() : setTimeout(recordOffset, 0); + } + } + + // Case 2: changed object is an anchor — push to children for their unlocked components + if (id in s.anchorChildrenByAnchorId) { + const visited = new Set([id]); + Object.keys(s.anchorChildrenByAnchorId[id]) + .forEach(childId => applyAnchorToChild(childId, undefined, visited)); + } + + refreshObjState(obj); + }; + + const onObjectChangedImmediate = (obj) => onObjectChanged(obj, undefined, true); + + // ------------------------------------------------------------------------- + // Polling + // ------------------------------------------------------------------------- + + /** + * Poll for position changes that the change:graphic events may have missed + * (e.g. bulk moves, map imports). Also enforces locked-child positions. + * + * Lock semantics: + * LOCKED — child is frozen relative to anchor; any manual move is undone + * every poll tick by re-applying the stored anchor transform. + * UNLOCKED — child follows anchor on change events normally, and if the + * child is manually moved the new relative offset is recorded. + */ + const pollUpdates = () => { + const s = state[SCRIPT_NAME]; + + // Process any pending anchor setups (fallback for add:graphic not firing) + Object.keys(s.pendingAnchors).forEach(anchorId => { + if (!isValidGraphic(anchorId)) return; + const pending = s.pendingAnchors[anchorId]; + delete s.pendingAnchors[anchorId]; + setAnchors(anchorId, pending.childIds, pending.components); + }); + + // Enforce locked children: for each child with any locked components, + // re-apply just the locked tracked components to undo any manual moves. + Object.keys(s.lockedObjects).forEach(id => { + if (!(id in s.anchorInfoByChildId)) return; + const info = s.anchorInfoByChildId[id]; + const locked = getLockedComponents(id); + const tracked = getTrackedComponentsFromInfo(info); + // Only enforce components that are both tracked AND locked + const lockedTracked = Object.fromEntries( + Object.keys(tracked).filter(c => locked.has(c)).map(c => [c, true]) + ); + if (Object.keys(lockedTracked).length > 0) { + applyAnchorToChild(id, lockedTracked); + } + }); + + // Detect external changes by comparing live values to snapshot + Object.entries(s.objectStates).forEach(([id, snap]) => { + const obj = getObj('graphic', id); + if (!obj) { + delete s.objectStates[id]; + return; + } + const live = snapshotObj(obj); + const changed = Object.keys(COMPONENTS).some(k => live[k] !== snap[k]); + if (changed) onObjectChanged(obj, snap); + }); + }; + + // ------------------------------------------------------------------------- + // Cleanup + // ------------------------------------------------------------------------- + + /** + * Remove all state entries that reference non-existent graphics. + * Called on ready and can be triggered manually. + */ + const cleanInvalidEntries = () => { + const s = state[SCRIPT_NAME]; + + // Clean anchorChildrenByAnchorId: remove bad anchor IDs and bad children + Object.keys(s.anchorChildrenByAnchorId).forEach(anchorId => { + if (!isValidGraphic(anchorId)) { + delete s.anchorChildrenByAnchorId[anchorId]; + return; + } + Object.keys(s.anchorChildrenByAnchorId[anchorId]).forEach(childId => { + if (!isValidGraphic(childId)) + delete s.anchorChildrenByAnchorId[anchorId][childId]; + }); + if (Object.keys(s.anchorChildrenByAnchorId[anchorId]).length === 0) + delete s.anchorChildrenByAnchorId[anchorId]; + }); + + // Clean anchorInfoByChildId + Object.keys(s.anchorInfoByChildId).forEach(childId => { + const info = s.anchorInfoByChildId[childId]; + if (!isValidGraphic(childId) || + !info || + !isValidGraphic(info.anchor_id) || + !(info.anchor_id in s.anchorChildrenByAnchorId) || + !(childId in s.anchorChildrenByAnchorId[info.anchor_id]) + ) { + delete s.anchorInfoByChildId[childId]; + } + }); + + // Clean lockedObjects — remove entries for invalid graphics + // and migrate old flat-value entries to Sets + Object.keys(s.lockedObjects).forEach(id => { + if (!isValidGraphic(id)) { + delete s.lockedObjects[id]; + return; + } + // Migrate old format (value was just the id string) + if (!(s.lockedObjects[id] instanceof Set)) { + s.lockedObjects[id] = new Set( + typeof s.lockedObjects[id] === 'object' + ? Object.keys(s.lockedObjects[id]) + : [] + ); + } + // Remove empty sets + if (s.lockedObjects[id].size === 0) delete s.lockedObjects[id]; + }); + + // Clean objectStates: keep only objects that are active anchors or children + Object.keys(s.objectStates).forEach(id => { + if (!isValidGraphic(id) || + (!(id in s.anchorInfoByChildId) && !(id in s.anchorChildrenByAnchorId)) + ) { + delete s.objectStates[id]; + } + }); + + // Clean autoCreatedAnchors + Object.keys(s.autoCreatedAnchors).forEach(id => { + if (!isValidGraphic(id)) delete s.autoCreatedAnchors[id]; + }); + + // Clean pendingAnchors: remove entries whose anchor token no longer exists + // and filter out any child IDs that are no longer valid + Object.keys(s.pendingAnchors).forEach(anchorId => { + if (!isValidGraphic(anchorId)) { + delete s.pendingAnchors[anchorId]; + return; + } + s.pendingAnchors[anchorId].childIds = + s.pendingAnchors[anchorId].childIds.filter(isValidGraphic); + if (s.pendingAnchors[anchorId].childIds.length === 0) { + delete s.pendingAnchors[anchorId]; + } + }); + + // Clean anchorZOrder: remove entries for invalid anchors or invalid children + Object.keys(s.anchorZOrder).forEach(anchorId => { + if (!isValidGraphic(anchorId)) { + delete s.anchorZOrder[anchorId]; + return; + } + const lists = s.anchorZOrder[anchorId]; + lists.front = lists.front.filter(isValidGraphic); + lists.back = lists.back.filter(isValidGraphic); + if (lists.front.length === 0 && lists.back.length === 0) { + delete s.anchorZOrder[anchorId]; + } + }); + }; + + // ------------------------------------------------------------------------- + // Event: destroy + // ------------------------------------------------------------------------- + + const onAddGraphic = (obj) => { + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + const pending = s.pendingAnchors[id]; + if (!pending) return; + + delete s.pendingAnchors[id]; + setAnchors(id, pending.childIds, pending.components); + }; + + const onDestroyObject = (obj) => { + const s = state[SCRIPT_NAME]; + const id = obj.get('id'); + + // If destroyed object was a child, detach it (may trigger auto-destroy + // of its anchor if that anchor is auto-created and now childless) + if (id in s.anchorInfoByChildId) { + const anchorId = s.anchorInfoByChildId[id].anchor_id; + detachChildFromAnchor(id, anchorId); + delete s.anchorInfoByChildId[id]; + } + + // If the destroyed object was itself an auto-created anchor, clean up + if (id in s.autoCreatedAnchors) { + delete s.autoCreatedAnchors[id]; + } + + // If destroyed object was an anchor, release all its children + if (id in s.anchorChildrenByAnchorId) { + Object.keys(s.anchorChildrenByAnchorId[id]) + .forEach(childId => { + delete s.anchorInfoByChildId[childId]; + if (!(childId in s.anchorChildrenByAnchorId)) + delete s.objectStates[childId]; + }); + delete s.anchorChildrenByAnchorId[id]; + } + + delete s.objectStates[id]; + }; + + // ------------------------------------------------------------------------- + // Chat command helpers + // ------------------------------------------------------------------------- + + const HELP_TEXT = [ + `${SCRIPT_NAME} v${SCRIPT_VERSION}`, + '', + `${CMD_TOKEN} [anchor_id] [flags] [ignore-selected] [child_id...]`, + 'Anchor selected/listed tokens. Auto-creates anchor token if no anchor_id given.', + 'Long form: anchor-all, anchor, anchor-position, anchor-x, anchor-y,', + 'anchor-rotation, anchor-scale, anchor-width, anchor-height, anchor-layer,', + 'anchor-flip, anchor-flipv, anchor-fliph, anchor-z', + 'Short aliases: -all, -pos, -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z', + 'Default (no flags): position+rotation+scale+flip. anchor-all/-all adds layer+z-order.', + '', + 'Add persist flag to keep an auto-created anchor token even when childless.', + '', + `${CMD_TOKEN} remove [ignore-selected] [child_id...]`, + 'Remove anchor from tokens.', + '', + `${CMD_TOKEN} lock [component flags] [ignore-selected] [child_id...]`, + 'Lock components — re-enforced every poll tick. No flags = lock all.', + 'Untracked components are pre-locked (activate when tracking is added).', + '', + `${CMD_TOKEN} unlock [component flags] [ignore-selected] [child_id...]`, + 'Unlock components. No flags = unlock all.', + '', + `${CMD_TOKEN} track [component flags] [ignore-selected] [child_id...]`, + 'Add tracking to existing relationship (records current relative state).', + '', + `${CMD_TOKEN} untrack [component flags] [ignore-selected] [child_id...]`, + 'Remove tracking. Does not affect locked state.', + '', + `${CMD_TOKEN} retrack [component flags] [ignore-selected] [child_id...]`, + 'Replace tracked set entirely. No flags = default set.', + '', + `${CMD_TOKEN} center [ignore-selected] [child_id...]`, + 'Snap child(ren) to anchor centre (0 offset, 0 rotation, 1:1 scale).', + '', + `${CMD_TOKEN} chain [component flags] [ignore-selected] [child_id...]`, + 'Mutually anchor tokens in a ring (A\u2192B, B\u2192C, C\u2192A). Move any one, all follow.', + '', + `${CMD_TOKEN} unchain [ignore-selected] [child_id...]`, + 'Dissolve a chain ring. Select any one token in the ring.', + '', + `${CMD_TOKEN} update [ignore-selected] [child_id...]`, + 'Force immediate transform sync.', + '', + `${CMD_TOKEN} info [ignore-selected] [child_id...]`, + 'Show anchor state for token(s).', + '', + `${CMD_TOKEN} config [key value] [reset]`, + 'View or change configuration. Keys: poll-interval, default-anchor-layer,', + 'default-anchor-size, default-anchor-name, default-anchor-aura-color,', + 'default-anchor-aura-visible, allow-player-use. Use reset to clear runtime overrides.', + ].join('
      '); + + /** + * Parse a flat args array into { flags: Set, otherArgs: string[] }. + * Recognised command flags are pulled out; everything else stays in otherArgs. + */ + const parseArgs = (argsArray) => { + const flags = new Set(); + const otherArgs = []; + argsArray.forEach(arg => { + // Resolve short aliases to their canonical long-form flag + const canonical = ALIAS_MAP[arg] || arg; + if (ALL_COMMAND_FLAGS.includes(canonical)) flags.add(canonical); + else otherArgs.push(arg); + }); + return { flags, otherArgs }; + }; + + /** + * Determine which COMPONENTS are being anchored based on the set of flags. + * If no component flags are present, defaults to DEFAULT_COMPONENTS (all + * except zorder). Use anchor-all / -all to include zorder. + */ + const resolveComponents = (flags) => { + const anchorFlags = Object.keys(FLAG_EXPANSIONS).filter(f => flags.has(f)); + if (anchorFlags.length === 0) { + // No explicit component flags → use default set (no zorder) + return Object.fromEntries(DEFAULT_COMPONENTS.map(k => [k, true])); + } + const active = {}; + anchorFlags.forEach(f => FLAG_EXPANSIONS[f].forEach(c => (active[c] = true))); + return active; + }; + + /** + * Like resolveComponents but returns null (not the default set) when no + * component flags are present. Used by lock/unlock where "no flags" means + * "operate on all components" rather than "use default set". + */ + const resolveComponentsOrNone = (flags) => { + const anchorFlags = Object.keys(FLAG_EXPANSIONS).filter(f => flags.has(f)); + if (anchorFlags.length === 0) return null; + const active = {}; + anchorFlags.forEach(f => FLAG_EXPANSIONS[f].forEach(c => (active[c] = true))); + return active; + }; + + /** + * Resolve the list of child IDs from the message context. + * Combines selected tokens (unless ignore-selected) with explicitly listed IDs. + */ + const resolveChildIds = (msg, flags, otherArgs) => { + const fromSelected = flags.has('ignore-selected') + ? [] + : (msg.selected || []).map(s => s._id); + return [...fromSelected, ...otherArgs].filter(isValidGraphic); + }; + + // ------------------------------------------------------------------------- + // Info display + // ------------------------------------------------------------------------- + + const showInfo = (msg, id) => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isAnchor = id in s.anchorChildrenByAnchorId; + const info = isChild ? s.anchorInfoByChildId[id] : null; + + let out = `Token: ${id}
      `; + out += `Anchor: ${info ? info.anchor_id : 'None'}
      `; + + if (isChild && info) { + const locked = getLockedComponents(id); + const tracked = getTrackedComponentsFromInfo(info); + + // Build tracked component display with lock status and stored values + const trackedDisplay = []; + if ('left' in info || 'top' in info) { + const locL = locked.has('left'); + const locT = locked.has('top'); + const lockStr = (locL && locT) ? ' 🔒' : locL ? ' (x🔒)' : locT ? ' (y🔒)' : ''; + trackedDisplay.push(`pos (${(info.left||0).toFixed(1)}, ${(info.top||0).toFixed(1)})${lockStr}`); + } + if ('rotation' in info) { + trackedDisplay.push(`rot ${info.rotation.toFixed(1)}°${locked.has('rotation') ? ' 🔒' : ''}`); + } + if ('widthRatio' in info) { + trackedDisplay.push(`w×${info.widthRatio.toFixed(3)}${locked.has('width') ? ' 🔒' : ''}`); + } + if ('heightRatio' in info) { + trackedDisplay.push(`h×${info.heightRatio.toFixed(3)}${locked.has('height') ? ' 🔒' : ''}`); + } + if ('layerOffset' in info) { + trackedDisplay.push(`layer${locked.has('layer') ? ' 🔒' : ''}`); + } + if ('flipv' in info) { + trackedDisplay.push(`flipv(${info.flipv ? 'flipped' : 'same'})${locked.has('flipv') ? ' 🔒' : ''}`); + } + if ('fliph' in info) { + trackedDisplay.push(`fliph(${info.fliph ? 'flipped' : 'same'})${locked.has('fliph') ? ' 🔒' : ''}`); + } + if ('zorder' in info) { + trackedDisplay.push(`z-order${locked.has('zorder') ? ' 🔒' : ''}`); + } + out += `Tracked: ${trackedDisplay.join(', ') || 'none'}
      `; + + // Pre-locked: locked but not tracked + const preLocked = [...locked].filter(c => !(c in tracked)); + if (preLocked.length > 0) { + out += `Pre-locked (untracked): ${preLocked.join(', ')}
      `; + } + } + + if (isAnchor) { + const childIds = Object.keys(s.anchorChildrenByAnchorId[id]); + out += `Children: ${childIds.join(', ')}
      `; + if (s.anchorZOrder && s.anchorZOrder[id]) { + const { front, back } = s.anchorZOrder[id]; + if (front.length > 0) out += `Z-front (front→back): ${front.join(', ')}
      `; + if (back.length > 0) out += `Z-back (front→back): ${back.join(', ')}
      `; + } + } + + const isAutoCreated = id in s.autoCreatedAnchors; + if (isAutoCreated) out += `Auto-created: yes (will auto-destroy when childless)
      `; + + reply(msg, 'Info', out); + }; + + // ------------------------------------------------------------------------- + // Config commands + // ------------------------------------------------------------------------- + + const showConfig = (msg) => { + const c = cfg(); + const lines = [ + `poll-interval: ${c.pollIntervalMs}ms`, + `default-anchor-layer: ${c.defaultAnchorLayer}`, + `default-anchor-size: ${c.defaultAnchorSize}px`, + `default-anchor-name: ${c.defaultAnchorName}`, + `default-anchor-imgsrc: ${c.defaultAnchorImgsrc ? '(set)' : '(not set)'}`, + `default-anchor-aura-color: ${c.defaultAnchorAuraColor}`, + `default-anchor-aura-visible: ${c.defaultAnchorAuraVisible}`, + `allow-player-use: ${c.allowPlayerUse}`, + ]; + reply(msg, 'Config', lines.join('
      ')); + }; + + const handleConfig = (msg, otherArgs) => { + const c = cfg(); + + if (otherArgs.length === 0) { showConfig(msg); return; } + if (otherArgs[0] === 'reset') { + // Delete the state config entirely so checkInstall rebuilds it + // from DEFAULTS + globalconfig on next sandbox restart. + // For immediate effect, also reassign from DEFAULTS now. + delete state[SCRIPT_NAME].config; + state[SCRIPT_NAME].config = Object.assign({}, DEFAULTS); + reply(msg, 'Config', 'Runtime config cleared. Values now reflect API Scripts page settings (or built-in defaults). Restart the sandbox to fully re-apply useroptions.'); + showConfig(msg); + return; + } + + const sub = otherArgs[0]; + const val = otherArgs[1]; + + if (sub === 'poll-interval') { + const ms = parseInt(val, 10); + if (isNaN(ms) || ms < 100) { + reply(msg, 'Config', 'poll-interval must be a number ≥ 100.'); + return; + } + c.pollIntervalMs = ms; + reply(msg, 'Config', `poll-interval set to ${ms}ms. Note: restart the API sandbox for the new interval to take effect.`); + return; + } + + if (sub === 'default-anchor-layer') { + const valid = ['gmlayer', 'objects', 'map']; + if (!valid.includes(val)) { + reply(msg, 'Config', `default-anchor-layer must be one of: ${valid.join(', ')}`); + return; + } + c.defaultAnchorLayer = val; + reply(msg, 'Config', `default-anchor-layer set to ${val}.`); + return; + } + + if (sub === 'default-anchor-size') { + const px = parseInt(val, 10); + if (isNaN(px) || px < 1) { + reply(msg, 'Config', 'default-anchor-size must be a positive integer.'); + return; + } + c.defaultAnchorSize = px; + reply(msg, 'Config', `default-anchor-size set to ${px}px.`); + return; + } + + if (sub === 'default-anchor-name') { + if (!val) { + reply(msg, 'Config', 'default-anchor-name requires a value.'); + return; + } + c.defaultAnchorName = val; + reply(msg, 'Config', `default-anchor-name set to "${val}".`); + return; + } + + if (sub === 'default-anchor-imgsrc') { + if (!val) { + reply(msg, 'Config', 'default-anchor-imgsrc requires a value — either a relative Roll20 path (e.g. /images/character.png) or a thumb URL from your Roll20 library.'); + return; + } + c.defaultAnchorImgsrc = val; + reply(msg, 'Config', `default-anchor-imgsrc set.`); + return; + } + + if (sub === 'default-anchor-aura-color') { + if (!val || !/^#[0-9a-fA-F]{6}$/.test(val)) { + reply(msg, 'Config', 'default-anchor-aura-color must be a hex color (e.g. #00ffff).'); + return; + } + c.defaultAnchorAuraColor = val; + reply(msg, 'Config', `default-anchor-aura-color set to ${val}.`); + return; + } + + if (sub === 'default-anchor-aura-visible') { + if (val !== 'true' && val !== 'false') { + reply(msg, 'Config', 'default-anchor-aura-visible must be true or false.'); + return; + } + c.defaultAnchorAuraVisible = val === 'true'; + reply(msg, 'Config', `default-anchor-aura-visible set to ${val}.`); + return; + } + + if (sub === 'allow-player-use') { + if (val !== 'true' && val !== 'false') { + reply(msg, 'Config', 'allow-player-use must be true or false.'); + return; + } + c.allowPlayerUse = val === 'true'; + reply(msg, 'Config', `allow-player-use set to ${val}.`); + return; + } + + const validKeys = [ + 'poll-interval', 'default-anchor-layer', 'default-anchor-size', + 'default-anchor-name', 'default-anchor-imgsrc', + 'default-anchor-aura-color', 'default-anchor-aura-visible', + 'allow-player-use', 'reset', + ]; + reply(msg, 'Config', `Unknown config key: ${sub}. Valid keys: ${validKeys.join(', ')}`); + }; + + // ------------------------------------------------------------------------- + // Main command handler + // ------------------------------------------------------------------------- + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + // Must start with the command token + if (msg.content.split(' ')[0] !== CMD_TOKEN) return; + + try { + const rawArgs = msg.content.slice(CMD_TOKEN.length).split(' ').filter(Boolean); + const { flags, otherArgs } = parseArgs(rawArgs); + + const isGM = playerIsGM(msg.playerid); + + // Non-GMs are blocked entirely unless allowPlayerUse is on. + // Even with allowPlayerUse, non-GMs cannot change config or + // create/remove anchor relationships — only info/lock/unlock/update/center + // on tokens they control. + if (!isGM && !cfg().allowPlayerUse) { + reply(msg, 'Error', 'Only the GM can use Anchor commands.'); + return; + } + + if (!isGM && (flags.has('config') || flags.has('remove') || + Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || + flags.size === 0)) { + reply(msg, 'Error', 'Players may only use: lock, unlock, update, center, info.'); + return; + } + + // --help + if (flags.has('--help')) { + reply(msg, HELP_TEXT); + return; + } + + // gen-dev-docs + if (flags.has('gen-dev-docs')) { + const handoutName = `Help: ${SCRIPT_NAME}/Scripting API`; + let hh = findObjs({ type: 'handout', name: handoutName })[0]; + if (!hh) { + hh = createObj('handout', { name: handoutName, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' }); + } + let html = `

      ${SCRIPT_NAME} — Scripting API

      `; + html += `

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

      `; + html += `

      Querying Relationships

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

      Creating / Removing

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

      Chain Linking

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

      Position (anchor-local)

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

      Rotation (anchor-local)

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

      Scale (anchor-local)

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

      Flip (relative to parent)

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

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

      `; + html += `

      Z-Order

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

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

      `; + html += `

      Lock / Unlock

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

      Forcing Updates

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

      Integration with Choreograph

      `; + html += `

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

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

      Integration with Sequence

      `; + html += `

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

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

      ${SCRIPT_NAME} v${SCRIPT_VERSION}

      `; + html += `

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

      `; + html += `

      Commands

      `; + html += `
        `; + html += `
      • !anchor [anchor_id] [flags] — Anchor selected tokens
      • `; + html += `
      • !anchor remove — Remove anchor relationship
      • `; + html += `
      • !anchor lock [flags] — Lock components
      • `; + html += `
      • !anchor unlock [flags] — Unlock components
      • `; + html += `
      • !anchor track [flags] — Add component tracking
      • `; + html += `
      • !anchor untrack [flags] — Remove component tracking
      • `; + html += `
      • !anchor retrack [flags] — Replace tracked set
      • `; + html += `
      • !anchor center — Snap children to anchor center
      • `; + html += `
      • !anchor update — Force immediate sync
      • `; + html += `
      • !anchor info — Show anchor state
      • `; + html += `
      • !anchor chain — Mutually anchor tokens in a ring
      • `; + html += `
      • !anchor unchain — Dissolve a chain ring from any member
      • `; + html += `
      • !anchor config [key] [value] — Configuration
      • `; + html += `
      • !anchor --help — Command reference
      • `; + html += `
      • !anchor gen-dev-docs — Generate scripting API handout
      • `; + html += `
      `; + html += `

      Component Flags

      `; + html += `

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

      `; + hh.set('notes', html); + })(); + + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('add:graphic', onAddGraphic); + on('change:graphic:left', onObjectChanged); + on('change:graphic:top', onObjectChanged); + on('change:graphic:rotation', onObjectChanged); + on('change:graphic:width', onObjectChanged); + on('change:graphic:height', onObjectChanged); + on('change:graphic:layer', onObjectChanged); + on('change:graphic:flipv', onObjectChanged); + on('change:graphic:fliph', onObjectChanged); + on('destroy:graphic', onDestroyObject); + + setInterval(pollUpdates, cfg().pollIntervalMs); + + // ── Sequence integration ────────────────────────────────────────── + const registerWithSequence = () => { + if (typeof Sequence === 'undefined') return; + + // Helper: update anchor object after modifying child's local state + const refreshAnchor = (obj) => { + const anchorId = getAnchor(obj.get('id')); + if (anchorId) { const a = getObj('graphic', anchorId); if (a) updateObj(a); } + }; + + // Register anchor-local position as virtual attributes + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'left', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local X position. Animates relative to anchor.', + valueType: 'number', + examples: ['+70 move right 70px in anchor space', '=0 snap to anchor center'], + startWatch: null, stopWatch: null, + get: (obj) => { const p = getPosition(obj); return p ? p[0] : obj.get('left'); }, + set: (obj, val) => { setPosition(obj, val, getPosition(obj)[1]); refreshAnchor(obj); }, + diff: (prev, curr) => { const d = Math.round((curr - prev) * 10000) / 10000; return d === 0 ? null : d; }, + apply: (obj, delta) => { const p = getPosition(obj); setPosition(obj, p[0] + delta, p[1]); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'top', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local Y position. Animates relative to anchor.', + valueType: 'number', + examples: ['+70 move down 70px in anchor space', '=0 snap to anchor center'], + startWatch: null, stopWatch: null, + get: (obj) => { const p = getPosition(obj); return p ? p[1] : obj.get('top'); }, + set: (obj, val) => { const p = getPosition(obj); setPosition(obj, p[0], val); refreshAnchor(obj); }, + diff: (prev, curr) => { const d = Math.round((curr - prev) * 10000) / 10000; return d === 0 ? null : d; }, + apply: (obj, delta) => { const p = getPosition(obj); setPosition(obj, p[0], p[1] + delta); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'rotation', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local rotation in degrees.', + valueType: 'number', + examples: ['+90 rotate 90° in anchor space'], + startWatch: null, stopWatch: null, + get: (obj) => { const r = getRotation(obj); return r !== undefined ? r : 0; }, + set: (obj, val) => { setRotation(obj, val); refreshAnchor(obj); }, + diff: (prev, curr) => { const d = Math.round((curr - prev) * 10000) / 10000; return d === 0 ? null : d; }, + apply: (obj, delta) => { setRotation(obj, getRotation(obj) + delta); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'scaleW', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local width scale (multiplicative — ×2 doubles).', + valueType: 'scale', + examples: ['×2 double width in anchor space', '×0.5 halve width'], + startWatch: null, stopWatch: null, + get: (obj) => { const s = getScale(obj); return s ? s[0] : 1; }, + set: (obj, val) => { const s = getScale(obj); setScale(obj, val, s ? s[1] : 1); refreshAnchor(obj); }, + diff: (prev, curr) => { if (!prev || prev === 0 || curr === prev) return null; const r = Math.round((curr / prev) * 10000) / 10000; return r === 1 ? null : r; }, + apply: (obj, ratio) => { const s = getScale(obj); setScale(obj, (s ? s[0] : 1) * ratio, s ? s[1] : 1); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 1 }), + format: (ratio) => `×${ratio}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + if (s.startsWith('×') || s.startsWith('*')) return { delta: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'scaleH', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local height scale (multiplicative).', + valueType: 'scale', + examples: ['×2 double height in anchor space'], + startWatch: null, stopWatch: null, + get: (obj) => { const s = getScale(obj); return s ? s[1] : 1; }, + set: (obj, val) => { const s = getScale(obj); setScale(obj, s ? s[0] : 1, val); refreshAnchor(obj); }, + diff: (prev, curr) => { if (!prev || prev === 0 || curr === prev) return null; const r = Math.round((curr / prev) * 10000) / 10000; return r === 1 ? null : r; }, + apply: (obj, ratio) => { const s = getScale(obj); setScale(obj, s ? s[0] : 1, (s ? s[1] : 1) * ratio); refreshAnchor(obj); }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 1 }), + format: (ratio) => `×${ratio}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + if (s.startsWith('×') || s.startsWith('*')) return { delta: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'flipV', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local vertical flip (true = flipped relative to parent).', + valueType: 'boolean', + examples: ['=true flip vertically', '=false unflip'], + startWatch: null, stopWatch: null, + get: (obj) => { const v = getFlipV(obj); return v !== undefined ? v : false; }, + set: (obj, val) => { setFlipV(obj, val); refreshAnchor(obj); }, + diff: (prev, curr) => curr === prev ? null : curr, + apply: (obj, val) => { setFlipV(obj, val); refreshAnchor(obj); }, + lerp: null, + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + const v = s.startsWith('=') ? s.slice(1) : s; + return { abs: v === 'true' || v === '1' }; + }, + }); + + Sequence.registerAttribute(SCRIPT_NAME, { + name: 'flipH', namespace: 'anchor', objectType: 'graphic', + description: 'Anchor-local horizontal flip (true = flipped relative to parent).', + valueType: 'boolean', + examples: ['=true flip horizontally', '=false unflip'], + startWatch: null, stopWatch: null, + get: (obj) => { const v = getFlipH(obj); return v !== undefined ? v : false; }, + set: (obj, val) => { setFlipH(obj, val); refreshAnchor(obj); }, + diff: (prev, curr) => curr === prev ? null : curr, + apply: (obj, val) => { setFlipH(obj, val); refreshAnchor(obj); }, + lerp: null, + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + const v = s.startsWith('=') ? s.slice(1) : s; + return { abs: v === 'true' || v === '1' }; + }, + }); + + log(`${SCRIPT_NAME}: registered anchor-local attributes with Sequence`); + }; + + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content === '!sequence-ready') registerWithSequence(); + }); + registerWithSequence(); + + // ── Choreograph integration ─────────────────────────────────────── + const registerWithChoreograph = () => { + if (typeof Choreograph === 'undefined') return; + + // Token variables (appear as token.anchor.parent, token.anchor.left, etc.) + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'parent', namespace: 'anchor', + description: 'The anchor token this token is attached to (or null)', + returns: 'token', + fn: (token) => { + const id = getAnchor(token.get('id')); + return id ? getObj('graphic', id) : null; + }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'left', namespace: 'anchor', + description: 'Anchor-local X position', + returns: 'number', + fn: (token) => { const p = getPosition(token); return p ? p[0] : 0; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'top', namespace: 'anchor', + description: 'Anchor-local Y position', + returns: 'number', + fn: (token) => { const p = getPosition(token); return p ? p[1] : 0; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'rotation', namespace: 'anchor', + description: 'Anchor-local rotation in degrees', + returns: 'number', + fn: (token) => { const r = getRotation(token); return r !== undefined ? r : 0; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'scaleW', namespace: 'anchor', + description: 'Anchor-local width scale ratio', + returns: 'number', + fn: (token) => { const s = getScale(token); return s ? s[0] : 1; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'scaleH', namespace: 'anchor', + description: 'Anchor-local height scale ratio', + returns: 'number', + fn: (token) => { const s = getScale(token); return s ? s[1] : 1; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'flipV', namespace: 'anchor', + description: 'Flipped vertically relative to anchor', + returns: 'boolean', + fn: (token) => { const v = getFlipV(token); return v !== undefined ? v : false; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'flipH', namespace: 'anchor', + description: 'Flipped horizontally relative to anchor', + returns: 'boolean', + fn: (token) => { const v = getFlipH(token); return v !== undefined ? v : false; }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'zOffset', namespace: 'anchor', + description: 'Z-order offset relative to anchor', + returns: 'number', + fn: (token) => getZOffset(token), + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'locked', namespace: 'anchor', + description: 'Locked component names', + returns: 'string[]', + fn: (token) => getLocked(token), + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'unlocked', namespace: 'anchor', + description: 'Tracked but unlocked component names', + returns: 'string[]', + fn: (token) => getUnlocked(token), + }); + + // Functions returning token arrays + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'siblings', namespace: 'anchor', + description: 'Other tokens anchored to the same parent', + returns: 'token[]', + fn: (token) => { + const anchorId = getAnchor(token.get('id')); + if (!anchorId) return []; + return (getChildren(anchorId) || []) + .filter(t => t.get('id') !== token.get('id')); + }, + }); + + Choreograph.registerTokenVariable(SCRIPT_NAME, { + name: 'children', namespace: 'anchor', + description: 'Tokens anchored to this token as children', + returns: 'token[]', + fn: (token) => getChildren(token.get('id')) || [], + }); + + // Lifecycle hook for !anchor commands in scenes + Choreograph.registerLifecycleHook(SCRIPT_NAME, { + commands: [/^!anchor\b/], + start: (ctx) => { handleInput(ctx); }, + stop: null, + pause: null, + resume: null, + }); + + log(`${SCRIPT_NAME}: registered with Choreograph`); + }; + + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content === '!choreograph-ready') registerWithChoreograph(); + }); + registerWithChoreograph(); + }; + + // ------------------------------------------------------------------------- + // Module export + // ------------------------------------------------------------------------- + + return { + // Lifecycle (called by on('ready')) + checkInstall, + registerEventHandlers, + + // Public API for other scripts + API: { + getAnchor, + getChildren, + anchorObj, + createAnchorFor, + removeAnchor, + updateObj, + updateZOrder, + getPosition, + setPosition, + getRotation, + setRotation, + getScale, + setScale, + getFlipV, + setFlipV, + getFlipH, + setFlipH, + getZOffset, + getLocked, + getUnlocked, + lock, + unlock, + chainAnchorObjs, + unchainAnchorObjs, + }, + }; +})(); + +on('ready', () => { + 'use strict'; + Anchor.checkInstall(); + Anchor.registerEventHandlers(); + // Expose the public API at the top level for other scripts: + // Anchor.getAnchor(id), Anchor.anchorObj(...), etc. + Object.assign(Anchor, Anchor.API); + delete Anchor.API; +}); \ No newline at end of file diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 398b29fdb2..4060e8b907 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -187,7 +187,7 @@ var Anchor = Anchor || (() => { 'remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain', - 'ignore-selected', 'persist', + 'ignore-selected', 'persist', 'new', 'config', '--help', ]; @@ -1540,9 +1540,15 @@ var Anchor = Anchor || (() => { if (otherArgs.length > 0 && isValidGraphic(otherArgs[0])) { // Use the supplied existing token as the anchor anchorId = otherArgs[0]; + } else if (!flags.has('new') && childIds.length > 1) { + // Selection-based: first selected = parent, rest = children + anchorId = childIds.shift(); + } else if (childIds.length === 1 && getAnchor(childIds[0]) && !flags.has('new')) { + // Single token already anchored — modify existing (handled below by lock/track) + reply(msg, 'Info', 'Token is already anchored. Use lock/unlock/track/untrack to modify, or new to create a new parent.'); + return; } else { - // No valid anchor ID supplied — auto-create an invisible anchor token. - // Place it at the first child's position. + // Auto-create invisible anchor (single selected or --new flag) const refObj = getObj('graphic', childIds[0]); const newToken = createAnchorToken(refObj); if (!newToken) { From 781f8ca36d4e768eb3f12daf071b6bad876e8743 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:04:34 -0400 Subject: [PATCH 76/96] =?UTF-8?q?Anchor:=20fix=20'new'=20flag=20=E2=80=94?= =?UTF-8?q?=20include=20in=20isNewAnchor=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Anchor/2.2.0/anchor.js | 2 +- Anchor/anchor.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 4060e8b907..7c48032890 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -1518,7 +1518,7 @@ var Anchor = Anchor || (() => { // If there's no valid graphic as the first arg, all otherArgs are child IDs. const ACTION_FLAGS = ['remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain']; const hasAction = ACTION_FLAGS.some(f => flags.has(f)); - const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.size === 0); + const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('new') || flags.size === 0); const firstArgIsAnchor = isNewAnchor && !flags.has('remove') && otherArgs.length > 0 && diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 4060e8b907..7c48032890 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -1518,7 +1518,7 @@ var Anchor = Anchor || (() => { // If there's no valid graphic as the first arg, all otherArgs are child IDs. const ACTION_FLAGS = ['remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain']; const hasAction = ACTION_FLAGS.some(f => flags.has(f)); - const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.size === 0); + const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('new') || flags.size === 0); const firstArgIsAnchor = isNewAnchor && !flags.has('remove') && otherArgs.length > 0 && From 43335a60ecd365171f2f0dab202df4a70f43ab11 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:08:52 -0400 Subject: [PATCH 77/96] Anchor: remove from parent unanchors children, up/down flags for disambiguation --- Anchor/2.2.0/anchor.js | 26 ++++++++++++++++++++++++-- Anchor/anchor.js | 26 ++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 7c48032890..a5739b5a55 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -187,7 +187,7 @@ var Anchor = Anchor || (() => { 'remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain', - 'ignore-selected', 'persist', 'new', + 'ignore-selected', 'persist', 'new', 'up', 'down', 'config', '--help', ]; @@ -1578,7 +1578,29 @@ var Anchor = Anchor || (() => { // Remove if (flags.has('remove')) { - childIds.forEach(id => setAnchor(id, undefined)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent) { + // Both — remove both directions unless up/down specified + if (flags.has('up')) { + setAnchor(id, undefined); + } else if (flags.has('down')) { + const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + children.forEach(cid => setAnchor(cid, undefined)); + } else { + setAnchor(id, undefined); + const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + children.forEach(cid => setAnchor(cid, undefined)); + } + } else if (isParent) { + const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + children.forEach(cid => setAnchor(cid, undefined)); + } else { + setAnchor(id, undefined); + } + }); } // Center diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 7c48032890..a5739b5a55 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -187,7 +187,7 @@ var Anchor = Anchor || (() => { 'remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain', - 'ignore-selected', 'persist', 'new', + 'ignore-selected', 'persist', 'new', 'up', 'down', 'config', '--help', ]; @@ -1578,7 +1578,29 @@ var Anchor = Anchor || (() => { // Remove if (flags.has('remove')) { - childIds.forEach(id => setAnchor(id, undefined)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent) { + // Both — remove both directions unless up/down specified + if (flags.has('up')) { + setAnchor(id, undefined); + } else if (flags.has('down')) { + const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + children.forEach(cid => setAnchor(cid, undefined)); + } else { + setAnchor(id, undefined); + const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + children.forEach(cid => setAnchor(cid, undefined)); + } + } else if (isParent) { + const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + children.forEach(cid => setAnchor(cid, undefined)); + } else { + setAnchor(id, undefined); + } + }); } // Center From 7d70268e9fcca6df3f66570b1cccf6c86f4627ee Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:24:44 -0400 Subject: [PATCH 78/96] Anchor v2.2.0: -- prefix for modifiers (--new, --up, --down), --persist/--ignore-selected aliases, up/down for track/untrack --- Anchor/2.2.0/anchor.js | 37 +++++++++++++++++++++++++------------ Anchor/anchor.js | 37 +++++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index a5739b5a55..39ce5b48a8 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -179,6 +179,8 @@ var Anchor = Anchor || (() => { '-flipv': 'anchor-flipv', '-fliph': 'anchor-fliph', '-z': 'anchor-z', + '--persist': 'persist', + '--ignore-selected': 'ignore-selected', }; const ALL_COMMAND_FLAGS = [ @@ -187,7 +189,7 @@ var Anchor = Anchor || (() => { 'remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain', - 'ignore-selected', 'persist', 'new', 'up', 'down', + 'ignore-selected', 'persist', '--new', '--up', '--down', 'config', '--help', ]; @@ -1518,7 +1520,7 @@ var Anchor = Anchor || (() => { // If there's no valid graphic as the first arg, all otherArgs are child IDs. const ACTION_FLAGS = ['remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain']; const hasAction = ACTION_FLAGS.some(f => flags.has(f)); - const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('new') || flags.size === 0); + const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('--new') || flags.size === 0); const firstArgIsAnchor = isNewAnchor && !flags.has('remove') && otherArgs.length > 0 && @@ -1540,12 +1542,12 @@ var Anchor = Anchor || (() => { if (otherArgs.length > 0 && isValidGraphic(otherArgs[0])) { // Use the supplied existing token as the anchor anchorId = otherArgs[0]; - } else if (!flags.has('new') && childIds.length > 1) { + } else if (!flags.has('--new') && childIds.length > 1) { // Selection-based: first selected = parent, rest = children anchorId = childIds.shift(); - } else if (childIds.length === 1 && getAnchor(childIds[0]) && !flags.has('new')) { + } else if (childIds.length === 1 && getAnchor(childIds[0]) && !flags.has('--new')) { // Single token already anchored — modify existing (handled below by lock/track) - reply(msg, 'Info', 'Token is already anchored. Use lock/unlock/track/untrack to modify, or new to create a new parent.'); + reply(msg, 'Info', 'Token is already anchored. Use lock/unlock/track/untrack to modify, or --new to create a new parent.'); return; } else { // Auto-create invisible anchor (single selected or --new flag) @@ -1584,9 +1586,9 @@ var Anchor = Anchor || (() => { const isParent = id in s.anchorChildrenByAnchorId; if (isChild && isParent) { // Both — remove both directions unless up/down specified - if (flags.has('up')) { + if (flags.has('--up')) { setAnchor(id, undefined); - } else if (flags.has('down')) { + } else if (flags.has('--down')) { const children = [...(s.anchorChildrenByAnchorId[id] || [])]; children.forEach(cid => setAnchor(cid, undefined)); } else { @@ -1637,19 +1639,30 @@ var Anchor = Anchor || (() => { if (flags.has('track')) { const comps = resolveComponents(flags); childIds.forEach(id => { - if (!(id in state[SCRIPT_NAME].anchorInfoByChildId)) { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + (s.anchorChildrenByAnchorId[id] || []).forEach(cid => addTrackedComponents(cid, comps)); + } else if (isChild) { + addTrackedComponents(id, comps); + } else { reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); - return; } - addTrackedComponents(id, comps); }); } if (flags.has('untrack')) { const comps = resolveComponents(flags); childIds.forEach(id => { - if (!(id in state[SCRIPT_NAME].anchorInfoByChildId)) return; - removeTrackedComponents(id, comps); + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + (s.anchorChildrenByAnchorId[id] || []).forEach(cid => removeTrackedComponents(cid, comps)); + } else if (isChild) { + removeTrackedComponents(id, comps); + } }); } diff --git a/Anchor/anchor.js b/Anchor/anchor.js index a5739b5a55..39ce5b48a8 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -179,6 +179,8 @@ var Anchor = Anchor || (() => { '-flipv': 'anchor-flipv', '-fliph': 'anchor-fliph', '-z': 'anchor-z', + '--persist': 'persist', + '--ignore-selected': 'ignore-selected', }; const ALL_COMMAND_FLAGS = [ @@ -187,7 +189,7 @@ var Anchor = Anchor || (() => { 'remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain', - 'ignore-selected', 'persist', 'new', 'up', 'down', + 'ignore-selected', 'persist', '--new', '--up', '--down', 'config', '--help', ]; @@ -1518,7 +1520,7 @@ var Anchor = Anchor || (() => { // If there's no valid graphic as the first arg, all otherArgs are child IDs. const ACTION_FLAGS = ['remove', 'lock', 'unlock', 'center', 'update', 'info', 'track', 'untrack', 'retrack', 'chain', 'unchain']; const hasAction = ACTION_FLAGS.some(f => flags.has(f)); - const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('new') || flags.size === 0); + const isNewAnchor = !hasAction && (Object.keys(FLAG_EXPANSIONS).some(f => flags.has(f)) || flags.has('--new') || flags.size === 0); const firstArgIsAnchor = isNewAnchor && !flags.has('remove') && otherArgs.length > 0 && @@ -1540,12 +1542,12 @@ var Anchor = Anchor || (() => { if (otherArgs.length > 0 && isValidGraphic(otherArgs[0])) { // Use the supplied existing token as the anchor anchorId = otherArgs[0]; - } else if (!flags.has('new') && childIds.length > 1) { + } else if (!flags.has('--new') && childIds.length > 1) { // Selection-based: first selected = parent, rest = children anchorId = childIds.shift(); - } else if (childIds.length === 1 && getAnchor(childIds[0]) && !flags.has('new')) { + } else if (childIds.length === 1 && getAnchor(childIds[0]) && !flags.has('--new')) { // Single token already anchored — modify existing (handled below by lock/track) - reply(msg, 'Info', 'Token is already anchored. Use lock/unlock/track/untrack to modify, or new to create a new parent.'); + reply(msg, 'Info', 'Token is already anchored. Use lock/unlock/track/untrack to modify, or --new to create a new parent.'); return; } else { // Auto-create invisible anchor (single selected or --new flag) @@ -1584,9 +1586,9 @@ var Anchor = Anchor || (() => { const isParent = id in s.anchorChildrenByAnchorId; if (isChild && isParent) { // Both — remove both directions unless up/down specified - if (flags.has('up')) { + if (flags.has('--up')) { setAnchor(id, undefined); - } else if (flags.has('down')) { + } else if (flags.has('--down')) { const children = [...(s.anchorChildrenByAnchorId[id] || [])]; children.forEach(cid => setAnchor(cid, undefined)); } else { @@ -1637,19 +1639,30 @@ var Anchor = Anchor || (() => { if (flags.has('track')) { const comps = resolveComponents(flags); childIds.forEach(id => { - if (!(id in state[SCRIPT_NAME].anchorInfoByChildId)) { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + (s.anchorChildrenByAnchorId[id] || []).forEach(cid => addTrackedComponents(cid, comps)); + } else if (isChild) { + addTrackedComponents(id, comps); + } else { reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); - return; } - addTrackedComponents(id, comps); }); } if (flags.has('untrack')) { const comps = resolveComponents(flags); childIds.forEach(id => { - if (!(id in state[SCRIPT_NAME].anchorInfoByChildId)) return; - removeTrackedComponents(id, comps); + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (flags.has('--down') && isParent) { + (s.anchorChildrenByAnchorId[id] || []).forEach(cid => removeTrackedComponents(cid, comps)); + } else if (isChild) { + removeTrackedComponents(id, comps); + } }); } From ab23c4676c68e168d990c44ea6be4f802baa09da Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:36:05 -0400 Subject: [PATCH 79/96] Anchor v2.2.0: bump header/script.json version, update README and help text --- Anchor/2.2.0/anchor.js | 44 +++++++++++++-------------- Anchor/README.md | 67 +++++++++++++++++++++++++++++------------- Anchor/anchor.js | 44 +++++++++++++-------------- Anchor/script.json | 4 +-- 4 files changed, 92 insertions(+), 67 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 39ce5b48a8..4241ae3b4e 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -1,6 +1,6 @@ // ============================================================================= -// Anchor v2.1.0 -// Last Updated: 2026-06-12 +// Anchor v2.2.0 +// Last Updated: 2026-06-26 // Author: Kenan Millet // // Description: @@ -102,7 +102,7 @@ var Anchor = Anchor || (() => { // ------------------------------------------------------------------------- const SCRIPT_NAME = 'Anchor'; - const SCRIPT_VERSION = '2.1.0'; + const SCRIPT_VERSION = '2.2.0'; const CMD_TOKEN = '!anchor'; const DEFAULTS = { @@ -1103,42 +1103,42 @@ var Anchor = Anchor || (() => { const HELP_TEXT = [ `${SCRIPT_NAME} v${SCRIPT_VERSION}`, '', - `${CMD_TOKEN} [anchor_id] [flags] [ignore-selected] [child_id...]`, - 'Anchor selected/listed tokens. Auto-creates anchor token if no anchor_id given.', - 'Long form: anchor-all, anchor, anchor-position, anchor-x, anchor-y,', + `${CMD_TOKEN} [anchor_id] [flags] [--ignore-selected] [--new] [--persist] [child_id...]`, + 'Anchor selected tokens. First selected = parent, rest = children.', + '--new: force auto-create invisible anchor (all selected = children).', + '--persist: keep auto-created anchor even when childless.', + 'Long form: anchor-all, anchor-position, anchor-x, anchor-y,', 'anchor-rotation, anchor-scale, anchor-width, anchor-height, anchor-layer,', 'anchor-flip, anchor-flipv, anchor-fliph, anchor-z', 'Short aliases: -all, -pos, -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z', - 'Default (no flags): position+rotation+scale+flip. anchor-all/-all adds layer+z-order.', + 'Default (no flags): position+rotation+scale+flip. -all adds layer+z-order.', '', - 'Add persist flag to keep an auto-created anchor token even when childless.', + `${CMD_TOKEN} remove [--up] [--down] [--ignore-selected] [child_id...]`, + 'Remove anchor relationships. Parent selected = unanchor children.', + '--up: remove only parent link. --down: remove only children.', '', - `${CMD_TOKEN} remove [ignore-selected] [child_id...]`, - 'Remove anchor from tokens.', - '', - `${CMD_TOKEN} lock [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} lock [component flags] [--ignore-selected] [child_id...]`, 'Lock components — re-enforced every poll tick. No flags = lock all.', - 'Untracked components are pre-locked (activate when tracking is added).', '', - `${CMD_TOKEN} unlock [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} unlock [component flags] [--ignore-selected] [child_id...]`, 'Unlock components. No flags = unlock all.', '', - `${CMD_TOKEN} track [component flags] [ignore-selected] [child_id...]`, - 'Add tracking to existing relationship (records current relative state).', + `${CMD_TOKEN} track [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Add tracking to existing relationship. --down: apply to children.', '', - `${CMD_TOKEN} untrack [component flags] [ignore-selected] [child_id...]`, - 'Remove tracking. Does not affect locked state.', + `${CMD_TOKEN} untrack [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Remove tracking. --down: apply to children.', '', - `${CMD_TOKEN} retrack [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} retrack [component flags] [--ignore-selected] [child_id...]`, 'Replace tracked set entirely. No flags = default set.', '', - `${CMD_TOKEN} center [ignore-selected] [child_id...]`, + `${CMD_TOKEN} center [--ignore-selected] [child_id...]`, 'Snap child(ren) to anchor centre (0 offset, 0 rotation, 1:1 scale).', '', - `${CMD_TOKEN} chain [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} chain [component flags] [--ignore-selected] [child_id...]`, 'Mutually anchor tokens in a ring (A\u2192B, B\u2192C, C\u2192A). Move any one, all follow.', '', - `${CMD_TOKEN} unchain [ignore-selected] [child_id...]`, + `${CMD_TOKEN} unchain [--ignore-selected] [child_id...]`, 'Dissolve a chain ring. Select any one token in the ring.', '', `${CMD_TOKEN} update [ignore-selected] [child_id...]`, diff --git a/Anchor/README.md b/Anchor/README.md index 707a226472..c283e001f7 100644 --- a/Anchor/README.md +++ b/Anchor/README.md @@ -20,15 +20,21 @@ Attach child tokens to an anchor token so they automatically follow its position ## Quick Start -Select one or more tokens and run: +Select a parent token and one or more child tokens, then run: ``` !anchor ``` -An invisible anchor token is auto-created at the first selected token's position on the GM layer. Move the anchor — the children follow. +The first selected token becomes the parent anchor. The rest become children that follow the parent's transforms. -To use an existing token as the anchor instead: +To auto-create an invisible anchor instead (children move together with no visible parent): + +``` +!anchor --new +``` + +To use an existing token as the anchor by ID: ``` !anchor @@ -38,15 +44,22 @@ To use an existing token as the anchor instead: ## Commands -All commands accept `[ignore-selected]` to skip the current token selection, and `[child_id...]` to specify tokens explicitly by ID. +All commands accept `[--ignore-selected]` to skip the current token selection, and `[child_id...]` to specify tokens explicitly by ID. ### Anchoring ``` -!anchor [anchor_id] [component flags] [ignore-selected] [child_id...] +!anchor [anchor_id] [component flags] [--ignore-selected] [--new] [--persist] [child_id...] ``` -Anchor selected/listed tokens to `anchor_id`. If `anchor_id` is omitted or not a valid token, an invisible anchor token is auto-created at the first child's position. The auto-created token is destroyed automatically when its last child is removed. Add `persist` to keep it even when childless. +Anchor selected/listed tokens to `anchor_id`. + +**Selection-based parenting** (when no `anchor_id` is given): +- **Multiple tokens selected:** first selected = parent, rest = children. +- **One token selected (not already anchored):** auto-creates an invisible anchor. +- **One token selected (already anchored):** reports existing relationship. +- **`--new` flag:** force auto-create an invisible anchor (all selected become children). +- **`--persist`:** keep auto-created anchors even when childless (otherwise auto-destroyed). **Default components** (when no flags given): position, rotation, scale, width, height, flipv, fliph. @@ -71,11 +84,12 @@ Anchor selected/listed tokens to `anchor_id`. If `anchor_id` is omitted or not a **Examples:** ``` -!anchor -- anchor selected token(s), all default components +!anchor -- first selected = parent, rest = children +!anchor --new -- auto-create invisible anchor, all selected = children +!anchor --new --persist -- same, but anchor persists when childless !anchor -x -rot -layer -- anchor x-position, rotation, and layer only !anchor anchor-all -- anchor everything including z-order -!anchor -OtyABCDEF123 -- anchor to an existing token -!anchor persist -- auto-create anchor and keep it when childless +!anchor -OtyABCDEF123 -- anchor selected to an existing token by ID ``` When z-order anchoring (`-z` / `anchor-z`) is active, call `Anchor.updateZOrder(anchorObj)` from another script (e.g. EasyReZorder) after moving the anchor in z-order to propagate the new stack to children. @@ -85,18 +99,21 @@ When z-order anchoring (`-z` / `anchor-z`) is active, call `Anchor.updateZOrder( ### Removing ``` -!anchor remove [ignore-selected] [child_id...] +!anchor remove [--up] [--down] [--ignore-selected] [child_id...] ``` -Remove the anchor relationship from tokens. Does not delete the anchor token itself. +Remove anchor relationships from tokens. +- **Child selected:** removes it from its parent. +- **Parent selected:** unanchors all its children. +- **Both parent and child:** removes both directions. Use `--up` (remove parent link only) or `--down` (remove children only) to disambiguate. --- ### Locking and Unlocking ``` -!anchor lock [component flags] [ignore-selected] [child_id...] -!anchor unlock [component flags] [ignore-selected] [child_id...] +!anchor lock [component flags] [--ignore-selected] [child_id...] +!anchor unlock [component flags] [--ignore-selected] [child_id...] ``` **Lock** freezes components — manual moves are undone every poll tick, and anchor changes to those components are ignored. With no component flags, locks all components. @@ -110,9 +127,9 @@ Components can be locked before they are tracked ("pre-locked") — they will ac ### Tracking ``` -!anchor track [component flags] [ignore-selected] [child_id...] -!anchor untrack [component flags] [ignore-selected] [child_id...] -!anchor retrack [component flags] [ignore-selected] [child_id...] +!anchor track [component flags] [--down] [--ignore-selected] [child_id...] +!anchor untrack [component flags] [--down] [--ignore-selected] [child_id...] +!anchor retrack [component flags] [--ignore-selected] [child_id...] ``` Modify which components are tracked on existing relationships without disturbing other stored offsets. @@ -120,33 +137,34 @@ Modify which components are tracked on existing relationships without disturbing - **`track`** — add components, recording the current relative state as the offset. - **`untrack`** — remove components. Does not affect locked state. - **`retrack`** — replace the tracked set entirely. No flags = default set. +- **`--down`** — when a parent is selected, apply to all children instead of the parent's own relationship. --- ### Other Commands ``` -!anchor center [ignore-selected] [child_id...] +!anchor center [--ignore-selected] [child_id...] ``` Snap children to anchor centre (offset 0,0, rotation 0°, scale 1:1). ``` -!anchor update [ignore-selected] [child_id...] +!anchor update [--ignore-selected] [child_id...] ``` Force an immediate transform sync for children. ``` -!anchor info [ignore-selected] [child_id...] +!anchor info [--ignore-selected] [child_id...] ``` Whisper anchor state to the caller. Shows tracked components with stored values, lock status (🔒), and pre-locked untracked components. With no tokens selected or specified, shows all anchored tokens on the current page. ``` -!anchor chain [component flags] [ignore-selected] [child_id...] +!anchor chain [component flags] [--ignore-selected] [child_id...] ``` Mutually anchor tokens in a ring (A→B, B→C, C→A). Move any one and all others follow. Useful for syncing tokens across pages or creating peer-linked groups. ``` -!anchor unchain [ignore-selected] [child_id...] +!anchor unchain [--ignore-selected] [child_id...] ``` Dissolve a chain ring. Select any one token in the ring (or even a child of a ring member) — the ring is detected and all relationships in it are removed. @@ -289,6 +307,13 @@ The public API changed: ## Changelog +### v2.2.0 +- Selection-based parenting: first selected = parent when multiple tokens selected +- `--new` flag: force auto-create invisible anchor (all selected become children) +- `--up` / `--down` modifiers for `remove`, `track`, `untrack` disambiguation +- `--persist` and `--ignore-selected` as preferred syntax (bare words still work) +- `remove` from parent now unanchors all children + ### v2.1.0 - Rewrite with full ES6 modernisation (IIFE module pattern, `const`/`let`, arrow functions) - Added component flags system with short aliases (`-x`, `-rot`, `-z`, etc.) diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 39ce5b48a8..4241ae3b4e 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -1,6 +1,6 @@ // ============================================================================= -// Anchor v2.1.0 -// Last Updated: 2026-06-12 +// Anchor v2.2.0 +// Last Updated: 2026-06-26 // Author: Kenan Millet // // Description: @@ -102,7 +102,7 @@ var Anchor = Anchor || (() => { // ------------------------------------------------------------------------- const SCRIPT_NAME = 'Anchor'; - const SCRIPT_VERSION = '2.1.0'; + const SCRIPT_VERSION = '2.2.0'; const CMD_TOKEN = '!anchor'; const DEFAULTS = { @@ -1103,42 +1103,42 @@ var Anchor = Anchor || (() => { const HELP_TEXT = [ `${SCRIPT_NAME} v${SCRIPT_VERSION}`, '', - `${CMD_TOKEN} [anchor_id] [flags] [ignore-selected] [child_id...]`, - 'Anchor selected/listed tokens. Auto-creates anchor token if no anchor_id given.', - 'Long form: anchor-all, anchor, anchor-position, anchor-x, anchor-y,', + `${CMD_TOKEN} [anchor_id] [flags] [--ignore-selected] [--new] [--persist] [child_id...]`, + 'Anchor selected tokens. First selected = parent, rest = children.', + '--new: force auto-create invisible anchor (all selected = children).', + '--persist: keep auto-created anchor even when childless.', + 'Long form: anchor-all, anchor-position, anchor-x, anchor-y,', 'anchor-rotation, anchor-scale, anchor-width, anchor-height, anchor-layer,', 'anchor-flip, anchor-flipv, anchor-fliph, anchor-z', 'Short aliases: -all, -pos, -x, -y, -rot, -scale, -w, -h, -layer, -flip, -flipv, -fliph, -z', - 'Default (no flags): position+rotation+scale+flip. anchor-all/-all adds layer+z-order.', + 'Default (no flags): position+rotation+scale+flip. -all adds layer+z-order.', '', - 'Add persist flag to keep an auto-created anchor token even when childless.', + `${CMD_TOKEN} remove [--up] [--down] [--ignore-selected] [child_id...]`, + 'Remove anchor relationships. Parent selected = unanchor children.', + '--up: remove only parent link. --down: remove only children.', '', - `${CMD_TOKEN} remove [ignore-selected] [child_id...]`, - 'Remove anchor from tokens.', - '', - `${CMD_TOKEN} lock [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} lock [component flags] [--ignore-selected] [child_id...]`, 'Lock components — re-enforced every poll tick. No flags = lock all.', - 'Untracked components are pre-locked (activate when tracking is added).', '', - `${CMD_TOKEN} unlock [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} unlock [component flags] [--ignore-selected] [child_id...]`, 'Unlock components. No flags = unlock all.', '', - `${CMD_TOKEN} track [component flags] [ignore-selected] [child_id...]`, - 'Add tracking to existing relationship (records current relative state).', + `${CMD_TOKEN} track [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Add tracking to existing relationship. --down: apply to children.', '', - `${CMD_TOKEN} untrack [component flags] [ignore-selected] [child_id...]`, - 'Remove tracking. Does not affect locked state.', + `${CMD_TOKEN} untrack [component flags] [--down] [--ignore-selected] [child_id...]`, + 'Remove tracking. --down: apply to children.', '', - `${CMD_TOKEN} retrack [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} retrack [component flags] [--ignore-selected] [child_id...]`, 'Replace tracked set entirely. No flags = default set.', '', - `${CMD_TOKEN} center [ignore-selected] [child_id...]`, + `${CMD_TOKEN} center [--ignore-selected] [child_id...]`, 'Snap child(ren) to anchor centre (0 offset, 0 rotation, 1:1 scale).', '', - `${CMD_TOKEN} chain [component flags] [ignore-selected] [child_id...]`, + `${CMD_TOKEN} chain [component flags] [--ignore-selected] [child_id...]`, 'Mutually anchor tokens in a ring (A\u2192B, B\u2192C, C\u2192A). Move any one, all follow.', '', - `${CMD_TOKEN} unchain [ignore-selected] [child_id...]`, + `${CMD_TOKEN} unchain [--ignore-selected] [child_id...]`, 'Dissolve a chain ring. Select any one token in the ring.', '', `${CMD_TOKEN} update [ignore-selected] [child_id...]`, diff --git a/Anchor/script.json b/Anchor/script.json index bac868e416..28bb8a8544 100644 --- a/Anchor/script.json +++ b/Anchor/script.json @@ -1,8 +1,8 @@ { "name": "Anchor", "script": "anchor.js", - "version": "2.1.0", - "previousversions": [ + "version": "2.2.0", + "previousversions": ["2.1.0", "1.0.0" ], "description": "Attach child tokens to an anchor token so they automatically follow its position, rotation, scale, layer, and flip. When the anchor moves, rotates, scales, or flips, all anchored children update to match their stored relative transform. Chains are supported: a child can itself be an anchor to grandchildren.\n\nAuto-creates an invisible anchor token when no anchor ID is supplied. The auto-created token is destroyed when its last child is removed.\n\n**Setup:** Upload a small transparent PNG to your Roll20 image library and set its thumb URL via !anchor config default-anchor-imgsrc, or on the API Scripts page. Without this, auto-created anchor tokens will not be visible or selectable.\n\n**Commands**\n\n\t!anchor [anchor_id] [flags] [ignore-selected] [child_id...]\n\tAnchor tokens. Component flags (long/short): anchor-all/-all, anchor/-default, anchor-position/-pos, anchor-x/-x, anchor-y/-y, anchor-rotation/-rot, anchor-scale/-scale, anchor-width/-w, anchor-height/-h, anchor-layer/-layer, anchor-flip/-flip, anchor-flipv/-flipv, anchor-fliph/-fliph, anchor-z/-z\n\n\t!anchor remove -- Remove anchor relationship\n\n\t!anchor lock [flags] -- Lock components (re-enforced every poll tick). No flags = lock all.\n\t!anchor unlock [flags] -- Unlock components. No flags = unlock all.\n\n\t!anchor track [flags] -- Add component tracking to existing relationship.\n\t!anchor untrack [flags] -- Remove component tracking.\n\t!anchor retrack [flags] -- Replace tracked set. No flags = default set.\n\n\t!anchor center -- Snap to anchor centre (offset 0,0, rotation 0, scale 1:1).\n\t!anchor update -- Force immediate transform sync.\n\t!anchor info -- Show anchor state (tracked components, lock status).\n\t!anchor config [key value] [reset] -- View or change configuration.\n\t!anchor --help -- Show command reference.\n\n**Scripting API** (for use by other scripts after ready):\n\tAnchor.getAnchor(id), Anchor.getChildren(id), Anchor.anchorObj(childId, anchorId, components)\n\tAnchor.createAnchorFor(obj, components, persist) -- auto-create anchor token programmatically\n\tAnchor.removeAnchor(id), Anchor.updateObj(obj), Anchor.updateZOrder(obj)\n\tAnchor.getPosition/setPosition, getRotation/setRotation, getScale/setScale\n\n**Dependencies:** MatrixMath", From ebad719885f2c7ef0f987000e982d59524bb4157 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:39:22 -0400 Subject: [PATCH 80/96] =?UTF-8?q?Anchor:=20fix=20anchorChildrenByAnchorId?= =?UTF-8?q?=20is=20object=20not=20array=20=E2=80=94=20use=20Object.keys()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Anchor/2.2.0/anchor.js | 10 +++++----- Anchor/anchor.js | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 4241ae3b4e..2728d14301 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -1589,15 +1589,15 @@ var Anchor = Anchor || (() => { if (flags.has('--up')) { setAnchor(id, undefined); } else if (flags.has('--down')) { - const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } else { setAnchor(id, undefined); - const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } } else if (isParent) { - const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } else { setAnchor(id, undefined); @@ -1643,7 +1643,7 @@ var Anchor = Anchor || (() => { const isChild = id in s.anchorInfoByChildId; const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { - (s.anchorChildrenByAnchorId[id] || []).forEach(cid => addTrackedComponents(cid, comps)); + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); } else if (isChild) { addTrackedComponents(id, comps); } else { @@ -1659,7 +1659,7 @@ var Anchor = Anchor || (() => { const isChild = id in s.anchorInfoByChildId; const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { - (s.anchorChildrenByAnchorId[id] || []).forEach(cid => removeTrackedComponents(cid, comps)); + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); } else if (isChild) { removeTrackedComponents(id, comps); } diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 4241ae3b4e..2728d14301 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -1589,15 +1589,15 @@ var Anchor = Anchor || (() => { if (flags.has('--up')) { setAnchor(id, undefined); } else if (flags.has('--down')) { - const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } else { setAnchor(id, undefined); - const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } } else if (isParent) { - const children = [...(s.anchorChildrenByAnchorId[id] || [])]; + const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } else { setAnchor(id, undefined); @@ -1643,7 +1643,7 @@ var Anchor = Anchor || (() => { const isChild = id in s.anchorInfoByChildId; const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { - (s.anchorChildrenByAnchorId[id] || []).forEach(cid => addTrackedComponents(cid, comps)); + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); } else if (isChild) { addTrackedComponents(id, comps); } else { @@ -1659,7 +1659,7 @@ var Anchor = Anchor || (() => { const isChild = id in s.anchorInfoByChildId; const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { - (s.anchorChildrenByAnchorId[id] || []).forEach(cid => removeTrackedComponents(cid, comps)); + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); } else if (isChild) { removeTrackedComponents(id, comps); } From a7221990dd77b0783d244064731bebc7884c74a7 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:44:06 -0400 Subject: [PATCH 81/96] Anchor: force --up/--down disambiguation for all commands when token is both parent and child --- Anchor/2.2.0/anchor.js | 21 +++++++++++++++++---- Anchor/anchor.js | 21 +++++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 2728d14301..d249b7c349 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -1585,16 +1585,15 @@ var Anchor = Anchor || (() => { const isChild = id in s.anchorInfoByChildId; const isParent = id in s.anchorChildrenByAnchorId; if (isChild && isParent) { - // Both — remove both directions unless up/down specified + // Both — require disambiguation if (flags.has('--up')) { setAnchor(id, undefined); } else if (flags.has('--down')) { const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } else { - setAnchor(id, undefined); - const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); - children.forEach(cid => setAnchor(cid, undefined)); + reply(msg, 'Error', 'Token is both parent and child. Use --up (remove from parent) or --down (unanchor children).'); + return; } } else if (isParent) { const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); @@ -1644,8 +1643,15 @@ var Anchor = Anchor || (() => { const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + addTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); + return; } else if (isChild) { addTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); } else { reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); } @@ -1660,8 +1666,15 @@ var Anchor = Anchor || (() => { const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + removeTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); + return; } else if (isChild) { removeTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); } }); } diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 2728d14301..d249b7c349 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -1585,16 +1585,15 @@ var Anchor = Anchor || (() => { const isChild = id in s.anchorInfoByChildId; const isParent = id in s.anchorChildrenByAnchorId; if (isChild && isParent) { - // Both — remove both directions unless up/down specified + // Both — require disambiguation if (flags.has('--up')) { setAnchor(id, undefined); } else if (flags.has('--down')) { const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); children.forEach(cid => setAnchor(cid, undefined)); } else { - setAnchor(id, undefined); - const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); - children.forEach(cid => setAnchor(cid, undefined)); + reply(msg, 'Error', 'Token is both parent and child. Use --up (remove from parent) or --down (unanchor children).'); + return; } } else if (isParent) { const children = Object.keys(s.anchorChildrenByAnchorId[id] || {}); @@ -1644,8 +1643,15 @@ var Anchor = Anchor || (() => { const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + addTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); + return; } else if (isChild) { addTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => addTrackedComponents(cid, comps)); } else { reply(msg, 'Error', `${id} is not anchored. Use !anchor to establish a relationship first.`); } @@ -1660,8 +1666,15 @@ var Anchor = Anchor || (() => { const isParent = id in s.anchorChildrenByAnchorId; if (flags.has('--down') && isParent) { Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); + } else if (flags.has('--up') && isChild) { + removeTrackedComponents(id, comps); + } else if (isChild && isParent) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify own parent link) or --down (modify children).'); + return; } else if (isChild) { removeTrackedComponents(id, comps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => removeTrackedComponents(cid, comps)); } }); } From bcb0c1cfa3434fd74445ee37ac6f78b20ac364e5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:45:28 -0400 Subject: [PATCH 82/96] Anchor: add --up/--down disambiguation to center command --- Anchor/2.2.0/anchor.js | 37 +++++++++++++++++++++++++++++-------- Anchor/anchor.js | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index d249b7c349..f2ac8311ed 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -1607,14 +1607,35 @@ var Anchor = Anchor || (() => { // Center if (flags.has('center')) { childIds.forEach(id => { - const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; - if (!info) return; - if ('left' in info) info.left = 0; - if ('top' in info) info.top = 0; - if ('rotation' in info) info.rotation = 0; - if ('widthRatio' in info) info.widthRatio = 1; - if ('heightRatio' in info) info.heightRatio = 1; - applyAnchorToChild(id); + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (center on parent) or --down (center children on self).'); + return; + } + + const centerChild = (cid) => { + const info = s.anchorInfoByChildId[cid]; + if (!info) return; + if ('left' in info) info.left = 0; + if ('top' in info) info.top = 0; + if ('rotation' in info) info.rotation = 0; + if ('widthRatio' in info) info.widthRatio = 1; + if ('heightRatio' in info) info.heightRatio = 1; + applyAnchorToChild(cid); + }; + + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } else if (flags.has('--up') && isChild) { + centerChild(id); + } else if (isChild) { + centerChild(id); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } }); } diff --git a/Anchor/anchor.js b/Anchor/anchor.js index d249b7c349..f2ac8311ed 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -1607,14 +1607,35 @@ var Anchor = Anchor || (() => { // Center if (flags.has('center')) { childIds.forEach(id => { - const info = state[SCRIPT_NAME].anchorInfoByChildId[id]; - if (!info) return; - if ('left' in info) info.left = 0; - if ('top' in info) info.top = 0; - if ('rotation' in info) info.rotation = 0; - if ('widthRatio' in info) info.widthRatio = 1; - if ('heightRatio' in info) info.heightRatio = 1; - applyAnchorToChild(id); + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (center on parent) or --down (center children on self).'); + return; + } + + const centerChild = (cid) => { + const info = s.anchorInfoByChildId[cid]; + if (!info) return; + if ('left' in info) info.left = 0; + if ('top' in info) info.top = 0; + if ('rotation' in info) info.rotation = 0; + if ('widthRatio' in info) info.widthRatio = 1; + if ('heightRatio' in info) info.heightRatio = 1; + applyAnchorToChild(cid); + }; + + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } else if (flags.has('--up') && isChild) { + centerChild(id); + } else if (isChild) { + centerChild(id); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => centerChild(cid)); + } }); } From c8942b012e6924a60e00f1a58ca50b2d61fb0c7f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 10:51:59 -0400 Subject: [PATCH 83/96] =?UTF-8?q?Anchor:=20flesh=20out=20Help=20handout=20?= =?UTF-8?q?=E2=80=94=20quick=20start,=20modifiers,=20disambiguation,=20def?= =?UTF-8?q?aults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Anchor/2.2.0/anchor.js | 34 +++++++++++++++++++++++----------- Anchor/anchor.js | 34 +++++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index f2ac8311ed..43c2baddb1 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -2227,26 +2227,38 @@ var Anchor = Anchor || (() => { } let html = `

      ${SCRIPT_NAME} v${SCRIPT_VERSION}

      `; html += `

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

      `; + html += `

      Quick Start

      `; + html += `

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

      `; + html += `

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

      `; html += `

      Commands

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

      Modifiers

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

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

      `; html += `

      Component Flags

      `; - html += `

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

      `; + html += `

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

      `; + html += `

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

      `; hh.set('notes', html); })(); diff --git a/Anchor/anchor.js b/Anchor/anchor.js index f2ac8311ed..43c2baddb1 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -2227,26 +2227,38 @@ var Anchor = Anchor || (() => { } let html = `

      ${SCRIPT_NAME} v${SCRIPT_VERSION}

      `; html += `

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

      `; + html += `

      Quick Start

      `; + html += `

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

      `; + html += `

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

      `; html += `

      Commands

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

      Modifiers

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

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

      `; html += `

      Component Flags

      `; - html += `

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

      `; + html += `

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

      `; + html += `

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

      `; hh.set('notes', html); })(); From 8657912d2f8813f2682beb790e1c351b3a27ff44 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:24:42 -0400 Subject: [PATCH 84/96] =?UTF-8?q?Anchor=20v2.2.1:=20fix=20child=20offset?= =?UTF-8?q?=20scaling=20=E2=80=94=20include=20parent=20scale=20in=20transf?= =?UTF-8?q?orm=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Anchor/2.2.0/anchor.js | 19 ++++++++++++------- Anchor/anchor.js | 19 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 43c2baddb1..8588e93fed 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -1,5 +1,5 @@ // ============================================================================= -// Anchor v2.2.0 +// Anchor v2.2.1 // Last Updated: 2026-06-26 // Author: Kenan Millet // @@ -102,7 +102,7 @@ var Anchor = Anchor || (() => { // ------------------------------------------------------------------------- const SCRIPT_NAME = 'Anchor'; - const SCRIPT_VERSION = '2.2.0'; + const SCRIPT_VERSION = '2.2.1'; const CMD_TOKEN = '!anchor'; const DEFAULTS = { @@ -225,10 +225,11 @@ var Anchor = Anchor || (() => { /** * Build a 3×3 transform matrix for a graphic's current position + rotation. */ - const buildTransform = (left, top, rotationDeg) => { + const buildTransform = (left, top, rotationDeg, scaleX, scaleY) => { let m = MatrixMath.identity(3); m = MatrixMath.multiply(m, MatrixMath.translate([left, top])); m = MatrixMath.multiply(m, MatrixMath.rotate(toRad(rotationDeg))); + m = MatrixMath.multiply(m, MatrixMath.scale([scaleX, scaleY])); return m; }; @@ -323,10 +324,13 @@ var Anchor = Anchor || (() => { const info = { id: childId, anchor_id: anchorId }; if (components.left || components.top) { - // Express child position in anchor-local frame (undo anchor rotation) + // Express child position in anchor-local frame (undo rotation + scale) const relTransform = MatrixMath.multiply( - MatrixMath.rotate(toRad(-aRot)), - MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + MatrixMath.scale([aW > 0 ? 1/aW : 1, aH > 0 ? 1/aH : 1]), + MatrixMath.multiply( + MatrixMath.rotate(toRad(-aRot)), + MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + ) ); if (components.left) info.left = relTransform[2][0]; if (components.top) info.top = relTransform[2][1]; @@ -746,7 +750,8 @@ var Anchor = Anchor || (() => { if (('left' in info || 'top' in info) && (shouldApply('left') || shouldApply('top'))) { const anchorTransform = buildTransform( - anchor.get('left'), anchor.get('top'), anchor.get('rotation') + anchor.get('left'), anchor.get('top'), anchor.get('rotation'), + anchor.get('width'), anchor.get('height') ); // Mirror offsets when flip components are tracked and anchor is flipped. diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 43c2baddb1..8588e93fed 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -1,5 +1,5 @@ // ============================================================================= -// Anchor v2.2.0 +// Anchor v2.2.1 // Last Updated: 2026-06-26 // Author: Kenan Millet // @@ -102,7 +102,7 @@ var Anchor = Anchor || (() => { // ------------------------------------------------------------------------- const SCRIPT_NAME = 'Anchor'; - const SCRIPT_VERSION = '2.2.0'; + const SCRIPT_VERSION = '2.2.1'; const CMD_TOKEN = '!anchor'; const DEFAULTS = { @@ -225,10 +225,11 @@ var Anchor = Anchor || (() => { /** * Build a 3×3 transform matrix for a graphic's current position + rotation. */ - const buildTransform = (left, top, rotationDeg) => { + const buildTransform = (left, top, rotationDeg, scaleX, scaleY) => { let m = MatrixMath.identity(3); m = MatrixMath.multiply(m, MatrixMath.translate([left, top])); m = MatrixMath.multiply(m, MatrixMath.rotate(toRad(rotationDeg))); + m = MatrixMath.multiply(m, MatrixMath.scale([scaleX, scaleY])); return m; }; @@ -323,10 +324,13 @@ var Anchor = Anchor || (() => { const info = { id: childId, anchor_id: anchorId }; if (components.left || components.top) { - // Express child position in anchor-local frame (undo anchor rotation) + // Express child position in anchor-local frame (undo rotation + scale) const relTransform = MatrixMath.multiply( - MatrixMath.rotate(toRad(-aRot)), - MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + MatrixMath.scale([aW > 0 ? 1/aW : 1, aH > 0 ? 1/aH : 1]), + MatrixMath.multiply( + MatrixMath.rotate(toRad(-aRot)), + MatrixMath.translate([cLeft - aLeft, cTop - aTop]) + ) ); if (components.left) info.left = relTransform[2][0]; if (components.top) info.top = relTransform[2][1]; @@ -746,7 +750,8 @@ var Anchor = Anchor || (() => { if (('left' in info || 'top' in info) && (shouldApply('left') || shouldApply('top'))) { const anchorTransform = buildTransform( - anchor.get('left'), anchor.get('top'), anchor.get('rotation') + anchor.get('left'), anchor.get('top'), anchor.get('rotation'), + anchor.get('width'), anchor.get('height') ); // Mirror offsets when flip components are tracked and anchor is flipped. From 750d48fcee755d631f9d58404394b489aae21d0f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:29:33 -0400 Subject: [PATCH 85/96] =?UTF-8?q?Anchor=20v2.2.1:=20unify=20flip=20into=20?= =?UTF-8?q?transform=20matrix=20=E2=80=94=20no=20more=20manual=20offset=20?= =?UTF-8?q?negation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Anchor/2.2.0/anchor.js | 30 +++++++++++++++--------------- Anchor/anchor.js | 30 +++++++++++++++--------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 8588e93fed..fe74b683a7 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -324,9 +324,13 @@ var Anchor = Anchor || (() => { const info = { id: childId, anchor_id: anchorId }; if (components.left || components.top) { - // Express child position in anchor-local frame (undo rotation + scale) + // Express child position in anchor-local frame (undo rotation + scale + flip) + const aFlipH = components.fliph && anchor.get('fliph'); + const aFlipV = components.flipv && anchor.get('flipv'); + const sx = aW > 0 ? 1 / (aW * (aFlipH ? -1 : 1)) : 1; + const sy = aH > 0 ? 1 / (aH * (aFlipV ? -1 : 1)) : 1; const relTransform = MatrixMath.multiply( - MatrixMath.scale([aW > 0 ? 1/aW : 1, aH > 0 ? 1/aH : 1]), + MatrixMath.scale([sx, sy]), MatrixMath.multiply( MatrixMath.rotate(toRad(-aRot)), MatrixMath.translate([cLeft - aLeft, cTop - aTop]) @@ -749,22 +753,18 @@ var Anchor = Anchor || (() => { const updates = {}; if (('left' in info || 'top' in info) && (shouldApply('left') || shouldApply('top'))) { + // Include flip as negative scale in the transform (for offset mirroring only) + const aFlipH = ('fliph' in info) && anchor.get('fliph'); + const aFlipV = ('flipv' in info) && anchor.get('flipv'); + const sx = anchor.get('width') * (aFlipH ? -1 : 1); + const sy = anchor.get('height') * (aFlipV ? -1 : 1); + const anchorTransform = buildTransform( - anchor.get('left'), anchor.get('top'), anchor.get('rotation'), - anchor.get('width'), anchor.get('height') + anchor.get('left'), anchor.get('top'), anchor.get('rotation'), sx, sy ); - // Mirror offsets when flip components are tracked and anchor is flipped. - // fliph flips the anchor horizontally → mirror the x (left) offset. - // flipv flips the anchor vertically → mirror the y (top) offset. - const aFlipH = anchor.get('fliph'); - const aFlipV = anchor.get('flipv'); - const localLeft = ('left' in info) - ? (('fliph' in info) && aFlipH ? -(info.left) : info.left) - : 0; - const localTop = ('top' in info) - ? (('flipv' in info) && aFlipV ? -(info.top) : info.top) - : 0; + const localLeft = ('left' in info) ? info.left : 0; + const localTop = ('top' in info) ? info.top : 0; const childWorld = MatrixMath.multiply( anchorTransform, diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 8588e93fed..fe74b683a7 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -324,9 +324,13 @@ var Anchor = Anchor || (() => { const info = { id: childId, anchor_id: anchorId }; if (components.left || components.top) { - // Express child position in anchor-local frame (undo rotation + scale) + // Express child position in anchor-local frame (undo rotation + scale + flip) + const aFlipH = components.fliph && anchor.get('fliph'); + const aFlipV = components.flipv && anchor.get('flipv'); + const sx = aW > 0 ? 1 / (aW * (aFlipH ? -1 : 1)) : 1; + const sy = aH > 0 ? 1 / (aH * (aFlipV ? -1 : 1)) : 1; const relTransform = MatrixMath.multiply( - MatrixMath.scale([aW > 0 ? 1/aW : 1, aH > 0 ? 1/aH : 1]), + MatrixMath.scale([sx, sy]), MatrixMath.multiply( MatrixMath.rotate(toRad(-aRot)), MatrixMath.translate([cLeft - aLeft, cTop - aTop]) @@ -749,22 +753,18 @@ var Anchor = Anchor || (() => { const updates = {}; if (('left' in info || 'top' in info) && (shouldApply('left') || shouldApply('top'))) { + // Include flip as negative scale in the transform (for offset mirroring only) + const aFlipH = ('fliph' in info) && anchor.get('fliph'); + const aFlipV = ('flipv' in info) && anchor.get('flipv'); + const sx = anchor.get('width') * (aFlipH ? -1 : 1); + const sy = anchor.get('height') * (aFlipV ? -1 : 1); + const anchorTransform = buildTransform( - anchor.get('left'), anchor.get('top'), anchor.get('rotation'), - anchor.get('width'), anchor.get('height') + anchor.get('left'), anchor.get('top'), anchor.get('rotation'), sx, sy ); - // Mirror offsets when flip components are tracked and anchor is flipped. - // fliph flips the anchor horizontally → mirror the x (left) offset. - // flipv flips the anchor vertically → mirror the y (top) offset. - const aFlipH = anchor.get('fliph'); - const aFlipV = anchor.get('flipv'); - const localLeft = ('left' in info) - ? (('fliph' in info) && aFlipH ? -(info.left) : info.left) - : 0; - const localTop = ('top' in info) - ? (('flipv' in info) && aFlipV ? -(info.top) : info.top) - : 0; + const localLeft = ('left' in info) ? info.left : 0; + const localTop = ('top' in info) ? info.top : 0; const childWorld = MatrixMath.multiply( anchorTransform, From c302563d9be63328e0369b7ebd099943d21ea33d Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:36:56 -0400 Subject: [PATCH 86/96] Anchor v2.2.1: auto-set lockMovement when left+top are locked, track ownership --- Anchor/2.2.0/anchor.js | 23 +++++++++++++++++++++++ Anchor/anchor.js | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index fe74b683a7..0afd99786b 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -442,6 +442,16 @@ var Anchor = Anchor || (() => { ? Object.keys(components) : ALL_COMPONENTS; toAdd.forEach(c => locked.add(c)); + + // If both left and top are locked, set lockMovement on the token + if (locked.has('left') && locked.has('top')) { + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; + } + } }; /** @@ -452,11 +462,24 @@ var Anchor = Anchor || (() => { const s = state[SCRIPT_NAME]; if (!components || Object.keys(components).length === 0) { delete s.lockedObjects[childId]; + // Clear lockMovement if we set it + if (s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } return; } const locked = getLockedComponents(childId); Object.keys(components).forEach(c => locked.delete(c)); if (locked.size === 0) delete s.lockedObjects[childId]; + + // Clear lockMovement if left or top is no longer locked + if ((!locked.has('left') || !locked.has('top')) && s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } }; /** diff --git a/Anchor/anchor.js b/Anchor/anchor.js index fe74b683a7..0afd99786b 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -442,6 +442,16 @@ var Anchor = Anchor || (() => { ? Object.keys(components) : ALL_COMPONENTS; toAdd.forEach(c => locked.add(c)); + + // If both left and top are locked, set lockMovement on the token + if (locked.has('left') && locked.has('top')) { + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; + } + } }; /** @@ -452,11 +462,24 @@ var Anchor = Anchor || (() => { const s = state[SCRIPT_NAME]; if (!components || Object.keys(components).length === 0) { delete s.lockedObjects[childId]; + // Clear lockMovement if we set it + if (s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } return; } const locked = getLockedComponents(childId); Object.keys(components).forEach(c => locked.delete(c)); if (locked.size === 0) delete s.lockedObjects[childId]; + + // Clear lockMovement if left or top is no longer locked + if ((!locked.has('left') || !locked.has('top')) && s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } }; /** From ebd057aedbd9b8c2a1db435ceb450ceffaf17eb5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:42:30 -0400 Subject: [PATCH 87/96] Anchor v2.2.1: lockMovement requires tracked+locked, add --up/--down to lock/unlock --- Anchor/2.2.0/anchor.js | 53 +++++++++++++++++++++++++++++++++++------- Anchor/anchor.js | 53 +++++++++++++++++++++++++++++++++++------- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 0afd99786b..f977e8c1df 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -443,13 +443,16 @@ var Anchor = Anchor || (() => { : ALL_COMPONENTS; toAdd.forEach(c => locked.add(c)); - // If both left and top are locked, set lockMovement on the token + // If both left and top are locked AND tracked, set lockMovement on the token if (locked.has('left') && locked.has('top')) { - const obj = getObj('graphic', childId); - if (obj && !obj.get('lockMovement')) { - obj.set('lockMovement', true); - if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; - s.placementLockedByAnchor[childId] = true; + const info = s.anchorInfoByChildId[childId]; + if (info && 'left' in info && 'top' in info) { + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; + } } } }; @@ -1676,10 +1679,44 @@ var Anchor = Anchor || (() => { // With no component flags: lock/unlock ALL components (tracked + pre-lock). if (flags.has('unlock')) { const unlockComps = resolveComponentsOrNone(flags); - childIds.forEach(id => unlockComponents(id, unlockComps)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (unlock own parent link) or --down (unlock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } else if (flags.has('--up') && isChild) { + unlockComponents(id, unlockComps); + } else if (isChild) { + unlockComponents(id, unlockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } + }); } else if (flags.has('lock')) { const lockComps = resolveComponentsOrNone(flags); - childIds.forEach(id => lockComponents(id, lockComps)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (lock own parent link) or --down (lock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } else if (flags.has('--up') && isChild) { + lockComponents(id, lockComps); + } else if (isChild) { + lockComponents(id, lockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } + }); } // Track / untrack / retrack — modify which components are tracked diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 0afd99786b..f977e8c1df 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -443,13 +443,16 @@ var Anchor = Anchor || (() => { : ALL_COMPONENTS; toAdd.forEach(c => locked.add(c)); - // If both left and top are locked, set lockMovement on the token + // If both left and top are locked AND tracked, set lockMovement on the token if (locked.has('left') && locked.has('top')) { - const obj = getObj('graphic', childId); - if (obj && !obj.get('lockMovement')) { - obj.set('lockMovement', true); - if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; - s.placementLockedByAnchor[childId] = true; + const info = s.anchorInfoByChildId[childId]; + if (info && 'left' in info && 'top' in info) { + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; + } } } }; @@ -1676,10 +1679,44 @@ var Anchor = Anchor || (() => { // With no component flags: lock/unlock ALL components (tracked + pre-lock). if (flags.has('unlock')) { const unlockComps = resolveComponentsOrNone(flags); - childIds.forEach(id => unlockComponents(id, unlockComps)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (unlock own parent link) or --down (unlock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } else if (flags.has('--up') && isChild) { + unlockComponents(id, unlockComps); + } else if (isChild) { + unlockComponents(id, unlockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => unlockComponents(cid, unlockComps)); + } + }); } else if (flags.has('lock')) { const lockComps = resolveComponentsOrNone(flags); - childIds.forEach(id => lockComponents(id, lockComps)); + childIds.forEach(id => { + const s = state[SCRIPT_NAME]; + const isChild = id in s.anchorInfoByChildId; + const isParent = id in s.anchorChildrenByAnchorId; + if (isChild && isParent && !flags.has('--up') && !flags.has('--down')) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (lock own parent link) or --down (lock children).'); + return; + } + if (flags.has('--down') && isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } else if (flags.has('--up') && isChild) { + lockComponents(id, lockComps); + } else if (isChild) { + lockComponents(id, lockComps); + } else if (isParent) { + Object.keys(s.anchorChildrenByAnchorId[id] || {}).forEach(cid => lockComponents(cid, lockComps)); + } + }); } // Track / untrack / retrack — modify which components are tracked From 3d119f6f5ceaa2eaee91e8eccf0dabfdbd8178cc Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:46:30 -0400 Subject: [PATCH 88/96] Anchor v2.2.1: use clearPlacementLockIfNeeded consistently everywhere --- Anchor/2.2.0/anchor.js | 41 ++++++++++++++++++++++++----------------- Anchor/anchor.js | 41 ++++++++++++++++++++++++----------------- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index f977e8c1df..c88b22003a 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -465,24 +465,12 @@ var Anchor = Anchor || (() => { const s = state[SCRIPT_NAME]; if (!components || Object.keys(components).length === 0) { delete s.lockedObjects[childId]; - // Clear lockMovement if we set it - if (s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { - const obj = getObj('graphic', childId); - if (obj) obj.set('lockMovement', false); - delete s.placementLockedByAnchor[childId]; - } - return; - } - const locked = getLockedComponents(childId); - Object.keys(components).forEach(c => locked.delete(c)); - if (locked.size === 0) delete s.lockedObjects[childId]; - - // Clear lockMovement if left or top is no longer locked - if ((!locked.has('left') || !locked.has('top')) && s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { - const obj = getObj('graphic', childId); - if (obj) obj.set('lockMovement', false); - delete s.placementLockedByAnchor[childId]; + } else { + const locked = getLockedComponents(childId); + Object.keys(components).forEach(c => locked.delete(c)); + if (locked.size === 0) delete s.lockedObjects[childId]; } + clearPlacementLockIfNeeded(childId); }; /** @@ -565,6 +553,24 @@ var Anchor = Anchor || (() => { break; } }); + clearPlacementLockIfNeeded(childId); + }; + + /** + * Clear lockMovement if we set it and conditions no longer hold + * (left+top must both be tracked AND locked). + */ + const clearPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + if (!s.placementLockedByAnchor || !s.placementLockedByAnchor[childId]) return; + const info = s.anchorInfoByChildId[childId]; + const locked = getLockedComponents(childId); + const bothTrackedAndLocked = info && 'left' in info && 'top' in info && locked.has('left') && locked.has('top'); + if (!bothTrackedAndLocked) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } }; /** @@ -589,6 +595,7 @@ var Anchor = Anchor || (() => { delete s.objectStates[childId]; } delete s.lockedObjects[childId]; + clearPlacementLockIfNeeded(childId); return; } diff --git a/Anchor/anchor.js b/Anchor/anchor.js index f977e8c1df..c88b22003a 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -465,24 +465,12 @@ var Anchor = Anchor || (() => { const s = state[SCRIPT_NAME]; if (!components || Object.keys(components).length === 0) { delete s.lockedObjects[childId]; - // Clear lockMovement if we set it - if (s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { - const obj = getObj('graphic', childId); - if (obj) obj.set('lockMovement', false); - delete s.placementLockedByAnchor[childId]; - } - return; - } - const locked = getLockedComponents(childId); - Object.keys(components).forEach(c => locked.delete(c)); - if (locked.size === 0) delete s.lockedObjects[childId]; - - // Clear lockMovement if left or top is no longer locked - if ((!locked.has('left') || !locked.has('top')) && s.placementLockedByAnchor && s.placementLockedByAnchor[childId]) { - const obj = getObj('graphic', childId); - if (obj) obj.set('lockMovement', false); - delete s.placementLockedByAnchor[childId]; + } else { + const locked = getLockedComponents(childId); + Object.keys(components).forEach(c => locked.delete(c)); + if (locked.size === 0) delete s.lockedObjects[childId]; } + clearPlacementLockIfNeeded(childId); }; /** @@ -565,6 +553,24 @@ var Anchor = Anchor || (() => { break; } }); + clearPlacementLockIfNeeded(childId); + }; + + /** + * Clear lockMovement if we set it and conditions no longer hold + * (left+top must both be tracked AND locked). + */ + const clearPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + if (!s.placementLockedByAnchor || !s.placementLockedByAnchor[childId]) return; + const info = s.anchorInfoByChildId[childId]; + const locked = getLockedComponents(childId); + const bothTrackedAndLocked = info && 'left' in info && 'top' in info && locked.has('left') && locked.has('top'); + if (!bothTrackedAndLocked) { + const obj = getObj('graphic', childId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByAnchor[childId]; + } }; /** @@ -589,6 +595,7 @@ var Anchor = Anchor || (() => { delete s.objectStates[childId]; } delete s.lockedObjects[childId]; + clearPlacementLockIfNeeded(childId); return; } From 10c84d1e6c79073964e6d3d516249a6445ffe63a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:48:23 -0400 Subject: [PATCH 89/96] Anchor v2.2.1: add setPlacementLockIfNeeded helper, call from lockComponents and addTrackedComponents --- Anchor/2.2.0/anchor.js | 28 +++++++++++++++++----------- Anchor/anchor.js | 28 +++++++++++++++++----------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index c88b22003a..311788c3c2 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -442,18 +442,23 @@ var Anchor = Anchor || (() => { ? Object.keys(components) : ALL_COMPONENTS; toAdd.forEach(c => locked.add(c)); + setPlacementLockIfNeeded(childId); + }; - // If both left and top are locked AND tracked, set lockMovement on the token - if (locked.has('left') && locked.has('top')) { - const info = s.anchorInfoByChildId[childId]; - if (info && 'left' in info && 'top' in info) { - const obj = getObj('graphic', childId); - if (obj && !obj.get('lockMovement')) { - obj.set('lockMovement', true); - if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; - s.placementLockedByAnchor[childId] = true; - } - } + /** + * Set lockMovement if both left+top are tracked AND locked and we haven't already. + */ + const setPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + const info = s.anchorInfoByChildId[childId]; + if (!info || !('left' in info) || !('top' in info)) return; + const locked = getLockedComponents(childId); + if (!locked.has('left') || !locked.has('top')) return; + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; } }; @@ -521,6 +526,7 @@ var Anchor = Anchor || (() => { break; } }); + setPlacementLockIfNeeded(childId); }; /** diff --git a/Anchor/anchor.js b/Anchor/anchor.js index c88b22003a..311788c3c2 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -442,18 +442,23 @@ var Anchor = Anchor || (() => { ? Object.keys(components) : ALL_COMPONENTS; toAdd.forEach(c => locked.add(c)); + setPlacementLockIfNeeded(childId); + }; - // If both left and top are locked AND tracked, set lockMovement on the token - if (locked.has('left') && locked.has('top')) { - const info = s.anchorInfoByChildId[childId]; - if (info && 'left' in info && 'top' in info) { - const obj = getObj('graphic', childId); - if (obj && !obj.get('lockMovement')) { - obj.set('lockMovement', true); - if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; - s.placementLockedByAnchor[childId] = true; - } - } + /** + * Set lockMovement if both left+top are tracked AND locked and we haven't already. + */ + const setPlacementLockIfNeeded = (childId) => { + const s = state[SCRIPT_NAME]; + const info = s.anchorInfoByChildId[childId]; + if (!info || !('left' in info) || !('top' in info)) return; + const locked = getLockedComponents(childId); + if (!locked.has('left') || !locked.has('top')) return; + const obj = getObj('graphic', childId); + if (obj && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + if (!s.placementLockedByAnchor) s.placementLockedByAnchor = {}; + s.placementLockedByAnchor[childId] = true; } }; @@ -521,6 +526,7 @@ var Anchor = Anchor || (() => { break; } }); + setPlacementLockIfNeeded(childId); }; /** From e4d0e720fb1a781ac459cdef69575d52dd377e95 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:53:37 -0400 Subject: [PATCH 90/96] Anchor v2.2.1: state migration for scale-normalized offsets, lockMovement helpers --- Anchor/2.2.0/anchor.js | 15 +++++++++++++++ Anchor/anchor.js | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.0/anchor.js index 311788c3c2..e5d4754c22 100644 --- a/Anchor/2.2.0/anchor.js +++ b/Anchor/2.2.0/anchor.js @@ -2340,6 +2340,21 @@ var Anchor = Anchor || (() => { hh.set('notes', html); })(); + // State migration: normalize pixel offsets to anchor-size-relative (v2.2.1) + (() => { + const s = state[SCRIPT_NAME]; + if (s.schemaVersion >= 2.21) return; + Object.entries(s.anchorInfoByChildId || {}).forEach(([childId, info]) => { + const anchor = getObj('graphic', info.anchor_id); + if (!anchor) return; + const aW = anchor.get('width'); + const aH = anchor.get('height'); + if ('left' in info && aW > 0) info.left = info.left / aW; + if ('top' in info && aH > 0) info.top = info.top / aH; + }); + s.schemaVersion = 2.21; + })(); + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); }; diff --git a/Anchor/anchor.js b/Anchor/anchor.js index 311788c3c2..e5d4754c22 100644 --- a/Anchor/anchor.js +++ b/Anchor/anchor.js @@ -2340,6 +2340,21 @@ var Anchor = Anchor || (() => { hh.set('notes', html); })(); + // State migration: normalize pixel offsets to anchor-size-relative (v2.2.1) + (() => { + const s = state[SCRIPT_NAME]; + if (s.schemaVersion >= 2.21) return; + Object.entries(s.anchorInfoByChildId || {}).forEach(([childId, info]) => { + const anchor = getObj('graphic', info.anchor_id); + if (!anchor) return; + const aW = anchor.get('width'); + const aH = anchor.get('height'); + if ('left' in info && aW > 0) info.left = info.left / aW; + if ('top' in info && aH > 0) info.top = info.top / aH; + }); + s.schemaVersion = 2.21; + })(); + log(`-=> ${SCRIPT_NAME} v${SCRIPT_VERSION} Initialized <=-`); }; From b4bb512e2fe25a45b3463652925258d26ec8927b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:56:24 -0400 Subject: [PATCH 91/96] =?UTF-8?q?Anchor:=20rename=20versioned=20folder=202?= =?UTF-8?q?.2.0=20=E2=86=92=202.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Anchor/{2.2.0 => 2.2.1}/anchor.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Anchor/{2.2.0 => 2.2.1}/anchor.js (100%) diff --git a/Anchor/2.2.0/anchor.js b/Anchor/2.2.1/anchor.js similarity index 100% rename from Anchor/2.2.0/anchor.js rename to Anchor/2.2.1/anchor.js From 71d2c652d7fe9a348f11e9f5d90b817c39e6578d Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 11:58:19 -0400 Subject: [PATCH 92/96] Anchor v2.2.1: update README changelog, script.json version --- Anchor/README.md | 6 ++++++ Anchor/script.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Anchor/README.md b/Anchor/README.md index c283e001f7..cd2b2b540a 100644 --- a/Anchor/README.md +++ b/Anchor/README.md @@ -307,6 +307,12 @@ The public API changed: ## Changelog +### v2.2.1 +- Fix: child position offset now scales with parent size (proper matrix transform with scale) +- Fix: flip mirroring handled via scale matrix instead of manual negation +- Auto `lockMovement` when both left+top are tracked AND locked (clears when conditions change) +- State migration: old pixel offsets auto-normalized to anchor-size-relative on first load + ### v2.2.0 - Selection-based parenting: first selected = parent when multiple tokens selected - `--new` flag: force auto-create invisible anchor (all selected become children) diff --git a/Anchor/script.json b/Anchor/script.json index 28bb8a8544..656c3ae038 100644 --- a/Anchor/script.json +++ b/Anchor/script.json @@ -1,7 +1,7 @@ { "name": "Anchor", "script": "anchor.js", - "version": "2.2.0", + "version": "2.2.1", "previousversions": ["2.1.0", "1.0.0" ], From 12ac86010cb4d034bf14710b5391022eb5102397 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 12:00:39 -0400 Subject: [PATCH 93/96] Anchor: add --up/--down to lock/unlock in README, note lockMovement behavior --- Anchor/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Anchor/README.md b/Anchor/README.md index cd2b2b540a..0a3e443d84 100644 --- a/Anchor/README.md +++ b/Anchor/README.md @@ -112,14 +112,16 @@ Remove anchor relationships from tokens. ### Locking and Unlocking ``` -!anchor lock [component flags] [--ignore-selected] [child_id...] -!anchor unlock [component flags] [--ignore-selected] [child_id...] +!anchor lock [component flags] [--up] [--down] [--ignore-selected] [child_id...] +!anchor unlock [component flags] [--up] [--down] [--ignore-selected] [child_id...] ``` -**Lock** freezes components — manual moves are undone every poll tick, and anchor changes to those components are ignored. With no component flags, locks all components. +**Lock** freezes components — manual moves are undone every poll tick, and anchor changes to those components are ignored. With no component flags, locks all components. When both left+top are tracked and locked, `lockMovement` is set on the token to prevent dragging entirely. **Unlock** releases components. With no component flags, unlocks everything. +**`--down`** — when a parent is selected, apply to all children. **`--up`** — apply to own parent link. Required when token is both parent and child. + Components can be locked before they are tracked ("pre-locked") — they will activate automatically when tracking is added via `track`. --- From 41c19b61f6e9f322bc9d0877b83fe7f333ea4ce9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 12:13:33 -0400 Subject: [PATCH 94/96] Mirror v1.1.0: --ignore-selected, auto lockMovement for hard-linked left+top children --- Mirror/1.1.0/Mirror.js | 1226 ++++++++++++++++++++++++++++++++++++++++ Mirror/Mirror.js | 84 ++- 2 files changed, 1306 insertions(+), 4 deletions(-) create mode 100644 Mirror/1.1.0/Mirror.js diff --git a/Mirror/1.1.0/Mirror.js b/Mirror/1.1.0/Mirror.js new file mode 100644 index 0000000000..933704bc15 --- /dev/null +++ b/Mirror/1.1.0/Mirror.js @@ -0,0 +1,1226 @@ +// ============================================================================= +// 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.1.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; + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--soft' && a !== '--align' && a !== '--ignore-selected'; }); + + // Parse --exclude + var excludes = []; + var exIdx = args.indexOf('--exclude'); + if (exIdx !== -1) { + var afterExclude = args.slice(exIdx + 1); + args = args.slice(0, exIdx); + var exResolved = resolveProps(afterExclude); + excludes = exResolved.props; + // Any remaining non-prop args after --exclude are IDs + args = args.concat(exResolved.remaining); + } + + var resolved = resolveProps(args); + var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); }); + if (!ignoreSelected) ids = ids.concat(getSelectedIds(msg)); + ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe + + // Determine if using 'all' or specific props + // null means "no props specified" (let the caller decide context-dependent behavior) + var props; + if (resolved.props.length === 0) { + props = null; // no props specified + } else if (resolved.props.length === getKnownProps().length) { + props = 'all'; // explicit 'all' group + } else { + // Explicit prop list: apply --exclude as immediate filter + props = excludes.length > 0 + ? resolved.props.filter(function(p) { return excludes.indexOf(p) === -1; }) + : resolved.props; + excludes = []; // already applied, don't store + } + + return { props: props, ids: ids, soft: soft, align: align, excludes: excludes }; + }; + + /** + * Align targets to source: copy specified props from first token to all others. + */ + const alignTokens = (ids, props) => { + if (ids.length < 2) return; + var source = getObj('graphic', ids[0]); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + for (var i = 1; i < ids.length; i++) { + var target = getObj('graphic', ids[i]); + if (target) target.set(updates); + } + }; + + const createLink = (mode, props, ids, soft, excludes) => { + var s = state[SCRIPT_NAME]; + + // Guard: children in a hard link can't have multiple hard parents or join chains + if (mode === 'link' && !soft) { + // Check that child IDs (ids[1:]) don't already have a hard parent + for (var i = 1; i < ids.length; i++) { + var existing = findLinksForToken(ids[i]); + var hasHardParent = existing.some(function(e) { + return e.link.mode === 'link' && !e.link.soft && e.link.ids[0] !== ids[i]; + }); + if (hasHardParent) { + log(SCRIPT_NAME + ': Cannot hard-link ' + ids[i] + ' — already has a hard parent.'); + return null; + } + if (s.chainedIds[ids[i]]) { + log(SCRIPT_NAME + ': Cannot hard-link ' + ids[i] + ' — token is in a chain.'); + return null; + } + } + } + + if (mode === 'chain') { + // Check that none of the IDs have a hard parent link as a child + for (var i = 0; i < ids.length; i++) { + var existing = findLinksForToken(ids[i]); + var hasHardParent = existing.some(function(e) { + return e.link.mode === 'link' && !e.link.soft && e.link.ids[0] !== ids[i]; + }); + if (hasHardParent) { + log(SCRIPT_NAME + ': Cannot chain ' + ids[i] + ' — has a hard parent link.'); + return null; + } + } + ids.forEach(function(id) { s.chainedIds[id] = true; }); + } + + var linkId = genId(); + s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] }; + updatePlacementLocks(linkId); + return linkId; + }; + + /** + * Set/clear lockMovement for children in a link based on whether left+top are hard-linked. + */ + const updatePlacementLocks = (linkId) => { + var s = state[SCRIPT_NAME]; + var link = s.links[linkId]; + if (!link) return; + if (!s.placementLockedByMirror) s.placementLockedByMirror = {}; + + var effectiveProps = getEffectiveProps(link); + var hasLeftTop = effectiveProps.indexOf('left') !== -1 && effectiveProps.indexOf('top') !== -1; + var shouldLock = hasLeftTop && !link.soft; + + // Children are ids[1:] for links, all ids for chains + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { + var obj = getObj('graphic', id); + if (!obj) return; + if (shouldLock && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + s.placementLockedByMirror[id] = true; + } else if (!shouldLock && s.placementLockedByMirror[id]) { + obj.set('lockMovement', false); + delete s.placementLockedByMirror[id]; + } + }); + }; + + /** + * Clear lockMovement for a token if we set it. + */ + const clearPlacementLock = (tokenId) => { + var s = state[SCRIPT_NAME]; + if (!s.placementLockedByMirror || !s.placementLockedByMirror[tokenId]) return; + var obj = getObj('graphic', tokenId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByMirror[tokenId]; + }; + + /** + * Re-evaluate lockMovement for a token across all its links. + */ + const updatePlacementLocksForToken = (tokenId) => { + var links = findLinksForToken(tokenId); + var shouldLock = links.some(function(entry) { + var link = entry.link; + if (link.soft) return false; + var effectiveProps = getEffectiveProps(link); + if (effectiveProps.indexOf('left') === -1 || effectiveProps.indexOf('top') === -1) return false; + // Only lock children, not the parent + if (link.mode === 'link' && link.ids[0] === tokenId) return false; + return true; + }); + var s = state[SCRIPT_NAME]; + if (!s.placementLockedByMirror) s.placementLockedByMirror = {}; + var obj = getObj('graphic', tokenId); + if (!obj) return; + if (shouldLock && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + s.placementLockedByMirror[tokenId] = true; + } else if (!shouldLock && s.placementLockedByMirror[tokenId]) { + obj.set('lockMovement', false); + delete s.placementLockedByMirror[tokenId]; + } + }; + + /** + * Get the effective props for a link, accounting for 'all'/'api-all' and excludes. + */ + const getEffectiveProps = (link) => { + if (link.props === 'all') { + var excludes = (link.excludes || []).concat(getGlobalExcludes()); + return getKnownProps().filter(function(p) { return excludes.indexOf(p) === -1; }); + } + if (link.props === 'api-all') { + var excludes = link.excludes || []; + return getKnownProps().filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return link.props; + }; + + const getGlobalExcludes = () => { + return state[SCRIPT_NAME].globalExcludes || []; + }; + + const findLinksForToken = (tokenId) => { + var s = state[SCRIPT_NAME]; + var results = []; + Object.entries(s.links).forEach(function(entry) { + if (entry[1].ids.indexOf(tokenId) !== -1) results.push({ id: entry[0], link: entry[1] }); + }); + return results; + }; + + const removePropsFromLink = (linkId, propsToRemove) => { + var s = state[SCRIPT_NAME]; + var link = s.links[linkId]; + if (!link) return; + if (!propsToRemove || propsToRemove.length === 0) { + // Remove entire link — clear locks for children + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { clearPlacementLock(id); }); + if (link.mode === 'chain') { + link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); + } + delete s.links[linkId]; + } else { + link.props = link.props.filter(function(p) { return propsToRemove.indexOf(p) === -1; }); + if (link.props.length === 0) { + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { clearPlacementLock(id); }); + if (link.mode === 'chain') { + link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); + } + delete s.links[linkId]; + } else { + updatePlacementLocks(linkId); + } + } + }; + + const rebuildChainedIds = (tokenId, excludeLinkId) => { + var s = state[SCRIPT_NAME]; + // Check if token is still in any other chain + var stillChained = Object.entries(s.links).some(function(entry) { + return entry[0] !== excludeLinkId && entry[1].mode === 'chain' && entry[1].ids.indexOf(tokenId) !== -1; + }); + if (!stillChained) delete s.chainedIds[tokenId]; + }; + + // ========================================================================= + // Sync Engine + // ========================================================================= + + var syncing = false; + + /** + * Recursively propagate updates to targets and their children. + * visited prevents infinite loops in circular link structures. + */ + const propagateUpdates = (tokenId, updates, visited) => { + var s = state[SCRIPT_NAME]; + Object.values(s.links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + + var effectiveProps = getEffectiveProps(link); + var relevantUpdates = {}; + Object.keys(updates).forEach(function(p) { + if (effectiveProps.indexOf(p) !== -1) relevantUpdates[p] = updates[p]; + }); + if (Object.keys(relevantUpdates).length === 0) return; + + if (link.mode === 'chain') { + link.ids.forEach(function(id) { + if (id === tokenId || visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(relevantUpdates); + propagateUpdates(id, relevantUpdates, visited); + } + }); + } else if (idx === 0) { + // Source: propagate down to children + link.ids.slice(1).forEach(function(id) { + if (visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(relevantUpdates); + propagateUpdates(id, relevantUpdates, visited); + } + }); + } + }); + }; + + const onGraphicChanged = (obj, prev) => { + if (syncing) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find changed properties dynamically from prev keys + var changed = Object.keys(prev).filter(function(k) { + return !k.startsWith('_') && prev[k] !== obj.get(k); + }); + if (changed.length === 0) return; + + // Grow known props set with any discovered properties + changed.forEach(function(p) { s.knownProps[p] = true; }); + + syncing = true; + var visited = new Set([tokenId]); + + Object.values(s.links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + + var effectiveProps = getEffectiveProps(link); + var relevantProps = changed.filter(function(p) { return effectiveProps.indexOf(p) !== -1; }); + if (relevantProps.length === 0) return; + + var updates = {}; + relevantProps.forEach(function(p) { updates[p] = obj.get(p); }); + + if (link.mode === 'chain') { + link.ids.forEach(function(id) { + if (visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(updates); + propagateUpdates(id, updates, visited); + } + }); + } else if (idx === 0) { + // Source: propagate to children recursively + link.ids.slice(1).forEach(function(id) { + if (visited.has(id)) return; + visited.add(id); + var target = getObj('graphic', id); + if (target) { + target.set(updates); + propagateUpdates(id, updates, visited); + } + }); + } else if (!link.soft) { + // Hard lock: revert child to source value + var source = getObj('graphic', link.ids[0]); + if (source) { + var revert = {}; + relevantProps.forEach(function(p) { revert[p] = source.get(p); }); + obj.set(revert); + } + } + }); + + syncing = false; + }; + + // ========================================================================= + // Commands + // ========================================================================= + + const doLink = (msg, args) => { + var up = args.indexOf('--up') !== -1; + var down = args.indexOf('--down') !== -1; + args = args.filter(function(a) { return a !== '--up' && a !== '--down'; }); + var parsed = parseCommand(msg, args); + var linkProps = parsed.props || 'all'; + + // Check if token already has an existing link + var existingLink = null; + if (parsed.ids.length >= 1) { + var links = findLinksForToken(parsed.ids[0]); + var asParent = links.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]; }); + var asChild = links.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; }); + + if (parsed.ids.length === 1) { + if (asParent.length > 0 && asChild.length > 0 && !up && !down) { + reply(msg, 'Error', 'Token is both parent and child. Use --up (modify parent link) or --down (modify child link).'); + return; + } + if (up && asChild.length > 0) existingLink = asChild[0]; + else if (down && asParent.length > 0) existingLink = asParent[0]; + else if (asParent.length > 0) existingLink = asParent[0]; + else if (asChild.length > 0) existingLink = asChild[0]; + } else { + // Multi-token: check if source has existing link as parent + if (asParent.length > 0) existingLink = asParent[0]; + } + } + + if (parsed.ids.length < 2 && !existingLink) { + reply(msg, 'Error', 'Link requires at least 2 tokens (or 1 token already in a link).'); + return; + } + + if (existingLink && Array.isArray(linkProps)) { + var link = existingLink.link; + if (link.props === 'all' || link.props === 'api-all') { + link.excludes = (link.excludes || []).filter(function(p) { + return linkProps.indexOf(p) === -1; + }); + reply(msg, 'Link', 'Re-included ' + linkProps.length + ' prop(s) in existing link.'); + } else { + linkProps.forEach(function(p) { + if (link.props.indexOf(p) === -1) link.props.push(p); + }); + reply(msg, 'Link', 'Added ' + linkProps.length + ' prop(s) to existing link (' + link.props.length + ' total).'); + } + } else { + var result = createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes); + if (!result) { reply(msg, 'Error', 'Cannot create link — a child token already has a hard parent or is in a chain.'); return; } + if (parsed.align) { + var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps; + alignTokens(parsed.ids, alignProps); + } + var propCount = linkProps === 'all' ? 'all' : linkProps.length; + reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').'); + } + parsed.ids.forEach(function(id) { updatePlacementLocksForToken(id); }); + }; + + const doUnlink = (msg, args) => { + var parsed = parseCommand(msg, args); + if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + var hasSpecificProps = parsed.props !== null && parsed.props !== 'all'; + var processed = 0; + var errors = []; + parsed.ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode === 'chain') { + if (hasSpecificProps) { + errors.push((getObj('graphic', id) || {get:function(){return id;}}).get('name') || id); + return; + } + // Remove this token from the chain + var link = entry.link; + link.ids = link.ids.filter(function(tid) { return tid !== id; }); + // Rebuild chainedIds for removed token + rebuildChainedIds(id, entry.id); + // If chain has fewer than 2 members, destroy it + if (link.ids.length < 2) { + link.ids.forEach(function(tid) { rebuildChainedIds(tid, entry.id); }); + delete state[SCRIPT_NAME].links[entry.id]; + } + processed++; + } else { + // Non-chain: existing behavior + if (!hasSpecificProps) { + removePropsFromLink(entry.id, null); + } else if (entry.link.props === 'all' || entry.link.props === 'api-all') { + parsed.props.forEach(function(p) { + if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); + }); + } else { + removePropsFromLink(entry.id, parsed.props); + } + processed++; + } + }); + }); + var out = 'Processed ' + processed + ' link(s).'; + if (errors.length > 0) out += '
      Cannot unlink specific props from chain members: ' + errors.join(', ') + '. Use !mirror unchain [props] instead.'; + reply(msg, 'Unlink', out); + parsed.ids.forEach(function(id) { updatePlacementLocksForToken(id); }); + }; + + const doChain = (msg, args) => { + var parsed = parseCommand(msg, args); + var linkProps = parsed.props; // null = no props specified, 'all' = explicit all, [...] = specific + + if (parsed.ids.length < 1) { reply(msg, 'Error', 'Select or specify at least one token.'); return; } + + // Find which selected tokens are already in chains + var chainMap = {}; // linkId → entry + var unchainedIds = []; + parsed.ids.forEach(function(id) { + var links = findLinksForToken(id); + var inChain = links.find(function(e) { return e.link.mode === 'chain'; }); + if (inChain) chainMap[inChain.id] = inChain; + else unchainedIds.push(id); + }); + var existingChains = Object.values(chainMap); + + if (linkProps === null) { + // No props specified: add unchained tokens to chain, or create new chain + if (existingChains.length > 1) { + reply(msg, 'Error', 'Selected tokens belong to multiple chains. Cannot merge.'); + return; + } + if (existingChains.length === 1) { + // Add unchained tokens to the existing chain + if (unchainedIds.length === 0) { + reply(msg, 'Error', 'No unchained tokens to add. Use !mirror chain all to set all props, or specify props to add.'); + return; + } + var chain = existingChains[0].link; + unchainedIds.forEach(function(id) { + if (chain.ids.indexOf(id) === -1) { + chain.ids.push(id); + state[SCRIPT_NAME].chainedIds[id] = true; + } + }); + reply(msg, 'Chain', 'Added ' + unchainedIds.length + ' token(s) to existing chain (' + chain.ids.length + ' total).'); + } else { + // No existing chains: create new chain with 'all' + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + var result = createLink('chain', 'all', parsed.ids, true, parsed.excludes); + if (!result) { reply(msg, 'Error', 'Cannot create chain — a token has a hard parent link.'); return; } + if (parsed.align) alignTokens(parsed.ids, getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; })); + reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (all props' + (parsed.align ? ', aligned' : '') + ').'); + } + } else { + // Props specified: modify existing chains or create new one + if (existingChains.length > 0) { + existingChains.forEach(function(entry) { + var link = entry.link; + var propsToApply = linkProps === 'all' ? null : linkProps; + if (propsToApply && (link.props === 'all' || link.props === 'api-all')) { + link.excludes = (link.excludes || []).filter(function(p) { return propsToApply.indexOf(p) === -1; }); + } else if (propsToApply && Array.isArray(link.props)) { + propsToApply.forEach(function(p) { if (link.props.indexOf(p) === -1) link.props.push(p); }); + } + }); + var propCount = linkProps === 'all' ? 'all' : linkProps.length; + var msg2 = 'Updated ' + existingChains.length + ' chain(s) (' + propCount + ' props).'; + if (unchainedIds.length > 0) msg2 += '
      ' + unchainedIds.length + ' unchained token(s) ignored (use no props to add them).'; + reply(msg, 'Chain', msg2); + } else { + // No existing chains: create new + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; } + var result = createLink('chain', linkProps, parsed.ids, true, parsed.excludes); + if (!result) { reply(msg, 'Error', 'Cannot create chain — a token has a hard parent link.'); return; } + if (parsed.align) { + var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps; + alignTokens(parsed.ids, alignProps); + } + var propCount = linkProps === 'all' ? 'all' : linkProps.length; + reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').'); + } + } + }; + + const doUnchain = (msg, args) => { + var parsed = parseCommand(msg, args); + if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; } + var hasSpecificProps = parsed.props !== null && parsed.props !== 'all'; + var processed = 0; + parsed.ids.forEach(function(id) { + findLinksForToken(id).forEach(function(entry) { + if (entry.link.mode !== 'chain') return; + if (!hasSpecificProps) { + // No props specified: remove entire chain + removePropsFromLink(entry.id, null); + } else if (entry.link.props === 'all') { + // Link uses 'all': add props to excludes + var propsToExclude = parsed.props; + propsToExclude.forEach(function(p) { + if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); + }); + } else { + // Link uses specific props: remove them + removePropsFromLink(entry.id, parsed.props); + } + processed++; + }); + }); + reply(msg, 'Unchain', 'Processed ' + processed + ' chain(s).'); + }; + + const doConfig = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)')); + return; + } + var sub = args.shift(); + if (sub === 'exclude') { + var resolved = resolveProps(args); + if (resolved.props.length === 0) { reply(msg, 'Error', 'Specify properties to exclude.'); return; } + resolved.props.forEach(function(p) { + if (s.globalExcludes.indexOf(p) === -1) s.globalExcludes.push(p); + }); + reply(msg, 'Config', 'Global excludes: ' + s.globalExcludes.join(', ')); + } else if (sub === 'include') { + var resolved = resolveProps(args); + if (resolved.props.length === 0) { reply(msg, 'Error', 'Specify properties to include.'); return; } + s.globalExcludes = s.globalExcludes.filter(function(p) { return resolved.props.indexOf(p) === -1; }); + reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)')); + } else if (sub === 'reset') { + s.globalExcludes = []; + reply(msg, 'Config', 'Global excludes cleared.'); + } else { + reply(msg, 'Error', 'Usage: !mirror config [exclude|include|reset] [props]'); + } + }; + + const doAlign = (msg, args) => { + var linked = args.indexOf('--linked') !== -1; + var unlinked = args.indexOf('--unlinked') !== -1; + var up = args.indexOf('--up') !== -1; + var down = args.indexOf('--down') !== -1; + var ifLinked = args.indexOf('--if-linked') !== -1; + args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain' && a !== '--if-linked'; }); + if (!linked && !unlinked) linked = true; + // --up takes precedence; --up --down is same as --up + if (up) down = false; + + var parsed = parseCommand(msg, args); + + // Single-token align + if (parsed.ids.length === 1 && linked) { + var singleLinks = findLinksForToken(parsed.ids[0]); + if (singleLinks.length > 0) { + var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; }); + var isParentOrChain = singleLinks.some(function(e) { return e.link.mode === 'chain' || (e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]); }); + var isChild = asChild.length > 0; + + // Ambiguous: both parent/chain AND child, no flags + if (isParentOrChain && isChild && !up && !down) { + reply(msg, 'Error', 'Token is both parent/chain and child. Use --up (align to parent then cascade) or --down (cascade from current value).'); + return; + } + + var source = getObj('graphic', parsed.ids[0]); + if (!source) { reply(msg, 'Error', 'Token not found.'); return; } + var aligned = 0; + + // Step 1: If --up (or unambiguous child), align self to parent + var doUp = up || (!down && isChild && !isParentOrChain); + if (doUp && asChild.length > 0) { + var parentLink = asChild[0].link; + var props = parsed.props === null ? getEffectiveProps(parentLink) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + var parent = getObj('graphic', parentLink.ids[0]); + if (parent) { + var updates = {}; + props.forEach(function(p) { updates[p] = parent.get(p); }); + source.set(updates); + aligned++; + } + } + + // Step 2: Cascade from self to chain + children recursively + var cascadeVisited = new Set([parsed.ids[0]]); + var cascadeFrom = function(tokenId) { + var tokenObj = getObj('graphic', tokenId); + if (!tokenObj) return; + var s = state[SCRIPT_NAME]; + Object.values(s.links).forEach(function(link) { + var idx = link.ids.indexOf(tokenId); + if (idx === -1) return; + var requestedProps = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + // --if-linked: intersect with link's effective props + var linkProps = ifLinked ? requestedProps.filter(function(p) { return getEffectiveProps(link).indexOf(p) !== -1; }) : requestedProps; + var updates = {}; + linkProps.forEach(function(p) { updates[p] = tokenObj.get(p); }); + + if (link.mode === 'chain') { + link.ids.forEach(function(tid) { + if (cascadeVisited.has(tid)) return; + cascadeVisited.add(tid); + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; cascadeFrom(tid); } + }); + } else if (idx === 0) { + link.ids.slice(1).forEach(function(tid) { + if (cascadeVisited.has(tid)) return; + cascadeVisited.add(tid); + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; cascadeFrom(tid); } + }); + } + }); + }; + cascadeFrom(parsed.ids[0]); + + reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).'); + return; + } + } + + if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens (or 1 token in a link/chain).'); return; } + + var s = state[SCRIPT_NAME]; + var aligned = 0; + var ignored = []; + + if (linked) { + parsed.ids.forEach(function(id) { + var links = findLinksForToken(id); + links.forEach(function(entry) { + var link = entry.link; + // null = use link's scope; 'all' or array = explicit + var props = parsed.props === null ? getEffectiveProps(link) : + parsed.props === 'all' ? getKnownProps() : parsed.props; + if (link.mode === 'chain') { + // Align to the first selected/passed id that is in this chain + var sourceId = parsed.ids.find(function(pid) { return link.ids.indexOf(pid) !== -1; }); + if (!sourceId) return; + var source = getObj('graphic', sourceId); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + link.ids.forEach(function(tid) { + if (tid === sourceId) return; + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; } + }); + } else { + // One-way: parent aligns children, or child aligns to parent + var sourceIdx = link.ids.indexOf(id); + if (sourceIdx === 0) { + // This is the parent — align children to it + var source = getObj('graphic', id); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + link.ids.slice(1).forEach(function(tid) { + var t = getObj('graphic', tid); + if (t) { t.set(updates); aligned++; } + }); + } else { + // This is a child — align to parent + var source = getObj('graphic', link.ids[0]); + if (!source) return; + var updates = {}; + props.forEach(function(p) { updates[p] = source.get(p); }); + var target = getObj('graphic', id); + if (target) { target.set(updates); aligned++; } + } + } + }); + if (links.length === 0) ignored.push(id); + }); + } + + if (unlinked) { + // Align unlinked tokens to first id in selection + var sourceId = parsed.ids[0]; + var source = getObj('graphic', sourceId); + if (source) { + var alignProps = parsed.props === null || parsed.props === 'all' ? getKnownProps() : parsed.props; + var updates = {}; + alignProps.forEach(function(p) { updates[p] = source.get(p); }); + parsed.ids.slice(1).forEach(function(id) { + var links = findLinksForToken(id); + if (links.length === 0) { + var t = getObj('graphic', id); + if (t) { t.set(updates); aligned++; } + } + }); + } + } + + var out = 'Aligned ' + aligned + ' token(s).'; + if (ignored.length > 0 && linked && !unlinked) { + out += '
      ' + ignored.length + ' token(s) ignored (not linked).'; + } + reply(msg, 'Align', out); + }; + + const doStatus = (msg) => { + var ids = getSelectedIds(msg); + if (ids.length === 0) { reply(msg, 'Error', 'Select token(s).'); return; } + var out = ''; + ids.forEach(function(id) { + var obj = getObj('graphic', id); + var name = obj ? (obj.get('name') || '(unnamed)') : '?'; + var links = findLinksForToken(id); + out += '' + name + ' (' + id + '): '; + if (links.length === 0) { out += 'no mirror links
      '; return; } + links.forEach(function(entry) { + var role = entry.link.mode === 'chain' ? 'chain' : (entry.link.ids[0] === id ? 'source' : 'target'); + out += role + ' (' + entry.link.props.length + ' props, ' + entry.link.ids.length + ' tokens' + (entry.link.soft ? ', soft' : '') + ')
      '; + }); + }); + reply(msg, out); + }; + + 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

      '; + 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' }); + var html = '

      ' + SCRIPT_NAME + ' — Scripting API

      '; + html += '

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

      '; + html += '

      Linking

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

      Unlinking

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

      Alignment

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

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

      '; + html += '

      Queries

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

      Configuration

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

      Constants

      '; + html += '
      Mirror.ALL_PROPS    // hardcoded prop list\nMirror.PROP_GROUPS  // { spatial, position, size, bars, light, auras, flip }
      '; + hh.set('notes', html); + reply(msg, 'Generated ' + name + ' — check your journal.'); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('change:graphic', onGraphicChanged); + }; + + 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 index 26c3e250e9..933704bc15 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -26,7 +26,7 @@ var Mirror = Mirror || (() => { 'use strict'; const SCRIPT_NAME = 'Mirror'; - const SCRIPT_VERSION = '1.0.0'; + const SCRIPT_VERSION = '1.1.0'; const CMD = '!mirror'; // All syncable graphic properties @@ -142,7 +142,8 @@ var Mirror = Mirror || (() => { 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'; }); + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--soft' && a !== '--align' && a !== '--ignore-selected'; }); // Parse --exclude var excludes = []; @@ -158,7 +159,7 @@ var Mirror = Mirror || (() => { var resolved = resolveProps(args); var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); }); - ids = ids.concat(getSelectedIds(msg)); + if (!ignoreSelected) ids = ids.concat(getSelectedIds(msg)); ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe // Determine if using 'all' or specific props @@ -233,9 +234,76 @@ var Mirror = Mirror || (() => { var linkId = genId(); s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] }; + updatePlacementLocks(linkId); return linkId; }; + /** + * Set/clear lockMovement for children in a link based on whether left+top are hard-linked. + */ + const updatePlacementLocks = (linkId) => { + var s = state[SCRIPT_NAME]; + var link = s.links[linkId]; + if (!link) return; + if (!s.placementLockedByMirror) s.placementLockedByMirror = {}; + + var effectiveProps = getEffectiveProps(link); + var hasLeftTop = effectiveProps.indexOf('left') !== -1 && effectiveProps.indexOf('top') !== -1; + var shouldLock = hasLeftTop && !link.soft; + + // Children are ids[1:] for links, all ids for chains + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { + var obj = getObj('graphic', id); + if (!obj) return; + if (shouldLock && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + s.placementLockedByMirror[id] = true; + } else if (!shouldLock && s.placementLockedByMirror[id]) { + obj.set('lockMovement', false); + delete s.placementLockedByMirror[id]; + } + }); + }; + + /** + * Clear lockMovement for a token if we set it. + */ + const clearPlacementLock = (tokenId) => { + var s = state[SCRIPT_NAME]; + if (!s.placementLockedByMirror || !s.placementLockedByMirror[tokenId]) return; + var obj = getObj('graphic', tokenId); + if (obj) obj.set('lockMovement', false); + delete s.placementLockedByMirror[tokenId]; + }; + + /** + * Re-evaluate lockMovement for a token across all its links. + */ + const updatePlacementLocksForToken = (tokenId) => { + var links = findLinksForToken(tokenId); + var shouldLock = links.some(function(entry) { + var link = entry.link; + if (link.soft) return false; + var effectiveProps = getEffectiveProps(link); + if (effectiveProps.indexOf('left') === -1 || effectiveProps.indexOf('top') === -1) return false; + // Only lock children, not the parent + if (link.mode === 'link' && link.ids[0] === tokenId) return false; + return true; + }); + var s = state[SCRIPT_NAME]; + if (!s.placementLockedByMirror) s.placementLockedByMirror = {}; + var obj = getObj('graphic', tokenId); + if (!obj) return; + if (shouldLock && !obj.get('lockMovement')) { + obj.set('lockMovement', true); + s.placementLockedByMirror[tokenId] = true; + } else if (!shouldLock && s.placementLockedByMirror[tokenId]) { + obj.set('lockMovement', false); + delete s.placementLockedByMirror[tokenId]; + } + }; + /** * Get the effective props for a link, accounting for 'all'/'api-all' and excludes. */ @@ -269,7 +337,9 @@ var Mirror = Mirror || (() => { var link = s.links[linkId]; if (!link) return; if (!propsToRemove || propsToRemove.length === 0) { - // Remove entire link + // Remove entire link — clear locks for children + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { clearPlacementLock(id); }); if (link.mode === 'chain') { link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); } @@ -277,10 +347,14 @@ var Mirror = Mirror || (() => { } else { link.props = link.props.filter(function(p) { return propsToRemove.indexOf(p) === -1; }); if (link.props.length === 0) { + var children = link.mode === 'chain' ? link.ids : link.ids.slice(1); + children.forEach(function(id) { clearPlacementLock(id); }); if (link.mode === 'chain') { link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); }); } delete s.links[linkId]; + } else { + updatePlacementLocks(linkId); } } }; @@ -466,6 +540,7 @@ var Mirror = Mirror || (() => { var propCount = linkProps === 'all' ? 'all' : linkProps.length; reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').'); } + parsed.ids.forEach(function(id) { updatePlacementLocksForToken(id); }); }; const doUnlink = (msg, args) => { @@ -510,6 +585,7 @@ var Mirror = Mirror || (() => { var out = 'Processed ' + processed + ' link(s).'; if (errors.length > 0) out += '
      Cannot unlink specific props from chain members: ' + errors.join(', ') + '. Use !mirror unchain [props] instead.'; reply(msg, 'Unlink', out); + parsed.ids.forEach(function(id) { updatePlacementLocksForToken(id); }); }; const doChain = (msg, args) => { From 5475fabc422ad6b291a66ed2665d5d32dfc9a356 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 12:16:53 -0400 Subject: [PATCH 95/96] Mirror v1.1.0: fix header version --- Mirror/1.1.0/Mirror.js | 4 ++-- Mirror/Mirror.js | 4 ++-- Mirror/README.md | 10 ++++++++++ Mirror/script.json | 4 ++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Mirror/1.1.0/Mirror.js b/Mirror/1.1.0/Mirror.js index 933704bc15..eeff94347d 100644 --- a/Mirror/1.1.0/Mirror.js +++ b/Mirror/1.1.0/Mirror.js @@ -1,6 +1,6 @@ // ============================================================================= -// Mirror v1.0.0 -// Last Updated: 2026-06-15 +// Mirror v1.1.0 +// Last Updated: 2026-06-26 // Author: Kenan Millet // // Description: diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index 933704bc15..eeff94347d 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -1,6 +1,6 @@ // ============================================================================= -// Mirror v1.0.0 -// Last Updated: 2026-06-15 +// Mirror v1.1.0 +// Last Updated: 2026-06-26 // Author: Kenan Millet // // Description: diff --git a/Mirror/README.md b/Mirror/README.md index 6bda9885e5..5c623118b9 100644 --- a/Mirror/README.md +++ b/Mirror/README.md @@ -96,6 +96,16 @@ This syncs everything *except* what Anchor manages (left, top, rotation, width, Mirror.align(tokenId, { ifLinked: true }) // push linked props to dependents ``` +## Changelog + +### v1.1.0 +- `--ignore-selected` flag: skip current selection, use only explicit IDs +- Auto `lockMovement` when left+top are hard-linked on a child token +- `lockMovement` cleared when unlinked or left/top removed from link + +### v1.0.0 +- Initial release + ## License MIT diff --git a/Mirror/script.json b/Mirror/script.json index 66ee730991..738885103c 100644 --- a/Mirror/script.json +++ b/Mirror/script.json @@ -1,8 +1,8 @@ { "name": "Mirror", "script": "Mirror.js", - "version": "1.0.0", - "previousversions": [], + "version": "1.1.0", + "previousversions": ["1.0.0"], "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", From 1adfbbb78f75034178e789a34a75dd722131bef8 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 15:41:11 -0400 Subject: [PATCH 96/96] =?UTF-8?q?Mirror:=20fix=20syntax=20error=20?= =?UTF-8?q?=E2=80=94=20missing=20function=20close=20before=20generateDevDo?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mirror/1.1.0/Mirror.js | 5 ++++- Mirror/Mirror.js | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Mirror/1.1.0/Mirror.js b/Mirror/1.1.0/Mirror.js index eeff94347d..76ee246599 100644 --- a/Mirror/1.1.0/Mirror.js +++ b/Mirror/1.1.0/Mirror.js @@ -1169,7 +1169,10 @@ var Mirror = Mirror || (() => { 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) => { + 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' }); diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js index eeff94347d..76ee246599 100644 --- a/Mirror/Mirror.js +++ b/Mirror/Mirror.js @@ -1169,7 +1169,10 @@ var Mirror = Mirror || (() => { 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) => { + 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' });