From 8257b1f5e8935498bdb594ff00470b2d898a4a54 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 19 Jun 2026 11:31:17 -0400 Subject: [PATCH 01/53] 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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] 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/53] 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/53] 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/53] 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/53] 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/53] =?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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] =?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/53] =?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/53] =?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/53] =?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/53] =?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/53] 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 2528d554277f5ca34f2e933e94aadac5aaf302da Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 20 Jun 2026 08:25:50 -0400 Subject: [PATCH 30/53] 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 74ac625ad6ff073c9b190240372faf61475ae1d4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 08:31:31 -0400 Subject: [PATCH 31/53] =?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 32/53] 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 33/53] 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 34/53] 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 35/53] 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 36/53] 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 c799cfeed3d0f4ae469c6e843f955798b526bbe6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 12:18:04 -0400 Subject: [PATCH 37/53] 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 ee9c647782562ea940cd000462794b25dcb8de30 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 25 Jun 2026 13:03:16 -0400 Subject: [PATCH 38/53] =?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 39/53] 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 40/53] 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 41/53] 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 42/53] =?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 43/53] 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 44/53] 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 45/53] 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 46/53] 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 47/53] 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 48/53] 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 49/53] 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 50/53] =?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 91c21470b353415cf840ada49a45e4c77ef91d60 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 22:01:41 -0400 Subject: [PATCH 51/53] =?UTF-8?q?Gaslight=20v2.0.0:=20workaround=20Fetch?= =?UTF-8?q?=20playerid=3DAPI=20issue=20=E2=80=94=20temp=20enable=20players?= =?UTF-8?q?canids=20during=20script=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/2.0.0/Gaslight.js | 23 +++++++++++++++++++++-- Gaslight/Gaslight.js | 23 +++++++++++++++++++++-- Gaslight/TODO.md | 1 + 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/Gaslight/2.0.0/Gaslight.js b/Gaslight/2.0.0/Gaslight.js index 63648f7284..0362910aca 100644 --- a/Gaslight/2.0.0/Gaslight.js +++ b/Gaslight/2.0.0/Gaslight.js @@ -2417,8 +2417,22 @@ 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 '--script-lock': + scripting = true; + // WORKAROUND: API sendChat sets playerid='API', Fetch denies char access. + // Temporarily enable playerscanids. TODO: remove when Fetch treats API as GM. + if (state.Fetch && state.Fetch.settings) { + state[SCRIPT_NAME]._fetchPcidBackup = state.Fetch.settings.playerscanids; + state.Fetch.settings.playerscanids = true; + } + return; + case '--script-unlock': + scripting = false; + if (state.Fetch && state.Fetch.settings && state[SCRIPT_NAME].hasOwnProperty('_fetchPcidBackup')) { + state.Fetch.settings.playerscanids = state[SCRIPT_NAME]._fetchPcidBackup; + delete state[SCRIPT_NAME]._fetchPcidBackup; + } + return; case '--assign-capture': { // Format: --assign-capture ... var acRollName = args[0]; @@ -2694,6 +2708,11 @@ var Gaslight = Gaslight || (() => { const checkInstall = () => { ensureState(); + // Crash recovery: revert Fetch playerscanids if we crashed mid-script + if (state[SCRIPT_NAME].hasOwnProperty('_fetchPcidBackup') && state.Fetch && state.Fetch.settings) { + state.Fetch.settings.playerscanids = state[SCRIPT_NAME]._fetchPcidBackup; + delete state[SCRIPT_NAME]._fetchPcidBackup; + } createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 63648f7284..0362910aca 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2417,8 +2417,22 @@ 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 '--script-lock': + scripting = true; + // WORKAROUND: API sendChat sets playerid='API', Fetch denies char access. + // Temporarily enable playerscanids. TODO: remove when Fetch treats API as GM. + if (state.Fetch && state.Fetch.settings) { + state[SCRIPT_NAME]._fetchPcidBackup = state.Fetch.settings.playerscanids; + state.Fetch.settings.playerscanids = true; + } + return; + case '--script-unlock': + scripting = false; + if (state.Fetch && state.Fetch.settings && state[SCRIPT_NAME].hasOwnProperty('_fetchPcidBackup')) { + state.Fetch.settings.playerscanids = state[SCRIPT_NAME]._fetchPcidBackup; + delete state[SCRIPT_NAME]._fetchPcidBackup; + } + return; case '--assign-capture': { // Format: --assign-capture ... var acRollName = args[0]; @@ -2694,6 +2708,11 @@ var Gaslight = Gaslight || (() => { const checkInstall = () => { ensureState(); + // Crash recovery: revert Fetch playerscanids if we crashed mid-script + if (state[SCRIPT_NAME].hasOwnProperty('_fetchPcidBackup') && state.Fetch && state.Fetch.settings) { + state.Fetch.settings.playerscanids = state[SCRIPT_NAME]._fetchPcidBackup; + delete state[SCRIPT_NAME]._fetchPcidBackup; + } createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 85a15a1b38..f4b609db47 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -49,4 +49,5 @@ - [ ] On-demand page cloning (if TruePageCopy exposes API) ## Known Issues +- WORKAROUND: Temporarily sets Fetch `playerscanids=true` during script execution because API sendChat sets `playerid='API'` and Fetch denies character access. Remove when Fetch treats API as GM-equivalent (reported to timmaugh). - linkedTokens accumulates duplicates on repeated splits (cosmetic, deduped at use) From 7bf67630010607e0e4f71c067de63bb4af87bfbf Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 26 Jun 2026 22:11:56 -0400 Subject: [PATCH 52/53] Gaslight: buildTriggerMap on startup if active splits exist --- Gaslight/2.0.0/Gaslight.js | 1 + Gaslight/Gaslight.js | 1 + 2 files changed, 2 insertions(+) diff --git a/Gaslight/2.0.0/Gaslight.js b/Gaslight/2.0.0/Gaslight.js index 0362910aca..470984965d 100644 --- a/Gaslight/2.0.0/Gaslight.js +++ b/Gaslight/2.0.0/Gaslight.js @@ -2716,6 +2716,7 @@ var Gaslight = Gaslight || (() => { createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) buildTriggerMap(); }; /** diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 0362910aca..470984965d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2716,6 +2716,7 @@ var Gaslight = Gaslight || (() => { createHelpHandout(); log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) buildTriggerMap(); }; /** From 8cfa866ca29d3b346ab21266ab6f84a1017655bb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 29 Jun 2026 09:28:39 -0400 Subject: [PATCH 53/53] Gaslight: add wiki link to script.json description --- Gaslight/script.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gaslight/script.json b/Gaslight/script.json index 2e9ccf90eb..2c7736a713 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -3,7 +3,7 @@ "script": "Gaslight.js", "version": "2.0.0", "previousversions": ["1.0.0", "1.1.0"], - "description": "Per-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", + "description": "[Wiki Page](https://wiki.roll20.net/Script:Gaslight)\n\nPer-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", "RollCapture"],