From e6aa9021413b07cfe9067b85f2859b8a15fe45c7 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 12 Jul 2026 16:23:08 -0400 Subject: [PATCH 01/37] Integrate Gaslight with ScriptKit - help, man, examples, handout generation --- Gaslight/Gaslight.js | 278 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index de9a88036..09f9c4450 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2883,6 +2883,9 @@ var Gaslight = Gaslight || (() => { if (msg.content.split(' ')[0] !== CMD) return; if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; + // ScriptKit handles help, man, examples, whatsnew, gen-help, gen-dev-docs + if (typeof ScriptKit !== 'undefined' && ScriptKit.handleInput(msg)) return; + const rawArgs = msg.content.slice(CMD.length).trim(); const args = []; var argRx = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g; @@ -3200,6 +3203,273 @@ var Gaslight = Gaslight || (() => { ].join('')); }; + // ========================================================================= + // ScriptKit Registration + // ========================================================================= + + const registerWithScriptKit = () => { + if (typeof ScriptKit === 'undefined') return; + const html = ScriptKit.html; + ScriptKit.register(SCRIPT_NAME, { + version: SCRIPT_VERSION, + command: CMD, + aliases: { help: ['help', '--help'], man: 'man', examples: 'examples', whatsnew: 'whatsnew', genHelp: 'gen-help', genDev: 'gen-dev-docs' }, + newSince: '2.1.0', + help: { + description: 'Per-player map perception. Split players onto individual page copies with synchronized tokens — each player can see different things while movement stays consistent.', + quickStart: [ + 'Create your master page with all tokens placed.', + 'Duplicate it once per player (Roll20 built-in Duplicate Page).', + 'Select party tokens on the master page, run ' + html.code('!gaslight setup mygroup') + ' — auto-detects duplicates, assigns pages, configures the group.', + html.code('!gaslight test mygroup') + ' — dry-run showing how tokens will link. Fix any warnings.', + html.code('!gaslight split mygroup') + ' — activates the group: links tokens, moves players, begins syncing.', + 'When done: ' + html.code('!gaslight merge') + ' — tears down all links, returns players.', + ], + changelog: [ + { version: '2.1.0', changes: [ + 'var command for managing gl_* variables on tokens', + 'sync/desync commands for selective token sync control', + 'HUD system for persistent on-screen status display', + 'Stage improvements: view-awareness, auto-link after stage', + 'Scripting engine fixes: anchor component fix, triggerMap auto-rebuild', + ]}, + { version: '2.0.0', changes: [ + 'Scripting engine — reactive per-player automation via map pins', + 'RollCapture integration for automatic trigger evaluation', + 'Auto-relay system for cross-page command forwarding', + 'Aggregation functions: any(), all(), max(), min(), join()', + 'Setup command for quick group configuration', + ]}, + ], + commands: [ + { group: 'Core', commands: [ + { syntax: 'setup [--selected | player1 player2 ...]', description: 'Quick-configure group from duplicate pages', version: '2.0.0', + details: 'Auto-detects page copies by name, assigns master + player pages, configures the group. Optionally specify which players to include.' }, + { syntax: 'split [--force]', description: 'Activate a prepared group', version: '1.0.0', + details: 'Links tokens across pages, moves players to individual pages, begins syncing. Runs test-first unless --force.' }, + { syntax: 'merge [group]', description: 'Tear down links, return players to shared page', version: '1.0.0' }, + { syntax: 'test ', description: 'Dry-run linking resolution', version: '1.0.0', + details: 'Shows which tokens would link via each resolution step (1-4) without activating anything.' }, + { syntax: 'status', description: 'Show configured groups, active splits, linked token counts', version: '1.0.0' }, + ]}, + { group: 'Token Linking', commands: [ + { syntax: 'link [|new] [ids...]', description: 'Set gaslight_link on tokens', version: '1.0.0', + details: 'Assigns a shared link ID to selected tokens + explicit IDs. Use "new" to auto-generate a fresh ID.' }, + { syntax: 'unlink [ids...|--group ]', description: 'Remove gaslight_link from tokens', version: '1.0.0' }, + { syntax: 'sync [ids...]', description: 'Enable sync for selected tokens', version: '2.1.0' }, + { syntax: 'desync [ids...]', description: 'Disable sync for selected tokens', version: '2.1.0' }, + ]}, + { group: 'Page Configuration', commands: [ + { syntax: 'group ', description: 'Assign current page to a group', version: '1.0.0' }, + { syntax: 'ungroup ', description: 'Remove page from group', version: '1.0.0' }, + { syntax: 'stage [players...]', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', + details: 'Clones tokens to all configured player pages with automatic linking. View-aware in v2.1.0.' }, + { syntax: 'view [player|master]', description: 'Switch relay view target', version: '2.0.0' }, + ]}, + { group: 'Relay & Automation', commands: [ + { syntax: 'relay ', description: 'Manually relay a command to specific views', version: '2.0.0', + details: 'Views: player names, "all", "master"/"GM". Token IDs in the command are swapped with linked counterparts per page.' }, + { syntax: 'config [relay-add|relay-remove|relay-list] [commands...]', description: 'Configure auto-relay command list', version: '2.0.0' }, + { syntax: 'eval [--dry-run] [--all|]', description: 'Evaluate script pins', version: '2.0.0', + details: 'Selected pins, all pins, or by handout name. --dry-run previews without executing.' }, + { syntax: 'var [value]', description: 'Get/set gl_* variables on tokens', version: '2.1.0' }, + { syntax: 'hud [on|off|refresh]', description: 'Toggle persistent status HUD', version: '2.1.0' }, + ]}, + ], + topics: { + linking: { + title: 'Token Linking', + description: 'How tokens are matched across pages', + version: '1.0.0', + body: 'When a group is split, Gaslight resolves token links across pages using a 4-step priority system. Higher steps only apply if previous ones don\'t match.', + items: [ + { name: 'Step 1: gaslight_link', description: 'Explicit match via gaslight_link ID in token GM notes', version: '1.0.0' }, + { name: 'Step 2: represents + name', description: 'Same character + token name (unique pair per page)', version: '1.0.0' }, + { name: 'Step 3: represents + fingerprint', description: 'Same character + position/property fingerprint', version: '1.0.0' }, + { name: 'Step 4: Unlinked', description: 'Has represents but no match found — not synced', version: '1.0.0' }, + ], + }, + sync: { + title: 'Sync Control', + description: 'How token properties stay synchronized', + version: '1.0.0', + details: 'Spatial sync (position, size, rotation) uses Anchor. Property sync (bars, status, tint) uses Mirror. Both respect the gaslight_sync attribute.', + body: 'Set ' + html.code('gaslight_sync') + ' on a character to control which properties sync. Supports include/exclude syntax:', + items: [ + { name: 'gaslight_sync = *', description: 'Sync everything (default)', version: '1.0.0' }, + { name: 'gaslight_sync = bar1 bar2 statusmarkers', description: 'Only sync listed properties', version: '1.0.0' }, + { name: 'gaslight_sync = * !tint_color', description: 'Sync everything except listed', version: '1.0.0' }, + { name: 'gaslight_sync = none', description: 'Disable all property sync', version: '1.0.0' }, + ], + }, + scripting: { + title: 'Scripting Engine', + description: 'Reactive per-player automation via map pins', + version: '2.0.0', + details: 'Scripts are stored in handouts and activated by placing map pins on the page. Each pin evaluates per-viewer per-target with conditional logic to fire different commands for different players.', + body: 'Create a handout with script content. Place a map pin on the page — title it with the handout name (prefix [GLS] is stripped). The pin evaluates whenever triggered attributes change.', + items: [ + { name: '@(target.field)', description: 'Reference a target token field (bar values, GM notes gl_* vars)', version: '2.0.0' }, + { name: '@(viewer.field)', description: 'Reference the viewer\'s token field', version: '2.0.0' }, + { name: '@(gm.field)', description: 'Reference the GM master token field', version: '2.0.0' }, + { name: '{& if condition}...{& endif}', description: 'Conditional block — evaluates per viewer', version: '2.0.0' }, + { name: '{& select target_id}', description: 'Target selection (SelectManager integration)', version: '2.0.0' }, + { name: 'any(), all(), max(), min()', description: 'Aggregate functions across viewer tokens', version: '2.0.0' }, + { name: 'join()', description: 'Space-separated IDs of matching viewer tokens', version: '2.0.0' }, + ], + }, + triggers: { + title: 'Triggers', + description: 'How scripts auto-evaluate on changes', + version: '2.0.0', + body: 'Scripts auto-detect triggers from ' + html.code('@(target.gl_*)') + ' references in ' + html.code('{& if}') + ' blocks. Override in pin GM notes:', + items: [ + { name: 'trigger: on change gl_stealth_result', description: 'Explicit trigger on specific field change', version: '2.0.0' }, + { name: 'trigger: manual only', description: 'Only evaluate when manually run via eval command', version: '2.0.0' }, + ], + }, + relay: { + title: 'Auto-Relay', + description: 'Cross-page command forwarding', + version: '2.0.0', + details: 'Commands targeting master-page tokens can auto-relay to all player pages with token IDs swapped for linked counterparts. Configure which commands relay with the config command.', + body: 'Add commands to the relay list with ' + html.code('!gaslight config relay-add !token-mod !aura') + '. Commands fired on the master page automatically relay to player pages with correct token IDs.', + }, + staging: { + title: 'Staging', + description: 'Adding tokens to an active split', + version: '1.1.0', + details: 'Stage propagates selected tokens from master to all player pages with automatic linking. Tokens with gaslight_stage=1 on their character auto-stage when added to the page.', + body: 'Select tokens on the master page and run ' + html.code('!gaslight stage') + '. Tokens are cloned to each player page, linked, and synced.' + html.paragraph('') + + 'Set ' + html.code('gaslight_stage = 1') + ' on a character attribute for auto-staging whenever that character\'s token is added to a gaslighted page.', + }, + hud: { + title: 'HUD', + description: 'Persistent on-screen status display', + version: '2.1.0', + body: 'The HUD shows the current Gaslight state (active group, view target, linked token count) as a persistent text element on the GM layer.' + html.paragraph('') + + html.code('!gaslight hud on') + ' / ' + html.code('!gaslight hud off') + ' to toggle. ' + html.code('!gaslight hud refresh') + ' to force update.', + }, + troubleshooting: { + title: 'Troubleshooting', + description: 'Common issues and solutions', + version: '1.0.0', + body: html.bold('Tokens not linking') + ' — Run ' + html.code('!gaslight test ') + ' to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.' + html.paragraph('') + + html.bold('Movement not syncing') + ' — Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with ' + html.code('!gaslight status') + '.' + html.paragraph('') + + html.bold('Relay not working') + ' — Confirm the command is in the relay list: ' + html.code('!gaslight config relay-list') + '. Commands must start with ! to be intercepted.' + html.paragraph('') + + html.bold('Scripts not evaluating') + ' — Pin must be on a page with an active split. Check pin title matches handout name. Run ' + html.code('!gaslight eval --dry-run') + ' to debug.' + html.paragraph('') + + html.bold('Players see the same thing') + ' — Make sure the group is actually split (' + html.code('!gaslight status') + '). Player-specific changes must be made on the player\'s page, not the master.', + }, + }, + }, + }); + + // Register examples + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'stealth', + description: 'Hide/show NPCs per player based on passive perception vs stealth DC', + guide: [ + { type: 'select', prompt: 'Select the NPC tokens that can stealth', key: 'targets' }, + { type: 'query', prompt: 'What attribute holds the viewer\'s passive perception?', key: 'percField', default: 'passive_wisdom' }, + ], + handout: { + notes: [ + '

Stealth — Per-Player Visibility

', + '

Hides or shows NPC tokens based on each player\'s passive perception vs the token\'s stealth DC stored in gl_stealth_result.

', + '

Setup

', + '
    ', + '
  1. Set gl_stealth_result on target NPCs (via !gaslight var or RollCapture).
  2. ', + '
  3. Ensure player characters have a passive_wisdom attribute (or your chosen perception field).
  4. ', + '
  5. Place a map pin on the page titled with this handout name.
  6. ', + '
', + '

Script

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

Trigger

', + '

Auto-triggers on gl_stealth_result change. Set a stealth DC with !gaslight var selected gl_stealth_result 15.

', + ].join('\n'), + }, + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'truesight', + description: 'Reveal true forms to viewers with truesight (swap token images)', + guide: [ + { type: 'select', prompt: 'Select the disguised/illusory tokens', key: 'targets' }, + { type: 'query', prompt: 'URL of the true form token image', key: 'trueImgsrc' }, + ], + handout: { + notes: [ + '

Truesight — Reveal True Forms

', + '

Swaps token image to reveal the true form when a viewer has gl_truesight = 1. Use for changelings, illusions, disguised NPCs.

', + '

Setup

', + '
    ', + '
  1. Set gl_truesight = 1 on viewer tokens that have truesight.
  2. ', + '
  3. Store the disguised image URL in gl_false_img and true form in gl_true_img on target tokens.
  4. ', + '
  5. Place a map pin on the page titled with this handout name.
  6. ', + '
', + '

Script

', + '
{& select @(target.token_id)}\n!token-mod --ids @(target.token_id) --set {& if any(@(viewer.gl_truesight)) == 1}imgsrc|@(target.gl_true_img){& else}imgsrc|@(target.gl_false_img){& endif}
', + '

Trigger

', + '

Auto-triggers on gl_truesight change. Grant truesight with !gaslight var selected gl_truesight 1.

', + ].join('\n'), + }, + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'winds-of-magic', + description: 'Show magical auras only to viewers with second sight (via StatusFX)', + guide: [ + { type: 'select', prompt: 'Select the magically-aura\'d tokens', key: 'targets' }, + { type: 'query', prompt: 'Status marker to use for the aura', key: 'marker', default: 'blue' }, + ], + handout: { + notes: [ + '

Winds of Magic — Selective Auras

', + '

Shows a status marker (which StatusFX can animate as particles) only to viewers that have gl_second_sight = 1. Others see no aura.

', + '

Prerequisites

', + '

StatusFX (optional) — for animated particle effects on the status marker.

', + '

Setup

', + '
    ', + '
  1. Set gl_second_sight = 1 on tokens that can perceive magic.
  2. ', + '
  3. Set gl_magical = 1 on tokens that radiate magical aura.
  4. ', + '
  5. Place a map pin on the page titled with this handout name.
  6. ', + '
', + '

Script

', + '
{& select @(target.token_id)}\n{& if @(target.gl_magical) == 1}\n!token-mod --ids @(target.token_id) --set {& if any(@(viewer.gl_second_sight)) == 1}statusmarkers|+blue{& else}statusmarkers|-blue{& endif}\n{& endif}
', + '

Trigger

', + '

Auto-triggers on gl_second_sight and gl_magical changes.

', + ].join('\n'), + }, + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'proximity-reveal', + description: 'Reveal hidden tokens when a player gets close (via triggered action)', + guide: [ + { type: 'auto', text: 'This example requires a Roll20 Triggered Action to set gl_revealed when a player enters proximity.' }, + { type: 'select', prompt: 'Select the hidden tokens to reveal on proximity', key: 'targets' }, + ], + handout: { + notes: [ + '

Proximity Reveal — Distance-Based Discovery

', + '

Hidden tokens become visible when a player token moves close enough. Uses a Roll20 Triggered Action to set gl_revealed, then Gaslight\'s scripting engine handles the layer swap.

', + '

Setup

', + '
    ', + '
  1. Create a Roll20 Triggered Action on the hidden token: when a player token enters proximity (e.g. 2 squares), fire !gaslight var TOKEN_ID gl_revealed 1.
  2. ', + '
  3. Place a map pin on the page titled with this handout name.
  4. ', + '
  5. Hidden tokens start on the GM layer.
  6. ', + '
', + '

Script

', + '
{& select @(target.token_id)}\n!token-mod --ids @(target.token_id) --set {& if @(target.gl_revealed) == 1}layer|objects{& else}layer|gmlayer{& endif}
', + '

Trigger

', + '

Triggers on gl_revealed change. The triggered action sets this when proximity is entered.

', + '

Notes

', + '

This is a non-per-player example — all players see the reveal simultaneously. For per-player proximity (only the approaching player sees it), use viewer-scoped conditions instead.

', + ].join('\n'), + }, + }); + }; + const checkInstall = () => { ensureState(); // Crash recovery: revert Fetch playerscanids if we crashed mid-script @@ -3207,7 +3477,7 @@ var Gaslight = Gaslight || (() => { state.Fetch.settings.playerscanids = state[SCRIPT_NAME]._fetchPcidBackup; delete state[SCRIPT_NAME]._fetchPcidBackup; } - createHelpHandout(); + // createHelpHandout(); // Replaced by ScriptKit gen-help log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) buildTriggerMap(); @@ -4538,6 +4808,9 @@ var Gaslight = Gaslight || (() => { on('chat:message', function(msg) { if (msg.type === 'api' && msg.content === '!rollcapture-ready') registerWithRollCapture(); }); + on('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!scriptkit-ready') registerWithScriptKit(); + }); registerWithRollCapture(); on('add:graphic', onTokenAdded); on('change:attribute', onAttributeChanged); @@ -5018,11 +5291,12 @@ var Gaslight = Gaslight || (() => { }); }; - return { checkInstall, registerEventHandlers }; + return { checkInstall, registerEventHandlers, registerWithScriptKit }; })(); on('ready', () => { 'use strict'; Gaslight.checkInstall(); Gaslight.registerEventHandlers(); + Gaslight.registerWithScriptKit(); }); From 4b049b8dde2eb21585a84b6a5626e21e379c95c2 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 13 Jul 2026 15:04:41 -0400 Subject: [PATCH 02/37] Gaslight ScriptKit: fix GLS scripts, guide improvements, remove proximity-reveal, add dump-token debug --- Gaslight/Gaslight.js | 334 +++++++++++++++++++++++++++++-------------- 1 file changed, 227 insertions(+), 107 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 09f9c4450..93b059186 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -370,8 +370,7 @@ var Gaslight = Gaslight || (() => { var imgsrc = token.get('imgsrc'); if (!imgsrc) return; - // Create with minimal props (spatial + identity + gmnotes) - // Mirror.align handles synced properties after linking is established + // Create with spatial + identity + gmnotes (minimum for Anchor/linking to work) var newToken = createObj('graphic', { _subtype: 'token', pageid: targetPageId, @@ -2982,6 +2981,14 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Eval', 'Pin: ' + headerContent); break; } + case '--dump-token': { + var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + sel.forEach(function(obj) { + log(SCRIPT_NAME + ' [dump-token ' + obj.get('id') + ']: ' + JSON.stringify(obj)); + }); + reply(msg, 'Debug', 'Dumped ' + sel.length + ' token(s) to API console.'); + break; + } case '--dump-html': { // Debug: dump raw content to console for selected pins/tokens or named character if (args.length > 0) { @@ -3183,7 +3190,7 @@ var Gaslight = Gaslight || (() => { '

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}
', + '
// Comments start with //\n!token-mod --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)
  • ', @@ -3213,6 +3220,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.register(SCRIPT_NAME, { version: SCRIPT_VERSION, command: CMD, + tag: 'GLS', aliases: { help: ['help', '--help'], man: 'man', examples: 'examples', whatsnew: 'whatsnew', genHelp: 'gen-help', genDev: 'gen-dev-docs' }, newSince: '2.1.0', help: { @@ -3243,37 +3251,78 @@ var Gaslight = Gaslight || (() => { ], commands: [ { group: 'Core', commands: [ - { syntax: 'setup [--selected | player1 player2 ...]', description: 'Quick-configure group from duplicate pages', version: '2.0.0', - details: 'Auto-detects page copies by name, assigns master + player pages, configures the group. Optionally specify which players to include.' }, + { syntax: 'setup [players...]', description: 'Quick-configure group from duplicate pages', version: '2.0.0', + details: 'Auto-detects page copies by name, assigns master + player pages. Players resolved from selected tokens or named explicitly.', + items: [ + { name: '', description: 'Name for the group configuration', version: '2.0.0' }, + { name: '[players...]', description: 'Player names to include (optional — auto-detected from selected tokens or party-tagged characters)', version: '2.0.0' }, + ]}, { syntax: 'split [--force]', description: 'Activate a prepared group', version: '1.0.0', - details: 'Links tokens across pages, moves players to individual pages, begins syncing. Runs test-first unless --force.' }, + details: 'Links tokens across pages, moves players to individual pages, begins syncing. Runs test-first unless --force.', + items: [ + { name: '--force', description: 'Skip the automatic test and split immediately', version: '1.0.0' }, + ]}, { syntax: 'merge [group]', description: 'Tear down links, return players to shared page', version: '1.0.0' }, { syntax: 'test ', description: 'Dry-run linking resolution', version: '1.0.0', details: 'Shows which tokens would link via each resolution step (1-4) without activating anything.' }, { syntax: 'status', description: 'Show configured groups, active splits, linked token counts', version: '1.0.0' }, ]}, { group: 'Token Linking', commands: [ - { syntax: 'link [|new] [ids...]', description: 'Set gaslight_link on tokens', version: '1.0.0', - details: 'Assigns a shared link ID to selected tokens + explicit IDs. Use "new" to auto-generate a fresh ID.' }, + { syntax: 'link [|new] [ids...]', description: 'Set gaslight_link on selected + explicit tokens', version: '1.0.0', + details: 'Assigns a shared link ID. "new" generates a fresh ID. First non-ID arg is the link name.' }, { syntax: 'unlink [ids...|--group ]', description: 'Remove gaslight_link from tokens', version: '1.0.0' }, - { syntax: 'sync [ids...]', description: 'Enable sync for selected tokens', version: '2.1.0' }, - { syntax: 'desync [ids...]', description: 'Disable sync for selected tokens', version: '2.1.0' }, + { syntax: 'sync [all|reset|]', description: 'Show or modify sync config for selected tokens', version: '2.1.0', + details: 'Overrides the token\'s gaslight_sync config without changing the character attribute. Use "reset" to re-read from the character.', + items: [ + { name: '(no args)', description: 'Show the current gaslight_sync config for each selected token', version: '2.1.0' }, + { name: 'all', description: 'Explicitly sync all properties', version: '2.1.0' }, + { name: 'reset', description: 'Re-copy sync config from the character\'s gaslight_sync attribute', version: '2.1.0' }, + { name: '', description: 'Add specific properties to the sync list (comma or space separated)', version: '2.1.0' }, + ]}, + { syntax: 'desync ', description: 'Exclude properties from sync on selected tokens', version: '2.1.0', + items: [ + { name: 'all', description: 'Disable all property syncing (set empty config)', version: '2.1.0' }, + { name: '', description: 'Add !prop exclusions (e.g. desync tint_color bar3_value)', version: '2.1.0' }, + ]}, ]}, { group: 'Page Configuration', commands: [ - { syntax: 'group ', description: 'Assign current page to a group', version: '1.0.0' }, - { syntax: 'ungroup ', description: 'Remove page from group', version: '1.0.0' }, - { syntax: 'stage [players...]', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', - details: 'Clones tokens to all configured player pages with automatic linking. View-aware in v2.1.0.' }, - { syntax: 'view [player|master]', description: 'Switch relay view target', version: '2.0.0' }, + { syntax: 'group ', description: 'Assign current page to a group', version: '1.0.0' }, + { syntax: 'ungroup ', description: 'Remove page from group', version: '1.0.0' }, + { syntax: 'stage', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', + details: 'Select tokens on a gaslighted page. Clones to all other pages in the group with automatic linking.' }, + { syntax: 'view [player|master|off]', description: 'Show or switch relay view target', version: '2.0.0', + details: 'Controls which page(s) receive relayed commands and where var writes go. Affects auto-relay, manual relay, and var command targeting.', + items: [ + { name: '(no args)', description: 'Show the current view target', version: '2.0.0' }, + { name: 'master / gm / all', description: 'Relay commands to all player pages', version: '2.0.0' }, + { name: 'off / none', description: 'Disable auto-relay entirely', version: '2.0.0' }, + { name: '', description: 'Relay only to a specific player\'s page', version: '2.0.0' }, + ]}, ]}, { group: 'Relay & Automation', commands: [ - { syntax: 'relay ', description: 'Manually relay a command to specific views', version: '2.0.0', - details: 'Views: player names, "all", "master"/"GM". Token IDs in the command are swapped with linked counterparts per page.' }, + { syntax: 'relay ', description: 'Relay a command to specific views (select tokens first)', version: '2.0.0', + details: 'Views: player names, "all", "master"/"gm". Token IDs in the command are swapped with linked counterparts per page.' }, { syntax: 'config [relay-add|relay-remove|relay-list] [commands...]', description: 'Configure auto-relay command list', version: '2.0.0' }, { syntax: 'eval [--dry-run] [--all|]', description: 'Evaluate script pins', version: '2.0.0', - details: 'Selected pins, all pins, or by handout name. --dry-run previews without executing.' }, - { syntax: 'var [value]', description: 'Get/set gl_* variables on tokens', version: '2.1.0' }, - { syntax: 'hud [on|off|refresh]', description: 'Toggle persistent status HUD', version: '2.1.0' }, + details: 'Select pins, or use --all for all active pages, or name a handout. --dry-run previews without executing.' }, + { syntax: 'var [--silent] --set|--get|--del [value]', description: 'Get/set/delete gl_* variables on selected tokens', version: '2.1.0', + details: 'Actions can be chained in one call (e.g. --set a 1 --set b 2 --del c). Names are auto-prefixed with gl_ if missing. View-aware: writes target the current view\'s linked tokens.', + items: [ + { name: '--set ', description: 'Set a gl_* field in token GM notes', version: '2.1.0' }, + { name: '--get ', description: 'Read the current value (from GM notes or character attribute)', version: '2.1.0' }, + { name: '--del ', description: 'Remove the field from token GM notes', version: '2.1.0' }, + { name: '--setch ', description: 'Set on the character attribute (shared across all tokens)', version: '2.1.0' }, + { name: '--delch ', description: 'Delete the character attribute', version: '2.1.0' }, + { name: '--silent', description: 'Don\'t trigger script re-evaluation after the change', version: '2.1.0' }, + ]}, + { syntax: 'hud [] [on|off|reset]', description: 'Toggle HUD elements', version: '2.1.0', + items: [ + { name: '(no args)', description: 'Toggle all HUD elements on/off', version: '2.1.0' }, + { name: 'view', description: 'The view/relay status indicator', version: '2.1.0' }, + { name: 'initiative', description: 'The initiative tracker HUD', version: '2.1.0' }, + { name: 'on / off', description: 'Explicitly enable or disable', version: '2.1.0' }, + { name: 'reset', description: 'Clear stored position and recreate from defaults', version: '2.1.0' }, + ]}, ]}, ], topics: { @@ -3312,7 +3361,7 @@ var Gaslight = Gaslight || (() => { { name: '@(target.field)', description: 'Reference a target token field (bar values, GM notes gl_* vars)', version: '2.0.0' }, { name: '@(viewer.field)', description: 'Reference the viewer\'s token field', version: '2.0.0' }, { name: '@(gm.field)', description: 'Reference the GM master token field', version: '2.0.0' }, - { name: '{& if condition}...{& endif}', description: 'Conditional block — evaluates per viewer', version: '2.0.0' }, + { name: '{& if condition}...{& end}', description: 'Conditional block — evaluates per viewer', version: '2.0.0' }, { name: '{& select target_id}', description: 'Target selection (SelectManager integration)', version: '2.0.0' }, { name: 'any(), all(), max(), min()', description: 'Aggregate functions across viewer tokens', version: '2.0.0' }, { name: 'join()', description: 'Space-separated IDs of matching viewer tokens', version: '2.0.0' }, @@ -3348,7 +3397,7 @@ var Gaslight = Gaslight || (() => { description: 'Persistent on-screen status display', version: '2.1.0', body: 'The HUD shows the current Gaslight state (active group, view target, linked token count) as a persistent text element on the GM layer.' + html.paragraph('') - + html.code('!gaslight hud on') + ' / ' + html.code('!gaslight hud off') + ' to toggle. ' + html.code('!gaslight hud refresh') + ' to force update.', + + html.code('!gaslight hud on') + ' / ' + html.code('!gaslight hud off') + ' to toggle. ' + html.code('!gaslight hud reset') + ' to recreate from defaults.', }, troubleshooting: { title: 'Troubleshooting', @@ -3364,109 +3413,179 @@ var Gaslight = Gaslight || (() => { }, }); + // Pin validation helper for guide steps + const validatePin = (ctx) => { + var pin = ctx.selected[0]; + if (!pin || pin.get('_type') !== 'pin') return 'Select the map pin you just placed.'; + var pinPage = pin.get('_pageid'); + var onMaster = Object.values(state[SCRIPT_NAME].activeGroups || {}).some(g => g.masterPageId === pinPage); + if (!onMaster) return 'That pin is not on a master page. Place it on the master page of your active gaslight group.'; + if (ctx.handoutName) { + var linkedHandout = pin.get('link'); + if (linkedHandout) { + var ho = getObj('handout', linkedHandout); + if (!ho || ho.get('name') !== ctx.handoutName) return 'That pin is not linked to the generated handout. Drag the "' + ctx.handoutName + '" handout onto the page to create the correct pin.'; + } + } + }; + // Register examples ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { - name: 'stealth', - description: 'Hide/show NPCs per player based on passive perception vs stealth DC', + name: 'first-map', + description: 'Walk through setting up your first gaslighted map (no handout generated)', guide: [ - { type: 'select', prompt: 'Select the NPC tokens that can stealth', key: 'targets' }, - { type: 'query', prompt: 'What attribute holds the viewer\'s passive perception?', key: 'percField', default: 'passive_wisdom' }, + { prompt: 'This guide walks you through creating your first **per-player split**. No handout is generated — just follow the steps and click Continue when ready.' }, + { prompt: 'Create your **master page** with all tokens placed (NPCs, players, objects). This is the "truth" page.' }, + { prompt: 'Duplicate the page once per player using Roll20\'s built-in **Duplicate Page**. Leave the page names as they are — the *"Copy of"* prefix is how Gaslight auto-detects them.' }, + { prompt: 'Select all party tokens on the master page and run `!gaslight setup mygroup`. This auto-detects the duplicates, assigns each to a player, and configures the group.', + onContinue: () => { + var groups = discoverAllGroups(); + if (Object.keys(groups).length === 0) return 'No group configured yet. Run !gaslight setup first.'; + } + }, + { prompt: 'Run `!gaslight test mygroup`. This dry-runs the linking without activating. Verify tokens link correctly (*Step 1* = explicit link, *Step 2* = character+name match).' }, + { prompt: 'Run `!gaslight split mygroup`. This activates the group — players are moved to individual pages and tokens begin syncing.', + onContinue: () => { + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run !gaslight split first.'; + } + }, + { prompt: '**Done!** Move tokens on the master page — they\'ll sync to all player pages. Make per-player changes on individual pages (hide/show tokens, swap images, etc). When finished: `!gaslight merge`' }, ], - handout: { - notes: [ - '

    Stealth — Per-Player Visibility

    ', - '

    Hides or shows NPC tokens based on each player\'s passive perception vs the token\'s stealth DC stored in gl_stealth_result.

    ', - '

    Setup

    ', - '
      ', - '
    1. Set gl_stealth_result on target NPCs (via !gaslight var or RollCapture).
    2. ', - '
    3. Ensure player characters have a passive_wisdom attribute (or your chosen perception field).
    4. ', - '
    5. Place a map pin on the page titled with this handout name.
    6. ', - '
    ', - '

    Script

    ', - '
    {& select @(target.token_id)}\n!token-mod --ids @(target.token_id) --set {& if any(@(viewer.passive_wisdom)) >= @(target.gl_stealth_result)}layer|objects{& else}layer|gmlayer{& endif}
    ', - '

    Trigger

    ', - '

    Auto-triggers on gl_stealth_result change. Set a stealth DC with !gaslight var selected gl_stealth_result 15.

    ', - ].join('\n'), - }, }); ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { - name: 'truesight', - description: 'Reveal true forms to viewers with truesight (swap token images)', + name: 'stealth', + description: 'Hide/show NPCs per player based on passive perception vs stealth DC (RollCapture)', guide: [ - { type: 'select', prompt: 'Select the disguised/illusory tokens', key: 'targets' }, - { type: 'query', prompt: 'URL of the true form token image', key: 'trueImgsrc' }, + { prompt: 'This example requires **RollCapture** to capture stealth rolls.', + onContinue: () => { + if (typeof RollCapture === 'undefined') return 'RollCapture is not installed. Install it from the one-click menu first.'; + } + }, + { prompt: 'Run `!rollcapture dissect` in chat, then roll stealth for any NPC. This shows you the template fields available for capture.' }, + { prompt: 'Create a RollCapture rule for skills. Run `!rollcapture rule skills` — this creates a *[RC] skills* handout. Open it in the journal.' }, + { prompt: 'Add the line `template: ` — where `` is the roll template shown by dissect (e.g. "simple", "atk", "npc").' }, + { prompt: 'Add the line `name_field: ` — where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, + { prompt: 'Add the line `result: ` — where `` is the inline roll field holding the total (e.g. "r1", "roll1").' }, + { prompt: 'Save the handout and run `!rollcapture reload` to load your new rule.', + onEnter: (ctx, advance) => { + ctx._rcReloadHandler = (msg) => { + if (msg.type === 'api' && msg.content === '!rollcapture reload') advance(); + }; + on('chat:message', ctx._rcReloadHandler); + }, + onExit: (ctx) => { + if (ctx._rcReloadHandler) off('chat:message', ctx._rcReloadHandler); + } + }, + { prompt: 'What does your character sheet call a stealth roll? This should match the value in the `name_field` when you roll stealth (e.g. "hide", "stealth").', + query: { name: 'rollname', default: 'stealth' } + }, + { prompt: 'What is the **passive perception** attribute on player characters? (e.g. `passive_wisdom`, `passive_perception`)', + query: { name: 'perception', default: 'passive_wisdom' } + }, + ScriptKit.handout(), + { prompt: (ctx) => 'The script handout has been generated. Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', + select: 'pin', as: 'pin', min: 1, max: 1, + onContinue: validatePin + }, + { prompt: '**Test:** Select an NPC token and roll stealth. RollCapture captures the result, which triggers this script. Players whose passive perception is lower than the roll should no longer see the NPC on their page.' }, ], - handout: { - notes: [ - '

    Truesight — Reveal True Forms

    ', - '

    Swaps token image to reveal the true form when a viewer has gl_truesight = 1. Use for changelings, illusions, disguised NPCs.

    ', - '

    Setup

    ', - '
      ', - '
    1. Set gl_truesight = 1 on viewer tokens that have truesight.
    2. ', - '
    3. Store the disguised image URL in gl_false_img and true form in gl_true_img on target tokens.
    4. ', - '
    5. Place a map pin on the page titled with this handout name.
    6. ', - '
    ', - '

    Script

    ', - '
    {& select @(target.token_id)}\n!token-mod --ids @(target.token_id) --set {& if any(@(viewer.gl_truesight)) == 1}imgsrc|@(target.gl_true_img){& else}imgsrc|@(target.gl_false_img){& endif}
    ', - '

    Trigger

    ', - '

    Auto-triggers on gl_truesight change. Grant truesight with !gaslight var selected gl_truesight 1.

    ', - ].join('\n'), - }, + handout: (ctx) => ({ + notes: '!token-mod --set {& if any(@(viewer.' + ctx.params.perception + '[-999])) >= @(target.gl_' + ctx.params.rollname + '_result[0])}layer|objects{& else}layer|gmlayer{& end}', + archived: false, + }), }); ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { - name: 'winds-of-magic', - description: 'Show magical auras only to viewers with second sight (via StatusFX)', + name: 'truesight', + description: 'Reveal true forms to viewers with truesight (swap token side)', guide: [ - { type: 'select', prompt: 'Select the magically-aura\'d tokens', key: 'targets' }, - { type: 'query', prompt: 'Status marker to use for the aura', key: 'marker', default: 'blue' }, + { prompt: 'Select one or more **multi-sided tokens** that represent disguised creatures. *Side 1* = disguised appearance, *side 2* = true form.', + select: 'token', as: 'disguised', min: 1, + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; + var notMultiSided = tokens.filter(t => { + var sides = t.get('sides'); + return !sides || sides.split('|').filter(Boolean).length < 2; + }); + if (notMultiSided.length > 0) { + var names = notMultiSided.map(t => t.get('name') || t.get('id')).join(', '); + return 'These tokens are not multi-sided: ' + names + '. Set up multiple sides on them first.'; + } + } + }, + { prompt: 'Run `!gaslight var --setch can_disguise 1` with those tokens still selected. This marks them as targets for the truesight script.', + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selections.disguised) ? ctx.selections.disguised : [ctx.selections.disguised]; + var missing = tokens.filter(t => { + var charId = t.get('represents'); + if (!charId) return true; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gl_can_disguise' })[0]; + return !attr || attr.get('current') !== '1'; + }); + if (missing.length > 0) { + var names = missing.map(t => t.get('name') || t.get('id')).join(', '); + return 'These tokens do not have gl_can_disguise = 1 on their character: ' + names + '. Run `!gaslight var --setch can_disguise 1` (`--setch`, not `--set`) with them selected.'; + } + } + }, + { prompt: 'Now select viewer tokens that have **truesight** and run `!gaslight var --set truesight 1`.', + select: 'token', as: 'viewers', min: 1, + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; + var missing = tokens.filter(t => { + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf('gl_truesight') === -1 || notes.indexOf('gl_truesight: 1') === -1; + }); + if (missing.length > 0) { + var names = missing.map(t => t.get('name') || t.get('id')).join(', '); + return 'These tokens do not have gl_truesight set: ' + names + '. Run `!gaslight var --set truesight 1` with them selected.'; + } + } + }, + ScriptKit.handout(), + { prompt: (ctx) => 'Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', + select: 'pin', as: 'pin', min: 1, max: 1, + onContinue: validatePin + }, + { prompt: '**Test:** Run `!gaslight eval --all`. Viewers with truesight should see *side 2* (true form) on disguised tokens.' }, ], handout: { - notes: [ - '

    Winds of Magic — Selective Auras

    ', - '

    Shows a status marker (which StatusFX can animate as particles) only to viewers that have gl_second_sight = 1. Others see no aura.

    ', - '

    Prerequisites

    ', - '

    StatusFX (optional) — for animated particle effects on the status marker.

    ', - '

    Setup

    ', - '
      ', - '
    1. Set gl_second_sight = 1 on tokens that can perceive magic.
    2. ', - '
    3. Set gl_magical = 1 on tokens that radiate magical aura.
    4. ', - '
    5. Place a map pin on the page titled with this handout name.
    6. ', - '
    ', - '

    Script

    ', - '
    {& select @(target.token_id)}\n{& if @(target.gl_magical) == 1}\n!token-mod --ids @(target.token_id) --set {& if any(@(viewer.gl_second_sight)) == 1}statusmarkers|+blue{& else}statusmarkers|-blue{& endif}\n{& endif}
    ', - '

    Trigger

    ', - '

    Auto-triggers on gl_second_sight and gl_magical changes.

    ', - ].join('\n'), + notes: '!token-mod --set {& if @(target.gl_can_disguise[0]) == 1 && any(@(viewer.gl_truesight[0])) == 1}currentSide|1{& else}currentSide|0{& end}', + gmnotes: '---GASLIGHT-SCRIPT---\nfilter: has gl_can_disguise', }, }); ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { - name: 'proximity-reveal', - description: 'Reveal hidden tokens when a player gets close (via triggered action)', + name: 'winds-of-magic', + description: 'Show magical auras only to viewers with second sight (via StatusFX)', guide: [ - { type: 'auto', text: 'This example requires a Roll20 Triggered Action to set gl_revealed when a player enters proximity.' }, - { type: 'select', prompt: 'Select the hidden tokens to reveal on proximity', key: 'targets' }, + { prompt: 'Select a token and apply the **status marker** you want to represent the magical aura. Then select that token.', + select: 'token', as: 'markerToken', min: 1, max: 1, + onContinue: (ctx) => { + var token = Array.isArray(ctx.selected) ? ctx.selected[0] : ctx.selected; + var markers = (token.get('statusmarkers') || '').split(',').filter(Boolean); + if (markers.length === 0) return 'That token has no status markers applied. Apply the marker you want to use for the aura, then select the token.'; + // Store the first marker found + ctx.params._marker = markers[0].replace(/@\d+$/, ''); // strip badge number if any + } + }, + { prompt: 'Select aura-radiating tokens and run `!gaslight var --set magical 1`.' }, + { prompt: 'Select tokens that can perceive magic and run `!gaslight var --setch second_sight 1`.' }, + { prompt: '*Optional:* Install **StatusFX** for animated particle effects on the status marker.' }, + ScriptKit.handout(), + { prompt: (ctx) => 'Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', + select: 'pin', as: 'pin', min: 1, max: 1, + onContinue: validatePin + }, + { prompt: (ctx) => '**Test:** Run `!gaslight eval --all`. Viewers with second sight should see the **' + (ctx.params._marker || 'blue') + '** status marker on magical tokens.' }, ], - handout: { - notes: [ - '

    Proximity Reveal — Distance-Based Discovery

    ', - '

    Hidden tokens become visible when a player token moves close enough. Uses a Roll20 Triggered Action to set gl_revealed, then Gaslight\'s scripting engine handles the layer swap.

    ', - '

    Setup

    ', - '
      ', - '
    1. Create a Roll20 Triggered Action on the hidden token: when a player token enters proximity (e.g. 2 squares), fire !gaslight var TOKEN_ID gl_revealed 1.
    2. ', - '
    3. Place a map pin on the page titled with this handout name.
    4. ', - '
    5. Hidden tokens start on the GM layer.
    6. ', - '
    ', - '

    Script

    ', - '
    {& select @(target.token_id)}\n!token-mod --ids @(target.token_id) --set {& if @(target.gl_revealed) == 1}layer|objects{& else}layer|gmlayer{& endif}
    ', - '

    Trigger

    ', - '

    Triggers on gl_revealed change. The triggered action sets this when proximity is entered.

    ', - '

    Notes

    ', - '

    This is a non-per-player example — all players see the reveal simultaneously. For per-player proximity (only the approaching player sees it), use viewer-scoped conditions instead.

    ', - ].join('\n'), - }, + handout: (ctx) => ({ + notes: '!token-mod --set {& if @(target.gl_magical[0]) == 1 && any(@(viewer.gl_second_sight[0])) == 1}statusmarkers|+' + (ctx.params._marker || 'blue') + '{& else}statusmarkers|-' + (ctx.params._marker || 'blue') + '{& end}', + archived: false, + }), }); }; @@ -4808,10 +4927,11 @@ var Gaslight = Gaslight || (() => { on('chat:message', function(msg) { if (msg.type === 'api' && msg.content === '!rollcapture-ready') registerWithRollCapture(); }); + registerWithRollCapture(); on('chat:message', function(msg) { if (msg.type === 'api' && msg.content === '!scriptkit-ready') registerWithScriptKit(); }); - registerWithRollCapture(); + registerWithScriptKit(); on('add:graphic', onTokenAdded); on('change:attribute', onAttributeChanged); on('change:graphic', onGraphicPropChanged); @@ -5291,12 +5411,12 @@ var Gaslight = Gaslight || (() => { }); }; - return { checkInstall, registerEventHandlers, registerWithScriptKit }; + return { checkInstall, registerEventHandlers }; })(); on('ready', () => { 'use strict'; Gaslight.checkInstall(); Gaslight.registerEventHandlers(); - Gaslight.registerWithScriptKit(); }); + From 1b9f01e8e90dc319d0aa8c9372cfeef3732223da Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 13 Jul 2026 15:39:42 -0400 Subject: [PATCH 03/37] Gaslight: use ScriptKit.waitForCommand, truesight validation, winds-of-magic dynamic marker --- Gaslight/Gaslight.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 93b059186..9d7841ff0 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3468,15 +3468,7 @@ var Gaslight = Gaslight || (() => { { prompt: 'Add the line `name_field: ` — where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, { prompt: 'Add the line `result: ` — where `` is the inline roll field holding the total (e.g. "r1", "roll1").' }, { prompt: 'Save the handout and run `!rollcapture reload` to load your new rule.', - onEnter: (ctx, advance) => { - ctx._rcReloadHandler = (msg) => { - if (msg.type === 'api' && msg.content === '!rollcapture reload') advance(); - }; - on('chat:message', ctx._rcReloadHandler); - }, - onExit: (ctx) => { - if (ctx._rcReloadHandler) off('chat:message', ctx._rcReloadHandler); - } + ...ScriptKit.waitForCommand('!rollcapture reload') }, { prompt: 'What does your character sheet call a stealth roll? This should match the value in the `name_field` when you roll stealth (e.g. "hide", "stealth").', query: { name: 'rollname', default: 'stealth' } From e6ff4549c8ddb7967ef701fa6136adcc0f94a55b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 18 Jul 2026 21:06:10 -0400 Subject: [PATCH 04/37] Gaslight: madness example, gm_target, pin-based init HUD, marketplace staging warnings, ScriptKit html fixes --- Gaslight/Gaslight.js | 346 ++++++++++++++++++++++++++++--------------- 1 file changed, 227 insertions(+), 119 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 9d7841ff0..7c006be01 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -341,13 +341,37 @@ var Gaslight = Gaslight || (() => { return null; }; + /** + * Test whether an imgsrc can be used with createObj. + * Creates a temporary token, checks if it succeeded, deletes it immediately. + * @param {string} imgsrc - URL to test + * @param {string} pageid - page to create the test token on + * @returns {boolean} true if the imgsrc is valid for API token creation + */ + const canCreateWithImgsrc = (imgsrc, pageid) => { + if (!imgsrc) return false; + var test = createObj('graphic', { + _subtype: 'token', + pageid: pageid, + imgsrc: imgsrc, + left: -1000, top: -1000, width: 10, height: 10, + layer: 'gmlayer' + }); + if (test) { + test.remove(); + return true; + } + return false; + }; + /** * Stage a single token to target pages using 3-step logic. - * Returns number of clones created. + * Returns { cloned: number, failed: boolean, name: string, imgsrc: string } */ const stageTokenToPages = (token, targetPageIds) => { var linkId = getLinkId(token); var pagesToCloneTo = []; + var result = { cloned: 0, failed: false, name: token.get('name') || '(unnamed)', imgsrc: '' }; if (linkId) { // Step 1-2: find pages missing a token with this gaslight_link @@ -365,7 +389,6 @@ var Gaslight = Gaslight || (() => { pagesToCloneTo = targetPageIds; } - var cloned = 0; pagesToCloneTo.forEach(function(targetPageId) { var imgsrc = token.get('imgsrc'); if (!imgsrc) return; @@ -388,10 +411,15 @@ var Gaslight = Gaslight || (() => { controlledby: token.get('controlledby') || '', gmnotes: token.get('gmnotes') || '' }); - if (newToken) setLinkId(newToken, getLinkId(token)); - cloned++; + if (newToken) { + setLinkId(newToken, getLinkId(token)); + result.cloned++; + } else { + result.failed = true; + result.imgsrc = imgsrc; + } }); - return cloned; + return result; }; const autoPopulateLinkId = (token) => { @@ -1745,6 +1773,7 @@ var Gaslight = Gaslight || (() => { if (targetPlayerIds.length === 0) { reply(msg, 'Error', 'No valid target players.'); return; } var staged = 0; + var marketplaceFailures = []; tokens.forEach(function(token) { var sourcePageId = token.get('_pageid'); var targetPages = targetPlayerIds @@ -1752,7 +1781,9 @@ var Gaslight = Gaslight || (() => { .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); + var result = stageTokenToPages(token, targetPages); + staged += result.cloned; + if (result.failed) marketplaceFailures.push(result); }); // Re-run linking for this group to pick up the new tokens @@ -1778,6 +1809,21 @@ var Gaslight = Gaslight || (() => { } reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); + + if (marketplaceFailures.length > 0) { + var failMsg = '⚠️ ' + marketplaceFailures.length + ' token(s) could not be staged because their images are from the Roll20 Marketplace and not in your library.

    '; + marketplaceFailures.forEach(function(f) { + failMsg += '
    ' + + '
    ' + + '' + f.name + '
    '; + }); + failMsg += '

    To fix:
      ' + + '
    1. In the Art Library tab, find the asset under Premium Assets → Marketplace Purchases, right-click it (or the folder), and select Copy to Library.
    2. ' + + '
    3. Replace the token(s) on the master page with the library copy (drag from your library).
    4. ' + + '
    5. Re-run !gaslight stage with the new token(s) selected.
    6. ' + + '
    '; + reply(msg, 'Stage', failMsg); + } }; /** @@ -2410,6 +2456,9 @@ var Gaslight = Gaslight || (() => { var tokens = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); var filter = config.filter.toLowerCase(); if (filter === 'all') return tokens; + if (filter === 'characters') { + return tokens.filter(function(t) { return !!t.get('represents'); }); + } if (filter === 'npc') { return tokens.filter(function(t) { var charId = t.get('represents'); @@ -2683,20 +2732,10 @@ var Gaslight = Gaslight || (() => { evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page 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') { - 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') + '.'); + // Replace @(gm_target.*) with master page token ID + content = content.replace(/@\(gm_target\./g, '@(' + targetToken.get('id') + '.'); // Resolve viewer tokens for aggregation var viewerTokens = findObjs({ _type: 'graphic', _pageid: viewerPageId, _subtype: 'token' }).filter(function(t) { var cid = t.get('represents'); @@ -3216,7 +3255,6 @@ var Gaslight = Gaslight || (() => { const registerWithScriptKit = () => { if (typeof ScriptKit === 'undefined') return; - const html = ScriptKit.html; ScriptKit.register(SCRIPT_NAME, { version: SCRIPT_VERSION, command: CMD, @@ -3228,10 +3266,10 @@ var Gaslight = Gaslight || (() => { quickStart: [ 'Create your master page with all tokens placed.', 'Duplicate it once per player (Roll20 built-in Duplicate Page).', - 'Select party tokens on the master page, run ' + html.code('!gaslight setup mygroup') + ' — auto-detects duplicates, assigns pages, configures the group.', - html.code('!gaslight test mygroup') + ' — dry-run showing how tokens will link. Fix any warnings.', - html.code('!gaslight split mygroup') + ' — activates the group: links tokens, moves players, begins syncing.', - 'When done: ' + html.code('!gaslight merge') + ' — tears down all links, returns players.', + 'Select party tokens on the master page, run `!gaslight setup mygroup` — auto-detects duplicates, assigns pages, configures the group.', + '`!gaslight test mygroup` — dry-run showing how tokens will link. Fix any warnings.', + '`!gaslight split mygroup` — activates the group: links tokens, moves players, begins syncing.', + 'When done: `!gaslight merge` — tears down all links, returns players.', ], changelog: [ { version: '2.1.0', changes: [ @@ -3343,7 +3381,7 @@ var Gaslight = Gaslight || (() => { description: 'How token properties stay synchronized', version: '1.0.0', details: 'Spatial sync (position, size, rotation) uses Anchor. Property sync (bars, status, tint) uses Mirror. Both respect the gaslight_sync attribute.', - body: 'Set ' + html.code('gaslight_sync') + ' on a character to control which properties sync. Supports include/exclude syntax:', + body: 'Set `gaslight_sync` on a character to control which properties sync. Supports include/exclude syntax:', items: [ { name: 'gaslight_sync = *', description: 'Sync everything (default)', version: '1.0.0' }, { name: 'gaslight_sync = bar1 bar2 statusmarkers', description: 'Only sync listed properties', version: '1.0.0' }, @@ -3371,7 +3409,7 @@ var Gaslight = Gaslight || (() => { title: 'Triggers', description: 'How scripts auto-evaluate on changes', version: '2.0.0', - body: 'Scripts auto-detect triggers from ' + html.code('@(target.gl_*)') + ' references in ' + html.code('{& if}') + ' blocks. Override in pin GM notes:', + body: 'Scripts auto-detect triggers from `@(target.gl_*)` references in `{& if}` blocks. Override in pin GM notes:', items: [ { name: 'trigger: on change gl_stealth_result', description: 'Explicit trigger on specific field change', version: '2.0.0' }, { name: 'trigger: manual only', description: 'Only evaluate when manually run via eval command', version: '2.0.0' }, @@ -3382,32 +3420,26 @@ var Gaslight = Gaslight || (() => { description: 'Cross-page command forwarding', version: '2.0.0', details: 'Commands targeting master-page tokens can auto-relay to all player pages with token IDs swapped for linked counterparts. Configure which commands relay with the config command.', - body: 'Add commands to the relay list with ' + html.code('!gaslight config relay-add !token-mod !aura') + '. Commands fired on the master page automatically relay to player pages with correct token IDs.', + body: 'Add commands to the relay list with `!gaslight config relay-add !token-mod !aura`. Commands fired on the master page automatically relay to player pages with correct token IDs.', }, staging: { title: 'Staging', description: 'Adding tokens to an active split', version: '1.1.0', details: 'Stage propagates selected tokens from master to all player pages with automatic linking. Tokens with gaslight_stage=1 on their character auto-stage when added to the page.', - body: 'Select tokens on the master page and run ' + html.code('!gaslight stage') + '. Tokens are cloned to each player page, linked, and synced.' + html.paragraph('') - + 'Set ' + html.code('gaslight_stage = 1') + ' on a character attribute for auto-staging whenever that character\'s token is added to a gaslighted page.', + body: 'Select tokens on the master page and run `!gaslight stage`. Tokens are cloned to each player page, linked, and synced.\n\nSet `gaslight_stage = 1` on a character attribute for auto-staging whenever that character\'s token is added to a gaslighted page.', }, hud: { title: 'HUD', description: 'Persistent on-screen status display', version: '2.1.0', - body: 'The HUD shows the current Gaslight state (active group, view target, linked token count) as a persistent text element on the GM layer.' + html.paragraph('') - + html.code('!gaslight hud on') + ' / ' + html.code('!gaslight hud off') + ' to toggle. ' + html.code('!gaslight hud reset') + ' to recreate from defaults.', + body: 'The HUD shows the current Gaslight state (active group, view target, linked token count) as a persistent text element on the GM layer.\n\n`!gaslight hud on` / `!gaslight hud off` to toggle. `!gaslight hud reset` to recreate from defaults.', }, troubleshooting: { title: 'Troubleshooting', description: 'Common issues and solutions', version: '1.0.0', - body: html.bold('Tokens not linking') + ' — Run ' + html.code('!gaslight test ') + ' to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.' + html.paragraph('') - + html.bold('Movement not syncing') + ' — Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with ' + html.code('!gaslight status') + '.' + html.paragraph('') - + html.bold('Relay not working') + ' — Confirm the command is in the relay list: ' + html.code('!gaslight config relay-list') + '. Commands must start with ! to be intercepted.' + html.paragraph('') - + html.bold('Scripts not evaluating') + ' — Pin must be on a page with an active split. Check pin title matches handout name. Run ' + html.code('!gaslight eval --dry-run') + ' to debug.' + html.paragraph('') - + html.bold('Players see the same thing') + ' — Make sure the group is actually split (' + html.code('!gaslight status') + '). Player-specific changes must be made on the player\'s page, not the master.', + body: '**Tokens not linking** — Run `!gaslight test ` to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.\n\n**Movement not syncing** — Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with `!gaslight status`.\n\n**Relay not working** — Confirm the command is in the relay list: `!gaslight config relay-list`. Commands must start with ! to be intercepted.\n\n**Scripts not evaluating** — Pin must be on a page with an active split. Check pin title matches handout name. Run `!gaslight eval --dry-run` to debug.\n\n**Players see the same thing** — Make sure the group is actually split (`!gaslight status`). Player-specific changes must be made on the player\'s page, not the master.', }, }, }, @@ -3443,7 +3475,6 @@ var Gaslight = Gaslight || (() => { if (Object.keys(groups).length === 0) return 'No group configured yet. Run !gaslight setup first.'; } }, - { prompt: 'Run `!gaslight test mygroup`. This dry-runs the linking without activating. Verify tokens link correctly (*Step 1* = explicit link, *Step 2* = character+name match).' }, { prompt: 'Run `!gaslight split mygroup`. This activates the group — players are moved to individual pages and tokens begin syncing.', onContinue: () => { if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run !gaslight split first.'; @@ -3579,6 +3610,41 @@ var Gaslight = Gaslight || (() => { archived: false, }), }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'madness', + description: 'An afflicted player sees all tokens as enemies — allies swap to a chosen enemy image', + guide: [ + { prompt: 'Select a token that has the **status marker** you want to represent madness applied to it (e.g. "screaming"). This marker will trigger the effect when placed on a viewer\'s token.', + select: 'token', as: 'markerToken', min: 1, max: 1, + onContinue: (ctx) => { + var token = Array.isArray(ctx.selected) ? ctx.selected[0] : ctx.selected; + var markers = (token.get('statusmarkers') || '').split(',').filter(Boolean); + if (markers.length === 0) return 'That token has no status markers applied. Apply the marker you want to use for madness, then select the token.'; + ctx.params._marker = markers[0].replace(/@\d+$/, ''); + } + }, + { prompt: 'Select an **enemy token** whose image will replace all other tokens for the afflicted viewer.', + select: 'token', as: 'enemyToken', min: 1, max: 1, + onContinue: (ctx) => { + var token = Array.isArray(ctx.selected) ? ctx.selected[0] : ctx.selected; + ctx.params._enemyId = token.get('id'); + } + }, + { prompt: (ctx) => 'To afflict a player, apply the **' + ctx.params._marker + '** status marker to their viewer token. Remove it to cure them.\n\nThe script will automatically store and restore original images.' }, + ScriptKit.handout(), + { prompt: (ctx) => 'Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', + select: 'pin', as: 'pin', min: 1, max: 1, + onContinue: validatePin + }, + { prompt: (ctx) => '**Test:** Apply the **' + ctx.params._marker + '** marker to a viewer token, then run `!gaslight eval --all`. All tokens on that viewer\'s page should swap to the enemy image. Remove the marker and re-eval to restore.' }, + ], + handout: (ctx) => ({ + notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|ŧ̈́h̶̛ë̵̡ý̸̲{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', + gmnotes: '---GASLIGHT-SCRIPT---\nfilter: characters\ntrigger: on change statusmarkers', + archived: false, + }), + }); }; const checkInstall = () => { @@ -4293,11 +4359,11 @@ var Gaslight = Gaslight || (() => { }); toRemove.reverse().forEach(function(i) { var entry = data.entries[i]; - var tok = getObj('graphic', entry.tokenId) || getObj('pin', entry.tokenId); + var pin = getObj('pin', entry.tokenId); var txt = getObj('text', entry.textId); // Splice first so destroy handler ignores data.entries.splice(i, 1); - if (tok) { if (typeof Mirror !== 'undefined' && entry.sourceId && !entry.sourceId.startsWith('custom:')) Mirror.unlink([tok.get('id')]); tok.remove(); } + if (pin) pin.remove(); if (txt) txt.remove(); }); @@ -4321,8 +4387,8 @@ var Gaslight = Gaslight || (() => { bgColor: 'transparent', useTextIcon: true, textIcon: '', - scale: 1.75, - tooltipVisibleTo: '', + scale: 1.25, + visibleTo: 'all', }); var pinText = createObj('text', { @@ -4348,18 +4414,17 @@ var Gaslight = Gaslight || (() => { var sourceToken = getObj('graphic', entry.id); if (!sourceToken) return; - var hudToken = createObj('graphic', { + var hudPin = createObj('pin', { _pageid: pageId, - layer: 'foreground', - imgsrc: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), - left: frameLeft, - top: frameTopEdge + (data.tokenPadding || defaultInitHud.tokenPadding) + tokenSize / 2, - width: tokenSize, - height: tokenSize, - showname: true, - name: sourceToken.get('name'), - baseOpacity: 1, - isdrawing: true, + x: frameLeft, + y: -5000, + title: sourceToken.get('name') || '', + shape: 'circle', + bgColor: 'transparent', + customizationType: 'image', + pinImage: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), + scale: 1.25, + visibleTo: 'all', }); var hudText = createObj('text', { @@ -4374,13 +4439,9 @@ var Gaslight = Gaslight || (() => { font_family: data.textFontFamily || defaultInitHud.textFontFamily, }); - if (typeof Mirror !== 'undefined') { - Mirror.link([sourceToken.get('id'), hudToken.get('id')], ['name', 'statusmarkers', 'tint_color'], { soft: true }); - } - data.entries.push({ sourceId: entry.id, - tokenId: hudToken.get('id'), + tokenId: hudPin ? hudPin.get('id') : null, textId: hudText.get('id'), }); } @@ -4504,15 +4565,15 @@ var Gaslight = Gaslight || (() => { var hudEntry = tokenMap[entry.id]; if (!hudEntry) return; - var tok = getObj('graphic', hudEntry.tokenId); + var pin = getObj('pin', hudEntry.tokenId); var txt = getObj('text', hudEntry.textId); - if (!tok) return; + if (!pin) return; var offset = i <= slotsBelow ? i : i - order.length; var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); - tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); + pin.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000 }); if (txt) { txt.set({ left: frameLeft + frameWidth / 2 + (data.textOffset || 15), @@ -4560,19 +4621,16 @@ var Gaslight = Gaslight || (() => { var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; if (!hudEntry) return; - var tok = getObj('graphic', hudEntry.tokenId) || getObj('pin', hudEntry.tokenId); + var pin = getObj('pin', hudEntry.tokenId); var txt = getObj('text', hudEntry.textId); - if (!tok) return; + if (!pin) return; var offset = i <= slotsBelow ? i : i - order.length; var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); - if (tok.get('type') === 'graphic') { - tok.set({ left: frameLeft, top: yPos, width: tokenSize, height: tokenSize, baseOpacity: visible ? 1 : 0, showname: visible }); - } else { - tok.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000, title: entry.custom || 'Custom' }); - } + pin.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000 }); + if (isCustom) pin.set('title', entry.custom || 'Custom'); if (txt) { txt.set({ @@ -5188,84 +5246,109 @@ var Gaslight = Gaslight || (() => { horizontalEscapePin = newX > frameLeftPin + fwPin / 2 || newX < frameLeftPin - fwPin / 2; } - // Pin dragged vertically — reorder custom turn in initiative + // Pin dragged vertically — reorder turn in initiative if (!horizontalEscapePin && newY !== oldY) { var order = JSON.parse(Campaign().get('turnorder') || '[]'); var hudOrder = getHudTurnOrder(); + var isCustomEntry = match.sourceId && match.sourceId.startsWith('custom:'); - // Find current index of this custom in the deduped order by matching text position - var txt = getObj('text', match.textId); + // Find current index in hudOrder var currentIdx = -1; - if (txt) { - var frame = getObj('pathv2', data.frameId); - if (!frame) return; - var frameTop = frame.get('y'); - var pts = JSON.parse(frame.get('points') || '[]'); - var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; - var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); - var tknSize = data.tokenSize || defaultInitHud.tokenSize; - var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; - currentIdx = Math.round((txt.get('top') - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + if (isCustomEntry) { + // For customs, find rank among customs and map to hudOrder position + var customsInData = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + var myCustomRank = customsInData.findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (myCustomRank !== -1) { + var cCount = 0; + for (var hi = 0; hi < hudOrder.length; hi++) { + if (!hudOrder[hi].id || hudOrder[hi].id === '-1') { + if (cCount === myCustomRank) { currentIdx = hi; break; } + cCount++; + } + } + } + } else { + currentIdx = hudOrder.findIndex(function(e) { return e.id === match.sourceId; }); } if (currentIdx < 0 || currentIdx >= hudOrder.length) { reflowInitiativeHud('none'); return; } - // Determine target slot based on new pin Y (subtract pin offset) - var frame = getObj('pathv2', data.frameId); - if (!frame) return; - var frameTop = frame.get('y'); - var pts = JSON.parse(frame.get('points') || '[]'); - var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; - var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); + // Determine target slot based on drag delta var tknSize = data.tokenSize || defaultInitHud.tokenSize; var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; - var slotY = newY - tknSize / 2; // undo pin offset - var targetIdx = Math.round((slotY - fTopEdge - tknSize / 2) / (tknSize + tknPad)); + var deltaSlots = Math.round((newY - oldY) / (tknSize + tknPad)); + var targetIdx = currentIdx + deltaSlots; + // Wrap around circular turn order + if (targetIdx < 0) targetIdx = hudOrder.length + targetIdx; + if (targetIdx >= hudOrder.length) targetIdx = targetIdx - hudOrder.length; targetIdx = Math.max(0, Math.min(hudOrder.length - 1, targetIdx)); if (targetIdx !== currentIdx) { - // Find the custom entry in the full order - var customEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); - var customIdx = 0; + // Find source entry in the full order var sourceFullIdx = -1; - for (var fi = 0; fi < order.length; fi++) { - if (!order[fi].id || order[fi].id === '-1') { - if (customIdx === (currentIdx - hudOrder.slice(0, currentIdx).filter(function(e) { return e.id && e.id !== '-1'; }).length)) { - sourceFullIdx = fi; - break; + if (isCustomEntry) { + // Find the Nth custom in full order (where N = this custom's rank) + var customsInData = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + var myRank = customsInData.findIndex(function(e) { return e.tokenId === obj.get('id'); }); + var cIdx = 0; + for (var fi = 0; fi < order.length; fi++) { + if (!order[fi].id || order[fi].id === '-1') { + if (cIdx === myRank) { sourceFullIdx = fi; break; } + cIdx++; } - customIdx++; } + } else { + sourceFullIdx = order.findIndex(function(e) { return e.id === match.sourceId; }); } if (sourceFullIdx === -1) { reflowInitiativeHud('none'); return; } - // Remove source from order - var removed = order.splice(sourceFullIdx, 1); + log(SCRIPT_NAME + ' [init-reorder] BEFORE: ' + JSON.stringify(order.map(function(e) { return e.custom || e.id; }))); + log(SCRIPT_NAME + ' [init-reorder] sourceId=' + match.sourceId + ' isCustom=' + isCustomEntry + ' currentIdx=' + currentIdx + ' targetIdx=' + targetIdx + ' sourceFullIdx=' + sourceFullIdx); + + // Remove source group from order (master + linked tokens) + var sourceGroupSize = 1; + if (!isCustomEntry) { + var sInfo = getLinkedInfo(match.sourceId); + sourceGroupSize = 1 + sInfo.linkedIds.filter(function(lid) { + return order.some(function(e) { return e.id === lid; }); + }).length; + } + var removed = order.splice(sourceFullIdx, sourceGroupSize); // Find target position in full order var targetHudEntry = hudOrder[targetIdx]; - var targetFullIdx; + var targetFullIdx = -1; if (!targetHudEntry.id || targetHudEntry.id === '-1') { - // Targeting another custom — find it in the full order - targetFullIdx = targetIdx > currentIdx ? sourceFullIdx : sourceFullIdx; + // Targeting a custom — find the Nth custom in the full order + var targetCustomRank = hudOrder.slice(0, targetIdx + 1).filter(function(e) { return !e.id || e.id === '-1'; }).length - 1; + var cCount = 0; + for (var fi = 0; fi < order.length; fi++) { + if (!order[fi].id || order[fi].id === '-1') { + if (cCount === targetCustomRank) { targetFullIdx = fi; break; } + cCount++; + } + } } else { targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); } if (targetFullIdx === -1) targetFullIdx = order.length; - // Insert + log(SCRIPT_NAME + ' [init-reorder] targetHudEntry=' + JSON.stringify(targetHudEntry) + ' targetFullIdx=' + targetFullIdx); + + // Insert: when moving down, place after target group; when moving up, place before target if (targetIdx > currentIdx) { var tInfo = getLinkedInfo(targetHudEntry.id || ''); var tGroupSize = (targetHudEntry.id && targetHudEntry.id !== '-1') ? 1 + tInfo.linkedIds.filter(function(lid) { return order.some(function(e) { return e.id === lid; }); }).length : 1; - order.splice(targetFullIdx + tGroupSize, 0, removed[0]); + order.splice.apply(order, [targetFullIdx + tGroupSize, 0].concat(removed)); } else { - order.splice(targetFullIdx, 0, removed[0]); + order.splice.apply(order, [targetFullIdx, 0].concat(removed)); } _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(order)); _suppressTurnSync = false; + log(SCRIPT_NAME + ' [init-reorder] AFTER: ' + JSON.stringify(order.map(function(e) { return e.custom || e.id; }))); } } @@ -5281,11 +5364,20 @@ var Gaslight = Gaslight || (() => { var swipeDir = newX > frameRightEdge ? 'forward' : 'backward'; var fullOrder = JSON.parse(Campaign().get('turnorder') || '[]'); - var customsInOrder = []; - fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); - var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); - if (customRank !== -1 && customsInOrder[customRank] !== undefined) { - var targetFullIdx = customsInOrder[customRank]; + // Find this entry's position in the full order + var targetFullIdx = -1; + if (match.sourceId && match.sourceId.startsWith('custom:')) { + var customsInOrder = []; + fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); + var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (customRank !== -1 && customsInOrder[customRank] !== undefined) { + targetFullIdx = customsInOrder[customRank]; + } + } else { + targetFullIdx = fullOrder.findIndex(function(e) { return e.id === match.sourceId; }); + } + + if (targetFullIdx !== -1) { if (targetFullIdx === 0) { // This pin is the current turn — advance/retreat to next/prev master or custom if (swipeDir === 'forward') { @@ -5314,7 +5406,7 @@ var Gaslight = Gaslight || (() => { fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); } } else { - // Non-current pin — rotate to it + // Non-current pin — rotate to it, applying formulas to customs passed along the way var rotCount = swipeDir === 'forward' ? targetFullIdx : fullOrder.length - targetFullIdx; for (var ri = 0; ri < rotCount; ri++) { if (swipeDir === 'forward') { @@ -5322,14 +5414,14 @@ var Gaslight = Gaslight || (() => { } else { fullOrder.unshift(fullOrder.pop()); } - } - // Apply formula if the new top is a custom turn - if (fullOrder[0] && fullOrder[0].id === '-1' && fullOrder[0].formula) { - var f = fullOrder[0].formula.trim(); - var v = parseFloat(f.replace(/^[+-]/, '')) || 0; - var add = f.startsWith('+'); - if (swipeDir === 'backward') add = !add; - fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + // Apply formula if we landed on a custom turn during rotation + if (fullOrder[0] && (!fullOrder[0].id || fullOrder[0].id === '-1') && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } } } } @@ -5339,6 +5431,22 @@ var Gaslight = Gaslight || (() => { } reflowInitiativeHud('none'); + + // Scale change — adjust all HUD pins to match and update tokenSize + var newScale = obj.get('scale'); + var oldScale = prev.scale; + if (newScale !== oldScale && newScale != null) { + newScale = Math.max(0.25, Math.min(2.0, newScale)); + obj.set('scale', newScale); + var baseTokenSize = defaultInitHud.tokenSize; + data.tokenSize = Math.round(baseTokenSize * newScale); + // Apply scale to all HUD entry pins + data.entries.forEach(function(e) { + var pin = getObj('pin', e.tokenId); + if (pin) pin.set('scale', newScale); + }); + reflowInitiativeHud('none'); + } }); // --- TriggerMap auto-rebuild --- From b068aa79be9e3ee6d406a4aaf6ee6961832cb8a9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sat, 18 Jul 2026 21:28:03 -0400 Subject: [PATCH 05/37] Remove debug logging --- Gaslight/Gaslight.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7c006be01..7b7d34196 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -5301,8 +5301,7 @@ var Gaslight = Gaslight || (() => { } if (sourceFullIdx === -1) { reflowInitiativeHud('none'); return; } - log(SCRIPT_NAME + ' [init-reorder] BEFORE: ' + JSON.stringify(order.map(function(e) { return e.custom || e.id; }))); - log(SCRIPT_NAME + ' [init-reorder] sourceId=' + match.sourceId + ' isCustom=' + isCustomEntry + ' currentIdx=' + currentIdx + ' targetIdx=' + targetIdx + ' sourceFullIdx=' + sourceFullIdx); + // Remove source group from order (master + linked tokens) var sourceGroupSize = 1; @@ -5332,7 +5331,7 @@ var Gaslight = Gaslight || (() => { } if (targetFullIdx === -1) targetFullIdx = order.length; - log(SCRIPT_NAME + ' [init-reorder] targetHudEntry=' + JSON.stringify(targetHudEntry) + ' targetFullIdx=' + targetFullIdx); + // Insert: when moving down, place after target group; when moving up, place before target if (targetIdx > currentIdx) { @@ -5348,7 +5347,6 @@ var Gaslight = Gaslight || (() => { _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(order)); _suppressTurnSync = false; - log(SCRIPT_NAME + ' [init-reorder] AFTER: ' + JSON.stringify(order.map(function(e) { return e.custom || e.id; }))); } } From f50218a71c9d2e98a66eb8a5c152311dc66fb970 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 19 Jul 2026 08:40:22 -0400 Subject: [PATCH 06/37] Gaslight: init command, turn reticle, isOnMasterPage helper, HUD reticle toggle/sync --- Gaslight/Gaslight.js | 230 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 4 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7b7d34196..7e3ee5ccf 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -89,8 +89,9 @@ var Gaslight = Gaslight || (() => { }; } if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; - if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: true, initiative: true }; + if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: true, initiative: true, reticle: true }; if (state[SCRIPT_NAME].hud.initiative === undefined) state[SCRIPT_NAME].hud.initiative = false; + if (state[SCRIPT_NAME].hud.reticle === undefined) state[SCRIPT_NAME].hud.reticle = true; // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; @@ -2289,6 +2290,14 @@ var Gaslight = Gaslight || (() => { evaluatePins([pin], fakeMsg, false); }); }); + + // Update turn indicator if the tracked token moved or resized + var s = state[SCRIPT_NAME]; + if (s.hud.initData && s.hud.initData.turnIndicatorTokenId === obj.get('id')) { + if (changed.indexOf('left') !== -1 || changed.indexOf('top') !== -1 || changed.indexOf('width') !== -1 || changed.indexOf('height') !== -1) { + updateTurnIndicator(); + } + } }; const onGmNotesChanged = (obj, prev) => { if (!prev || !prev.gmnotes) return; @@ -2950,6 +2959,7 @@ var Gaslight = Gaslight || (() => { case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; case 'hud': doHud(msg, args); break; + case 'init': doInit(msg, args); break; case 'eval': doEval(msg, args); break; case 'status': doStatus(msg); break; case '--script-lock': @@ -3849,13 +3859,21 @@ var Gaslight = Gaslight || (() => { /** * Determine if a token ID is a master token (on master page) in any active group. */ - const isMasterToken = (tokenId) => { + const isOnMasterPage = (tokenId) => { var s = state[SCRIPT_NAME]; var obj = getObj('graphic', tokenId); if (!obj) return false; var pageId = obj.get('_pageid'); return Object.values(s.activeGroups).some(function(active) { - return active.masterPageId === pageId && !!active.linkedTokens[tokenId]; + return active.masterPageId === pageId; + }); + }; + + const isMasterToken = (tokenId) => { + var s = state[SCRIPT_NAME]; + if (!isOnMasterPage(tokenId)) return false; + return Object.values(s.activeGroups).some(function(active) { + return !!active.linkedTokens[tokenId]; }); }; @@ -4103,6 +4121,92 @@ var Gaslight = Gaslight || (() => { return result; }; + /** + * !gaslight init [sync] [trim] + * sync (default): ensure all linked tokens are in initiative, grouped with masters first. + * trim: remove entries for tokens that no longer exist. + * Both can be combined: trim runs first, then sync. + */ + const doInit = (msg, args) => { + var s = state[SCRIPT_NAME]; + var doTrim = args.indexOf('trim') !== -1; + var doSync = args.indexOf('sync') !== -1 || args.length === 0 || (args.length === 1 && doTrim); + + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (order.length === 0) { reply(msg, 'Init', 'Turn order is empty.'); return; } + + var trimmed = 0; + var added = 0; + var warnings = []; + + // Trim: remove entries for tokens that no longer exist + if (doTrim) { + var before = order.length; + order = order.filter(function(e) { + if (!e.id || e.id === '-1') return true; + return !!getObj('graphic', e.id); + }); + trimmed = before - order.length; + } + + // Sync: add missing linked tokens, add missing masters, regroup + if (doSync) { + var existingIds = new Set(order.map(function(e) { return e.id; })); + var toAdd = []; + + order.forEach(function(entry) { + if (!entry.id || entry.id === '-1') return; + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) { + if (isOnMasterPage(entry.id)) { + var token = getObj('graphic', entry.id); + var name = token ? (token.get('name') || entry.id) : entry.id; + warnings.push(name + ' is in initiative but has no linked tokens (not staged?)'); + } + return; + } + + // Add master if missing + if (!isMasterToken(entry.id)) { + var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (masterId && !existingIds.has(masterId)) { + var masterObj = getObj('graphic', masterId); + toAdd.push({ id: masterId, pr: entry.pr, _pageid: masterObj ? masterObj.get('_pageid') : undefined }); + existingIds.add(masterId); + added++; + } + } + + // Add missing linked copies + info.linkedIds.forEach(function(linkedId) { + if (existingIds.has(linkedId)) return; + var linkedObj = getObj('graphic', linkedId); + if (!linkedObj) return; + toAdd.push({ id: linkedId, pr: entry.pr, _pageid: linkedObj.get('_pageid') }); + existingIds.add(linkedId); + added++; + }); + }); + + order = order.concat(toAdd); + order = reorderInitiative(order); + } + + _suppressTurnSync = true; + Campaign().set('turnorder', JSON.stringify(order)); + _suppressTurnSync = false; + + var parts = []; + if (doTrim) parts.push(trimmed + ' stale entry(s) trimmed'); + if (doSync) parts.push(added + ' entry(s) added'); + var out = parts.join(', ') + '.'; + if (warnings.length > 0) { + out += '

    ⚠️ ' + warnings.join('
    ⚠️ '); + } + reply(msg, 'Init', out); + if (s.hud.initiative) updateInitiativeHud(); + }; + // ========================================================================= // HUD System — on-canvas indicators // ========================================================================= @@ -4118,6 +4222,87 @@ var Gaslight = Gaslight || (() => { return group ? group.masterPageId : null; }; + /** + * Update or create the current-turn indicator (pathv2 rectangle around the current turn's token). + */ + const updateTurnIndicator = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.reticle) return; + if (!s.hud.initData) s.hud.initData = {}; + var data = s.hud.initData; + + var pageId = getHudPageId(); + if (!pageId) { removeTurnIndicator(); return; } + + // Find current turn master token + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (order.length === 0) { removeTurnIndicator(); return; } + + var topEntry = order[0]; + if (!topEntry.id || topEntry.id === '-1') { removeTurnIndicator(); return; } + + // Find the master token for this entry + var tokenId = topEntry.id; + if (!isMasterToken(tokenId)) { + var info = getLinkedInfo(tokenId); + var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (masterId) tokenId = masterId; + } + + var token = getObj('graphic', tokenId); + if (!token || token.get('_pageid') !== pageId) { removeTurnIndicator(); return; } + + var x = token.get('left'); + var y = token.get('top'); + var w = token.get('width'); + var h = token.get('height'); + var stroke = data.highlightStroke || defaultInitHud.highlightStroke; + var strokeWidth = data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth; + + // Create or update + var indicator = data.turnIndicatorId ? getObj('pathv2', data.turnIndicatorId) : null; + if (indicator) { + indicator.set({ + x: x, + y: y, + points: JSON.stringify([[0, 0], [w, h]]), + stroke: stroke, + stroke_width: strokeWidth, + }); + } else { + indicator = createObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: 'rec', + x: x, + y: y, + points: JSON.stringify([[0, 0], [w, h]]), + stroke: stroke, + stroke_width: strokeWidth, + fill: 'transparent', + }); + if (indicator) data.turnIndicatorId = indicator.get('id'); + } + + // Track which token we're following + data.turnIndicatorTokenId = tokenId; + }; + + /** + * Remove the current-turn indicator. + */ + const removeTurnIndicator = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initData) return; + var data = s.hud.initData; + if (data.turnIndicatorId) { + var indicator = getObj('pathv2', data.turnIndicatorId); + if (indicator) indicator.remove(); + delete data.turnIndicatorId; + } + delete data.turnIndicatorTokenId; + }; + /** * Find an existing HUD text object by stored ID. */ @@ -4448,6 +4633,7 @@ var Gaslight = Gaslight || (() => { }); reflowInitiativeHud(direction); + updateTurnIndicator(); }; /** @@ -4652,6 +4838,8 @@ var Gaslight = Gaslight || (() => { var data = s.hud.initData; if (!data) return; + removeTurnIndicator(); + var frameId = data.frameId; var highlightId = data.highlightId; var entries = data.entries.slice(); @@ -4852,6 +5040,12 @@ var Gaslight = Gaslight || (() => { } else if (obj.get('id') === s.hud.initData.highlightId) { // Highlight deleted — just clear ID, will be recreated on next update s.hud.initData.highlightId = null; + } else if (obj.get('id') === s.hud.initData.turnIndicatorId) { + // Turn indicator deleted — turn off reticle + s.hud.initData.turnIndicatorId = null; + s.hud.initData.turnIndicatorTokenId = null; + s.hud.reticle = false; + sendChat(SCRIPT_NAME, '/w gm HUD: Turn reticle disabled. Use !gaslight hud reticle on to re-enable.'); } }; @@ -4889,10 +5083,12 @@ var Gaslight = Gaslight || (() => { removeInitiativeHud(); s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); updateInitiativeHud(); + updateTurnIndicator(); } else { hudElements.forEach(function(el) { if (el === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); } if (el === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); } + if (el === 'reticle') { if (s.hud.reticle) updateTurnIndicator(); else removeTurnIndicator(); } }); } reply(msg, 'HUD', hudElements.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
    ')); @@ -4903,7 +5099,7 @@ var Gaslight = Gaslight || (() => { var toggle = args[1] ? args[1].toLowerCase() : null; // Aliases - var elementAliases = { init: 'initiative', turn: 'initiative', turns: 'initiative', relay: 'view' }; + var elementAliases = { init: 'initiative', turn: 'initiative', turns: 'initiative', relay: 'view', indicator: 'reticle' }; if (elementAliases[element]) element = elementAliases[element]; if (toggle && elementAliases[toggle]) toggle = elementAliases[toggle]; @@ -4951,6 +5147,9 @@ var Gaslight = Gaslight || (() => { removeInitiativeHud(); s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); updateInitiativeHud(); + } else if (element === 'reticle') { + removeTurnIndicator(); + updateTurnIndicator(); } reply(msg, 'HUD', '' + element + ' reset to default position.'); return; @@ -4966,6 +5165,9 @@ var Gaslight = Gaslight || (() => { } else if (element === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); + } else if (element === 'reticle') { + if (s.hud.reticle) updateTurnIndicator(); + else removeTurnIndicator(); } reply(msg, 'HUD', '' + element + ' is now ' + (s.hud[element] ? 'on' : 'off')); @@ -5045,6 +5247,12 @@ var Gaslight = Gaslight || (() => { if (hlStroke !== undefined) data.highlightStroke = hlStroke; if (hlFill !== undefined) data.highlightFill = hlFill; if (hlStrokeWidth !== undefined) data.highlightStrokeWidth = hlStrokeWidth; + // Sync to turn indicator + var ti = data.turnIndicatorId ? getObj('pathv2', data.turnIndicatorId) : null; + if (ti) { + if (hlStroke !== undefined) ti.set('stroke', hlStroke); + if (hlStrokeWidth !== undefined) ti.set('stroke_width', hlStrokeWidth); + } // Compute normalized Y offset (0=top+tokenSize/2+vPadding, 1=bottom-tokenSize/2-vPadding) var frame = getObj('pathv2', data.frameId); if (frame) { @@ -5060,6 +5268,19 @@ var Gaslight = Gaslight || (() => { data.currentTurnOffset = Math.max(0, Math.min(1, norm)); } reflowInitiativeHud('none'); + } else if (obj.get('id') === s.hud.initData.turnIndicatorId) { + var data = s.hud.initData; + // Sync styling between turn indicator and HUD highlight + var tiStroke = obj.get('stroke'); + var tiStrokeWidth = obj.get('stroke_width'); + if (tiStroke !== undefined) data.highlightStroke = tiStroke; + if (tiStrokeWidth !== undefined) data.highlightStrokeWidth = tiStrokeWidth; + // Update the HUD highlight to match + var hl = data.highlightId ? getObj('pathv2', data.highlightId) : null; + if (hl) { + if (tiStroke !== undefined) hl.set('stroke', tiStroke); + if (tiStrokeWidth !== undefined) hl.set('stroke_width', tiStrokeWidth); + } } }); on('change:graphic', function(obj, prev) { @@ -5429,6 +5650,7 @@ var Gaslight = Gaslight || (() => { } reflowInitiativeHud('none'); + updateTurnIndicator(); // Scale change — adjust all HUD pins to match and update tokenSize var newScale = obj.get('scale'); From 3d4fceb2b8ece06369cc59baecc50aba14564b89 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 19 Jul 2026 09:50:45 -0400 Subject: [PATCH 07/37] Gaslight: HUD registry refactor, reticle inheritance model, rotation/size/offset tracking, remove dead token-based HUD code --- Gaslight/Gaslight.js | 490 ++++++++++++++++++------------------------- 1 file changed, 201 insertions(+), 289 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7e3ee5ccf..6bfd44ad4 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2293,9 +2293,9 @@ var Gaslight = Gaslight || (() => { // Update turn indicator if the tracked token moved or resized var s = state[SCRIPT_NAME]; - if (s.hud.initData && s.hud.initData.turnIndicatorTokenId === obj.get('id')) { + if (s.hud.initData && s.hud.reticleData && s.hud.reticleData.tokenId === obj.get('id')) { if (changed.indexOf('left') !== -1 || changed.indexOf('top') !== -1 || changed.indexOf('width') !== -1 || changed.indexOf('height') !== -1) { - updateTurnIndicator(); + updateTurnReticle(); } } }; @@ -4225,21 +4225,23 @@ var Gaslight = Gaslight || (() => { /** * Update or create the current-turn indicator (pathv2 rectangle around the current turn's token). */ - const updateTurnIndicator = () => { + const updateTurnReticle = () => { var s = state[SCRIPT_NAME]; if (!s.hud.initiative || !s.hud.reticle) return; if (!s.hud.initData) s.hud.initData = {}; + if (!s.hud.reticleData) s.hud.reticleData = {}; var data = s.hud.initData; + var rd = s.hud.reticleData; var pageId = getHudPageId(); - if (!pageId) { removeTurnIndicator(); return; } + if (!pageId) { removeTurnReticle(); return; } // Find current turn master token var order = JSON.parse(Campaign().get('turnorder') || '[]'); - if (order.length === 0) { removeTurnIndicator(); return; } + if (order.length === 0) { removeTurnReticle(); return; } var topEntry = order[0]; - if (!topEntry.id || topEntry.id === '-1') { removeTurnIndicator(); return; } + if (!topEntry.id || topEntry.id === '-1') { removeTurnReticle(); return; } // Find the master token for this entry var tokenId = topEntry.id; @@ -4250,27 +4252,32 @@ var Gaslight = Gaslight || (() => { } var token = getObj('graphic', tokenId); - if (!token || token.get('_pageid') !== pageId) { removeTurnIndicator(); return; } - - var x = token.get('left'); - var y = token.get('top'); - var w = token.get('width'); - var h = token.get('height'); - var stroke = data.highlightStroke || defaultInitHud.highlightStroke; - var strokeWidth = data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth; + if (!token || token.get('_pageid') !== pageId) { removeTurnReticle(); return; } + + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var x = token.get('left') + (rd.xOffset || 0) * tokenW; + var y = token.get('top') + (rd.yOffset || 0) * tokenH; + var w = tokenW * (rd.widthScale || 1); + var h = tokenH * (rd.heightScale || 1); + var stroke = rd.stroke || data.highlightStroke || defaultInitHud.highlightStroke; + var strokeWidth = rd.strokeWidth || data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth; + var hlRotation = data.highlightRotation != null ? data.highlightRotation : 45; + var reticleRotation = hlRotation + (rd.rotationOffset != null ? rd.rotationOffset : -hlRotation); // Create or update - var indicator = data.turnIndicatorId ? getObj('pathv2', data.turnIndicatorId) : null; - if (indicator) { - indicator.set({ + var reticle = rd.id ? getObj('pathv2', rd.id) : null; + if (reticle) { + reticle.set({ x: x, y: y, points: JSON.stringify([[0, 0], [w, h]]), stroke: stroke, stroke_width: strokeWidth, + rotation: reticleRotation, }); } else { - indicator = createObj('pathv2', { + reticle = createObj('pathv2', { _pageid: pageId, layer: 'foreground', shape: 'rec', @@ -4280,27 +4287,28 @@ var Gaslight = Gaslight || (() => { stroke: stroke, stroke_width: strokeWidth, fill: 'transparent', + rotation: reticleRotation, }); - if (indicator) data.turnIndicatorId = indicator.get('id'); + if (reticle) rd.id = reticle.get('id'); } // Track which token we're following - data.turnIndicatorTokenId = tokenId; + rd.tokenId = tokenId; }; /** * Remove the current-turn indicator. */ - const removeTurnIndicator = () => { + const removeTurnReticle = () => { var s = state[SCRIPT_NAME]; - if (!s.hud.initData) return; - var data = s.hud.initData; - if (data.turnIndicatorId) { - var indicator = getObj('pathv2', data.turnIndicatorId); - if (indicator) indicator.remove(); - delete data.turnIndicatorId; + if (!s.hud.reticleData) return; + var rd = s.hud.reticleData; + if (rd.id) { + var reticle = getObj('pathv2', rd.id); + delete rd.id; + if (reticle) reticle.remove(); } - delete data.turnIndicatorTokenId; + delete rd.tokenId; }; /** @@ -4515,7 +4523,7 @@ var Gaslight = Gaslight || (() => { stroke: data.highlightStroke || defaultInitHud.highlightStroke, stroke_width: data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth, fill: data.highlightFill || defaultInitHud.highlightFill, - rotation: 45, + rotation: data.highlightRotation != null ? data.highlightRotation : 45, }); data.highlightId = highlight.get('id'); } @@ -4633,7 +4641,7 @@ var Gaslight = Gaslight || (() => { }); reflowInitiativeHud(direction); - updateTurnIndicator(); + updateTurnReticle(); }; /** @@ -4838,8 +4846,6 @@ var Gaslight = Gaslight || (() => { var data = s.hud.initData; if (!data) return; - removeTurnIndicator(); - var frameId = data.frameId; var highlightId = data.highlightId; var entries = data.entries.slice(); @@ -4857,9 +4863,9 @@ var Gaslight = Gaslight || (() => { if (hl) hl.remove(); } entries.forEach(function(entry) { - var tok = getObj('graphic', entry.tokenId) || getObj('pin', entry.tokenId); + var pin = getObj('pin', entry.tokenId); var txt = getObj('text', entry.textId); - if (tok) { if (typeof Mirror !== 'undefined' && entry.sourceId && !entry.sourceId.startsWith('custom:')) Mirror.unlink([tok.get('id')]); tok.remove(); } + if (pin) pin.remove(); if (txt) txt.remove(); }); }; @@ -4962,9 +4968,9 @@ var Gaslight = Gaslight || (() => { var matchIdx = data.entries.findIndex(function(e) { return e.textId === id; }); if (matchIdx !== -1) { var match = data.entries[matchIdx]; - // Remove associated token/pin - var tok = getObj('graphic', match.tokenId) || getObj('pin', match.tokenId); - if (tok) tok.remove(); + // Remove associated pin + var pin = getObj('pin', match.tokenId); + if (pin) pin.remove(); // Remove from entries data.entries.splice(matchIdx, 1); @@ -4989,7 +4995,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle destroy:graphic — if an initiative HUD token is deleted, turn off. + * Handle destroy:pin — if an initiative HUD pin is deleted, remove from turn order. */ const onHudGraphicDestroyed = (obj) => { var s = state[SCRIPT_NAME]; @@ -5040,10 +5046,10 @@ var Gaslight = Gaslight || (() => { } else if (obj.get('id') === s.hud.initData.highlightId) { // Highlight deleted — just clear ID, will be recreated on next update s.hud.initData.highlightId = null; - } else if (obj.get('id') === s.hud.initData.turnIndicatorId) { + } else if (s.hud.reticleData && obj.get('id') === s.hud.reticleData.id) { // Turn indicator deleted — turn off reticle - s.hud.initData.turnIndicatorId = null; - s.hud.initData.turnIndicatorTokenId = null; + s.hud.reticleData.id = null; + s.hud.reticleData.tokenId = null; s.hud.reticle = false; sendChat(SCRIPT_NAME, '/w gm HUD: Turn reticle disabled. Use !gaslight hud reticle on to re-enable.'); } @@ -5052,122 +5058,136 @@ var Gaslight = Gaslight || (() => { /** * Handle !gaslight hud command. */ + const hudRegistry = { + view: { + aliases: ['relay'], + enable: function() { updateViewHud(); }, + disable: function() { removeViewHud(); }, + reset: function() { + var s = state[SCRIPT_NAME]; + s.hud.viewData = {}; + var existing = findHudElement('view'); + if (existing) { + var pageId = getHudPageId(); + var page = pageId ? getObj('page', pageId) : null; + var pageWidth = page ? page.get('width') * 70 : 1400; + existing.set({ + left: Math.round(defaultViewHud.leftNorm * pageWidth), + top: defaultViewHud.top, + font_size: defaultViewHud.fontSize, + font_family: defaultViewHud.fontFamily, + color: defaultViewHud.color, + stroke: defaultViewHud.stroke, + }); + } else { + updateViewHud(); + } + }, + }, + initiative: { + aliases: ['init', 'turn', 'turns'], + enable: function() { updateInitiativeHud(); }, + disable: function() { removeInitiativeHud(); }, + reset: function() { + var s = state[SCRIPT_NAME]; + removeInitiativeHud(); + s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); + updateInitiativeHud(); + }, + }, + reticle: { + aliases: ['indicator', 'current'], + enable: function() { updateTurnReticle(); }, + disable: function() { removeTurnReticle(); }, + reset: function() { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData || {}; + var hlRot = data.highlightRotation != null ? data.highlightRotation : 45; + removeTurnReticle(); + s.hud.reticleData = { rotationOffset: -hlRot }; + updateTurnReticle(); + }, + }, + }; + + const resolveHudElement = (name) => { + if (hudRegistry[name]) return name; + for (var key in hudRegistry) { + if (hudRegistry[key].aliases && hudRegistry[key].aliases.indexOf(name) !== -1) return key; + } + return null; + }; + const doHud = (msg, args) => { var s = state[SCRIPT_NAME]; - var hudElements = Object.keys(s.hud).filter(function(k) { return typeof s.hud[k] === 'boolean'; }); + var elementNames = Object.keys(hudRegistry); var toggleWords = new Set(['on', 'off', 'reset']); if (args.length === 0) { // Toggle all elements - hudElements.forEach(function(el) { s.hud[el] = !s.hud[el]; }); - hudElements.forEach(function(el) { - if (el === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); } - if (el === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); } + elementNames.forEach(function(el) { + s.hud[el] = !s.hud[el]; + if (s.hud[el]) hudRegistry[el].enable(); + else hudRegistry[el].disable(); }); - reply(msg, 'HUD', hudElements.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
    ')); + reply(msg, 'HUD', elementNames.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
    ')); return; } // Single arg that's a toggle word — apply to all elements if (args.length === 1 && toggleWords.has(args[0].toLowerCase())) { var toggle = args[0].toLowerCase(); - hudElements.forEach(function(el) { - if (toggle === 'on') s.hud[el] = true; - else if (toggle === 'off') s.hud[el] = false; - }); if (toggle === 'reset') { - hudElements.forEach(function(el) { s.hud[el] = true; }); - s.hud.viewData = {}; - removeViewHud(); - updateViewHud(); - removeInitiativeHud(); - s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); - updateInitiativeHud(); - updateTurnIndicator(); + elementNames.forEach(function(el) { + s.hud[el] = true; + hudRegistry[el].reset(); + }); } else { - hudElements.forEach(function(el) { - if (el === 'view') { if (s.hud.view) updateViewHud(); else removeViewHud(); } - if (el === 'initiative') { if (s.hud.initiative) updateInitiativeHud(); else removeInitiativeHud(); } - if (el === 'reticle') { if (s.hud.reticle) updateTurnIndicator(); else removeTurnIndicator(); } + elementNames.forEach(function(el) { + s.hud[el] = (toggle === 'on'); + if (s.hud[el]) hudRegistry[el].enable(); + else hudRegistry[el].disable(); }); } - reply(msg, 'HUD', hudElements.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
    ')); + reply(msg, 'HUD', elementNames.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
    ')); return; } var element = args[0].toLowerCase(); var toggle = args[1] ? args[1].toLowerCase() : null; - // Aliases - var elementAliases = { init: 'initiative', turn: 'initiative', turns: 'initiative', relay: 'view', indicator: 'reticle' }; - if (elementAliases[element]) element = elementAliases[element]; - if (toggle && elementAliases[toggle]) toggle = elementAliases[toggle]; + // Resolve aliases + element = resolveHudElement(element) || element; + if (toggle) toggle = resolveHudElement(toggle) || toggle; // Allow toggle before or after element: "hud reset initiative" or "hud initiative reset" - if (toggleWords.has(element) && toggle && s.hud[toggle] !== undefined) { + if (toggleWords.has(element) && toggle && hudRegistry[toggle]) { var tmp = element; element = toggle; toggle = tmp; } - // Aliases (apply again after swap) - if (elementAliases[element]) element = elementAliases[element]; - - if (s.hud[element] === undefined) { - reply(msg, 'Error', 'Unknown HUD element: ' + element + '. Available: ' + Object.keys(s.hud).filter(function(k) { return typeof s.hud[k] === 'boolean'; }).join(', ')); + if (!hudRegistry[element]) { + reply(msg, 'Error', 'Unknown HUD element: ' + element + '. Available: ' + elementNames.join(', ')); return; } if (toggle === 'on') { s.hud[element] = true; + hudRegistry[element].enable(); } else if (toggle === 'off') { s.hud[element] = false; + hudRegistry[element].disable(); } else if (toggle === 'reset') { - // Clear stored position, turn on, move to defaults s.hud[element] = true; - if (element === 'view') { - s.hud.viewData = {}; - var existing = findHudElement('view'); - if (existing) { - var pageId = getHudPageId(); - var page = pageId ? getObj('page', pageId) : null; - var pageWidth = page ? page.get('width') * 70 : 1400; - existing.set({ - left: Math.round(defaultViewHud.leftNorm * pageWidth), - top: defaultViewHud.top, - font_size: defaultViewHud.fontSize, - font_family: defaultViewHud.fontFamily, - color: defaultViewHud.color, - stroke: defaultViewHud.stroke, - }); - } else { - updateViewHud(); - } - } else if (element === 'initiative') { - removeInitiativeHud(); - s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); - updateInitiativeHud(); - } else if (element === 'reticle') { - removeTurnIndicator(); - updateTurnIndicator(); - } - reply(msg, 'HUD', '' + element + ' reset to default position.'); + hudRegistry[element].reset(); + reply(msg, 'HUD', '' + element + ' reset to defaults.'); return; } else { // Toggle s.hud[element] = !s.hud[element]; - } - - // Apply - if (element === 'view') { - if (s.hud.view) updateViewHud(); - else removeViewHud(); - } else if (element === 'initiative') { - if (s.hud.initiative) updateInitiativeHud(); - else removeInitiativeHud(); - } else if (element === 'reticle') { - if (s.hud.reticle) updateTurnIndicator(); - else removeTurnIndicator(); + if (s.hud[element]) hudRegistry[element].enable(); + else hudRegistry[element].disable(); } reply(msg, 'HUD', '' + element + ' is now ' + (s.hud[element] ? 'on' : 'off')); @@ -5191,9 +5211,9 @@ var Gaslight = Gaslight || (() => { on('change:campaign:turnorder', onTurnOrderChanged); on('change:text', onHudTextChanged); on('destroy:text', onHudTextDestroyed); - on('destroy:graphic', function(obj) { onTokenDestroyed(obj); onHudGraphicDestroyed(obj); }); + on('destroy:graphic', function(obj) { onTokenDestroyed(obj); }); on('destroy:pathv2', onHudPathDestroyed); - on('change:pathv2', function(obj) { + on('change:pathv2', function(obj, prev) { var s = state[SCRIPT_NAME]; if (!s.hud.initiative || !s.hud.initData) return; if (obj.get('id') === s.hud.initData.frameId) { @@ -5240,18 +5260,25 @@ var Gaslight = Gaslight || (() => { reflowInitiativeHud('none'); } else if (obj.get('id') === s.hud.initData.highlightId) { var data = s.hud.initData; - // Track highlight styling + // Track highlight styling (parent/source of truth) var hlStroke = obj.get('stroke'); var hlFill = obj.get('fill'); var hlStrokeWidth = obj.get('stroke_width'); if (hlStroke !== undefined) data.highlightStroke = hlStroke; if (hlFill !== undefined) data.highlightFill = hlFill; if (hlStrokeWidth !== undefined) data.highlightStrokeWidth = hlStrokeWidth; - // Sync to turn indicator - var ti = data.turnIndicatorId ? getObj('pathv2', data.turnIndicatorId) : null; + var hlRotation = obj.get('rotation'); + if (hlRotation !== undefined) data.highlightRotation = hlRotation; + // Cascade to reticle if not overridden + var rd = s.hud.reticleData || {}; + var ti = rd.id ? getObj('pathv2', rd.id) : null; if (ti) { - if (hlStroke !== undefined) ti.set('stroke', hlStroke); - if (hlStrokeWidth !== undefined) ti.set('stroke_width', hlStrokeWidth); + if (hlStroke !== undefined && !rd.stroke) ti.set('stroke', hlStroke); + if (hlStrokeWidth !== undefined && !rd.strokeWidth) ti.set('stroke_width', hlStrokeWidth); + if (hlRotation !== undefined) { + var reticleOffset = rd.rotationOffset != null ? rd.rotationOffset : -(data.highlightRotation != null ? data.highlightRotation : 45); + ti.set('rotation', hlRotation + reticleOffset); + } } // Compute normalized Y offset (0=top+tokenSize/2+vPadding, 1=bottom-tokenSize/2-vPadding) var frame = getObj('pathv2', data.frameId); @@ -5268,180 +5295,66 @@ var Gaslight = Gaslight || (() => { data.currentTurnOffset = Math.max(0, Math.min(1, norm)); } reflowInitiativeHud('none'); - } else if (obj.get('id') === s.hud.initData.turnIndicatorId) { - var data = s.hud.initData; - // Sync styling between turn indicator and HUD highlight + } else if (s.hud.reticleData && obj.get('id') === s.hud.reticleData.id) { + var rd = s.hud.reticleData; + var data = s.hud.initData || {}; + // Check if reticle properties are overrides or match the highlight (inherit) var tiStroke = obj.get('stroke'); var tiStrokeWidth = obj.get('stroke_width'); - if (tiStroke !== undefined) data.highlightStroke = tiStroke; - if (tiStrokeWidth !== undefined) data.highlightStrokeWidth = tiStrokeWidth; - // Update the HUD highlight to match - var hl = data.highlightId ? getObj('pathv2', data.highlightId) : null; - if (hl) { - if (tiStroke !== undefined) hl.set('stroke', tiStroke); - if (tiStrokeWidth !== undefined) hl.set('stroke_width', tiStrokeWidth); + var tiRotation = obj.get('rotation'); + if (tiStroke !== undefined) { + if (tiStroke === (data.highlightStroke || defaultInitHud.highlightStroke)) { rd.stroke = null; } + else { rd.stroke = tiStroke; } } - } - }); - on('change:graphic', function(obj, prev) { - var s = state[SCRIPT_NAME]; - if (!s.hud.initiative || !s.hud.initData) return; - var data = s.hud.initData; - // Check if this is a HUD token that was resized - var match = data.entries.find(function(e) { return e.tokenId === obj.get('id'); }); - if (!match || match.sourceId.startsWith('custom:')) return; - var newSize = obj.get('width'); - var oldSize = prev.width; - if (newSize !== oldSize && newSize > 0) { - // Token resized — update token size, resize frame to fit - data.tokenSize = newSize; - var hPad = data.hPadding || defaultInitHud.hPadding; - var newFrameWidth = newSize + 2 * hPad; - // Update frame - var frame = getObj('pathv2', data.frameId); - if (frame) { - var points = JSON.parse(frame.get('points') || '[]'); - var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; - frame.set('points', JSON.stringify([[0, 0], [newFrameWidth, frameHeight]])); - data.frameSize = { width: newFrameWidth, height: frameHeight }; + if (tiStrokeWidth !== undefined) { + if (tiStrokeWidth === (data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth)) { rd.strokeWidth = null; } + else { rd.strokeWidth = tiStrokeWidth; } } - reflowInitiativeHud('none'); - } else { - // Token dragged vertically — reorder initiative - var newTop = obj.get('top'); - var oldTop = prev.top; - var newLeft = obj.get('left'); - var oldLeft = prev.left; - var frame = getObj('pathv2', data.frameId); - var frameLeft = frame ? frame.get('x') : 0; - var pts = frame ? JSON.parse(frame.get('points') || '[]') : []; - var fw = pts.length >= 2 ? pts[1][0] - pts[0][0] : 70; - var frameRightEdge = frameLeft + fw / 2; - var frameLeftEdge = frameLeft - fw / 2; - var horizontalEscape = newLeft > frameRightEdge || newLeft < frameLeftEdge; - - if (newTop !== oldTop && !horizontalEscape) { - var order = JSON.parse(Campaign().get('turnorder') || '[]'); - var sourceId = match.sourceId; - var hudOrder = getHudTurnOrder(); - var currentIdx = hudOrder.findIndex(function(e) { return e.id === sourceId; }); - if (currentIdx === -1) { reflowInitiativeHud('none'); return; } - - // Determine target slot based on new Y position - var frame = getObj('pathv2', data.frameId); - if (!frame) return; - var frameTop = frame.get('y'); - var pts = JSON.parse(frame.get('points') || '[]'); - var fHeight = pts.length >= 2 ? pts[1][1] - pts[0][1] : 510; - var fTopEdge = frameTop - fHeight / 2 + (data.vPadding || defaultInitHud.vPadding); - var tknSize = data.tokenSize || defaultInitHud.tokenSize; - var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; - var targetIdx = Math.round((newTop - fTopEdge - tknSize / 2) / (tknSize + tknPad)); - targetIdx = Math.max(0, Math.min(hudOrder.length - 1, targetIdx)); - - if (targetIdx !== currentIdx) { - var sourceEntry = order.find(function(e) { return e.id === sourceId; }); - if (!sourceEntry) { reflowInitiativeHud('none'); return; } - - var targetHudEntry = hudOrder[targetIdx]; - var targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); - if (targetFullIdx === -1) { reflowInitiativeHud('none'); return; } - - // Remove source (and its linked children) from order - var info = getLinkedInfo(sourceId); - var groupIds = new Set([sourceId].concat(info.linkedIds)); - var removed = []; - order = order.filter(function(e) { - if (groupIds.has(e.id)) { removed.push(e); return false; } - return true; - }); - - // Recalculate target index after removal - targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); - if (targetFullIdx === -1) targetFullIdx = order.length; - - // Insert: if moving down, insert after target group; if up, insert before - if (targetIdx > currentIdx) { - var tInfo = getLinkedInfo(targetHudEntry.id); - var tGroupSize = 1 + tInfo.linkedIds.filter(function(lid) { - return order.some(function(e) { return e.id === lid; }); - }).length; - var insertIdx = targetFullIdx + tGroupSize; - removed.forEach(function(e, i) { order.splice(insertIdx + i, 0, e); }); - } else { - removed.forEach(function(e, i) { order.splice(targetFullIdx + i, 0, e); }); - } - - _suppressTurnSync = true; - Campaign().set('turnorder', JSON.stringify(order)); - _suppressTurnSync = false; + if (tiRotation !== undefined) { + // Store as offset from highlight rotation + var hlRot = data.highlightRotation != null ? data.highlightRotation : 45; + rd.rotationOffset = tiRotation - hlRot; + } + // Track proportional size relative to tracked token + var tiPoints = JSON.parse(obj.get('points') || '[]'); + var prevPoints = prev && prev.points ? JSON.parse(prev.points) : tiPoints; + var wasResized = JSON.stringify(tiPoints) !== JSON.stringify(prevPoints); + if (wasResized && tiPoints.length >= 2 && rd.tokenId) { + var token = getObj('graphic', rd.tokenId); + if (token) { + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var reticleW = tiPoints[1][0] - tiPoints[0][0]; + var reticleH = tiPoints[1][1] - tiPoints[0][1]; + if (tokenW > 0) rd.widthScale = reticleW / tokenW; + if (tokenH > 0) rd.heightScale = reticleH / tokenH; + // Snap back to stored offset position after resize + var targetX = token.get('left') + (rd.xOffset || 0) * tokenW; + var targetY = token.get('top') + (rd.yOffset || 0) * tokenH; + obj.set({ x: targetX, y: targetY }); } - reflowInitiativeHud('none'); } - - // Token dragged horizontally — make it this token's turn - var verticalDrift = Math.abs(newTop - oldTop); - var tknSizeH = data.tokenSize || defaultInitHud.tokenSize; - if (newLeft !== oldLeft && verticalDrift < tknSizeH / 2 && horizontalEscape) { - var order = JSON.parse(Campaign().get('turnorder') || '[]'); - var sourceId = match.sourceId; - var swipeDirection = newLeft > frameRightEdge ? 'forward' : 'backward'; - var isCurrentTurn = order.length > 0 && order[0].id === sourceId; - - if (isCurrentTurn) { - // Current turn swiped — advance/retreat to next/prev master or custom - if (swipeDirection === 'forward') { - order.push(order.shift()); - // Skip past linked children - var skip = order.length; - while (skip-- > 0 && order[0] && order[0].id && order[0].id !== '-1') { - var cInfo = getLinkedInfo(order[0].id); - if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; - order.push(order.shift()); - } - } else { - order.unshift(order.pop()); - // Skip backward past linked children - var skip = order.length; - while (skip-- > 0 && order[0] && order[0].id && order[0].id !== '-1') { - var cInfo = getLinkedInfo(order[0].id); - if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; - order.unshift(order.pop()); - } - } - // Apply formula if new top is a custom - if (order[0] && order[0].id === '-1' && order[0].formula) { - var f = order[0].formula.trim(); - var v = parseFloat(f.replace(/^[+-]/, '')) || 0; - var add = f.startsWith('+'); - if (swipeDirection === 'backward') add = !add; - order[0].pr = (parseFloat(order[0].pr) || 0) + (add ? v : -v); - } - } else { - // Non-current turn — rotate until target is at position 0 - var safety = order.length; - while (safety-- > 0 && order.length > 0 && order[0].id !== sourceId) { - if (swipeDirection === 'forward') { - order.push(order.shift()); - } else { - order.unshift(order.pop()); - } - } - // Apply formula if the new top is a custom turn - if (order[0] && order[0].id === '-1' && order[0].formula) { - var f = order[0].formula.trim(); - var v = parseFloat(f.replace(/^[+-]/, '')) || 0; - var add = f.startsWith('+'); - if (swipeDirection === 'backward') add = !add; - order[0].pr = (parseFloat(order[0].pr) || 0) + (add ? v : -v); + // Track proportional position offset from token center (move without resize) + if (!wasResized && rd.tokenId) { + var tiX = obj.get('x'); + var tiY = obj.get('y'); + var token = getObj('graphic', rd.tokenId); + if (token) { + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var dx = tiX - token.get('left'); + var dy = tiY - token.get('top'); + var deadzone = 3; // pixels + var maxThreshold = Math.max(tokenW, tokenH) * 1.5; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist < deadzone) { + rd.xOffset = null; + rd.yOffset = null; + } else if (dist <= maxThreshold) { + rd.xOffset = tokenW > 0 ? dx / tokenW : 0; + rd.yOffset = tokenH > 0 ? dy / tokenH : 0; } } - _suppressTurnSync = true; - Campaign().set('turnorder', JSON.stringify(order)); - _suppressTurnSync = false; - reflowInitiativeHud('none'); - } else if (!horizontalEscape) { - reflowInitiativeHud('none'); } } }); @@ -5650,7 +5563,7 @@ var Gaslight = Gaslight || (() => { } reflowInitiativeHud('none'); - updateTurnIndicator(); + updateTurnReticle(); // Scale change — adjust all HUD pins to match and update tokenSize var newScale = obj.get('scale'); @@ -5739,4 +5652,3 @@ on('ready', () => { Gaslight.checkInstall(); Gaslight.registerEventHandlers(); }); - From 498f0f8ff3c93194f1aafe5eef2bc946f4b67015 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 19 Jul 2026 10:40:26 -0400 Subject: [PATCH 08/37] Gaslight: HUD registry event routing, extracted handlers, text rotation tracking, textVOffset --- Gaslight/Gaslight.js | 401 +++++++++++++++++++++++++++---------------- 1 file changed, 253 insertions(+), 148 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 6bfd44ad4..b4b344ee8 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4368,6 +4368,7 @@ var Gaslight = Gaslight || (() => { color: vd.color || defaultViewHud.color, stroke: vd.stroke || defaultViewHud.stroke, font_family: vd.fontFamily || defaultViewHud.fontFamily, + rotation: vd.rotation || 0, }); s.hud.viewId = obj.get('id'); } @@ -4594,6 +4595,7 @@ var Gaslight = Gaslight || (() => { color: data.textColor || defaultInitHud.textColor, stroke: data.textStroke || defaultInitHud.textStroke, font_family: data.textFontFamily || defaultInitHud.textFontFamily, + rotation: data.textRotation || 0, }); data.entries.push({ @@ -4630,6 +4632,7 @@ var Gaslight = Gaslight || (() => { color: data.textColor || defaultInitHud.textColor, stroke: data.textStroke || defaultInitHud.textStroke, font_family: data.textFontFamily || defaultInitHud.textFontFamily, + rotation: data.textRotation || 0, }); data.entries.push({ @@ -4771,7 +4774,7 @@ var Gaslight = Gaslight || (() => { if (txt) { txt.set({ left: frameLeft + frameWidth / 2 + (data.textOffset || 15), - top: yPos, + top: yPos + (data.textVOffset || 0), color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', text: String(entry.pr || ''), }); @@ -4829,7 +4832,7 @@ var Gaslight = Gaslight || (() => { if (txt) { txt.set({ left: frameLeft + frameWidth / 2 + (data.textOffset || 15), - top: yPos, + top: yPos + (data.textVOffset || 0), color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', text: String(entry.pr || ''), }); @@ -4889,6 +4892,8 @@ var Gaslight = Gaslight || (() => { if (vColor) s.hud.viewData.color = vColor; var vStroke = obj.get('stroke'); if (vStroke) s.hud.viewData.stroke = vStroke; + var vRotation = obj.get('rotation'); + if (vRotation !== undefined) s.hud.viewData.rotation = vRotation; } // Initiative HUD text moved or styled — update stored settings if (s.hud.initiative && s.hud.initData && s.hud.initData.entries) { @@ -4947,6 +4952,44 @@ var Gaslight = Gaslight || (() => { if (otherTxt) otherTxt.set('stroke', newStroke); }); } + // Rotation propagation + var newRotation = obj.get('rotation'); + if (newRotation !== undefined && newRotation !== data.textRotation) { + data.textRotation = newRotation; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('rotation', newRotation); + }); + } + // Vertical offset from slot position + var newTop = obj.get('top'); + var tokenSize = data.tokenSize || defaultInitHud.tokenSize; + var tokenPadding = data.tokenPadding || defaultInitHud.tokenPadding; + var frame = getObj('pathv2', data.frameId); + if (frame) { + var frameTop = frame.get('y'); + var fPts = JSON.parse(frame.get('points') || '[]'); + var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; + var vPadding = data.vPadding || defaultInitHud.vPadding; + var frameTopEdge = frameTop - fHeight / 2 + vPadding; + // Find this entry's slot index + var entryIdx = data.entries.indexOf(match); + var slotY = frameTopEdge + tokenSize / 2 + entryIdx * (tokenSize + tokenPadding); + var vOffset = newTop - slotY; + if (Math.abs(vOffset - (data.textVOffset || 0)) > 1) { + data.textVOffset = vOffset; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) { + var otherIdx = data.entries.indexOf(e); + var otherSlotY = frameTopEdge + tokenSize / 2 + otherIdx * (tokenSize + tokenPadding); + otherTxt.set('top', otherSlotY + vOffset); + } + }); + } + } } } }; @@ -5055,6 +5098,157 @@ var Gaslight = Gaslight || (() => { } }; + /** + * Handle frame pathv2 change — resize logic, position/style tracking. + */ + const onFrameChanged = (obj, prev) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + var points = JSON.parse(obj.get('points') || '[]'); + var newWidth = points.length >= 2 ? points[1][0] - points[0][0] : 0; + var newHeight = points.length >= 2 ? points[1][1] - points[0][1] : 0; + var oldWidth = data.frameSize ? data.frameSize.width : newWidth; + + if (newWidth !== oldWidth) { + var delta = newWidth - oldWidth; + var currentHPad = data.hPadding || defaultInitHud.hPadding; + var currentTokenSize = data.tokenSize || defaultInitHud.tokenSize; + var minHPad = 5; + + if (delta < 0) { + var padReduction = Math.min(Math.abs(delta) / 2, currentHPad - minHPad); + if (padReduction > 0) { + data.hPadding = currentHPad - padReduction; + delta += padReduction * 2; + } + if (delta < 0) { + data.tokenSize = Math.max(10, currentTokenSize + delta); + } + } else { + data.hPadding = currentHPad + delta / 2; + } + } + + data.pos = { left: obj.get('x'), top: obj.get('y') }; + data.frameSize = { width: newWidth, height: newHeight }; + var stroke = obj.get('stroke'); + var fill = obj.get('fill'); + var strokeWidth = obj.get('stroke_width'); + if (stroke !== undefined) data.frameStroke = stroke; + if (fill !== undefined) data.frameFill = fill; + if (strokeWidth !== undefined) data.frameStrokeWidth = strokeWidth; + reflowInitiativeHud('none'); + }; + + /** + * Handle highlight pathv2 change — style tracking, cascade to reticle, Y offset. + */ + const onHighlightChanged = (obj, prev) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + var hlStroke = obj.get('stroke'); + var hlFill = obj.get('fill'); + var hlStrokeWidth = obj.get('stroke_width'); + if (hlStroke !== undefined) data.highlightStroke = hlStroke; + if (hlFill !== undefined) data.highlightFill = hlFill; + if (hlStrokeWidth !== undefined) data.highlightStrokeWidth = hlStrokeWidth; + var hlRotation = obj.get('rotation'); + if (hlRotation !== undefined) data.highlightRotation = hlRotation; + // Cascade to reticle if not overridden + var rd = s.hud.reticleData || {}; + var ti = rd.id ? getObj('pathv2', rd.id) : null; + if (ti) { + if (hlStroke !== undefined && !rd.stroke) ti.set('stroke', hlStroke); + if (hlStrokeWidth !== undefined && !rd.strokeWidth) ti.set('stroke_width', hlStrokeWidth); + if (hlRotation !== undefined) { + var reticleOffset = rd.rotationOffset != null ? rd.rotationOffset : -(data.highlightRotation != null ? data.highlightRotation : 45); + ti.set('rotation', hlRotation + reticleOffset); + } + } + // Compute normalized Y offset + var frame = getObj('pathv2', data.frameId); + if (frame) { + var fTop = frame.get('y'); + var fPts = JSON.parse(frame.get('points') || '[]'); + var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; + var tknSz = data.tokenSize || defaultInitHud.tokenSize; + var vPad = data.vPadding || defaultInitHud.vPadding; + var usableTop = fTop - fHeight / 2 + tknSz / 2 + vPad; + var usableBot = fTop + fHeight / 2 - tknSz / 2 - vPad; + var hlY = obj.get('y'); + var norm = (hlY - usableTop) / (usableBot - usableTop); + data.currentTurnOffset = Math.max(0, Math.min(1, norm)); + } + reflowInitiativeHud('none'); + }; + + /** + * Handle reticle pathv2 change — style override, size scale, position offset. + */ + const onReticleChanged = (obj, prev) => { + var s = state[SCRIPT_NAME]; + var rd = s.hud.reticleData; + if (!rd) return; + var data = s.hud.initData || {}; + var tiStroke = obj.get('stroke'); + var tiStrokeWidth = obj.get('stroke_width'); + var tiRotation = obj.get('rotation'); + if (tiStroke !== undefined) { + if (tiStroke === (data.highlightStroke || defaultInitHud.highlightStroke)) { rd.stroke = null; } + else { rd.stroke = tiStroke; } + } + if (tiStrokeWidth !== undefined) { + if (tiStrokeWidth === (data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth)) { rd.strokeWidth = null; } + else { rd.strokeWidth = tiStrokeWidth; } + } + if (tiRotation !== undefined) { + var hlRot = data.highlightRotation != null ? data.highlightRotation : 45; + rd.rotationOffset = tiRotation - hlRot; + } + // Track proportional size relative to tracked token + var tiPoints = JSON.parse(obj.get('points') || '[]'); + var prevPoints = prev && prev.points ? JSON.parse(prev.points) : tiPoints; + var wasResized = JSON.stringify(tiPoints) !== JSON.stringify(prevPoints); + if (wasResized && tiPoints.length >= 2 && rd.tokenId) { + var token = getObj('graphic', rd.tokenId); + if (token) { + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var reticleW = tiPoints[1][0] - tiPoints[0][0]; + var reticleH = tiPoints[1][1] - tiPoints[0][1]; + if (tokenW > 0) rd.widthScale = reticleW / tokenW; + if (tokenH > 0) rd.heightScale = reticleH / tokenH; + var targetX = token.get('left') + (rd.xOffset || 0) * tokenW; + var targetY = token.get('top') + (rd.yOffset || 0) * tokenH; + obj.set({ x: targetX, y: targetY }); + } + } + // Track proportional position offset from token center (move without resize) + if (!wasResized && rd.tokenId) { + var tiX = obj.get('x'); + var tiY = obj.get('y'); + var token = getObj('graphic', rd.tokenId); + if (token) { + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var dx = tiX - token.get('left'); + var dy = tiY - token.get('top'); + var deadzone = 3; + var maxThreshold = Math.max(tokenW, tokenH) * 1.5; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist < deadzone) { + rd.xOffset = null; + rd.yOffset = null; + } else if (dist <= maxThreshold) { + rd.xOffset = tokenW > 0 ? dx / tokenW : 0; + rd.yOffset = tokenH > 0 ? dy / tokenH : 0; + } + } + } + }; + /** * Handle !gaslight hud command. */ @@ -5083,6 +5277,13 @@ var Gaslight = Gaslight || (() => { updateViewHud(); } }, + owns: function(id) { + var s = state[SCRIPT_NAME]; + return s.hud.viewId === id; + }, + events: { + text: { change: onHudTextChanged, destroy: onHudTextDestroyed }, + }, }, initiative: { aliases: ['init', 'turn', 'turns'], @@ -5094,6 +5295,28 @@ var Gaslight = Gaslight || (() => { s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); updateInitiativeHud(); }, + owns: function(id) { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData; + if (!data) return false; + if (id === data.frameId || id === data.highlightId) return true; + if (data.entries && data.entries.some(function(e) { return e.tokenId === id || e.textId === id; })) return true; + return false; + }, + events: { + pathv2: { + change: function(obj, prev) { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData; + if (!data) return; + if (obj.get('id') === data.frameId) onFrameChanged(obj, prev); + else if (obj.get('id') === data.highlightId) onHighlightChanged(obj, prev); + }, + destroy: onHudPathDestroyed, + }, + pin: { change: null, destroy: onHudGraphicDestroyed }, + text: { change: onHudTextChanged, destroy: onHudTextDestroyed }, + }, }, reticle: { aliases: ['indicator', 'current'], @@ -5107,6 +5330,13 @@ var Gaslight = Gaslight || (() => { s.hud.reticleData = { rotationOffset: -hlRot }; updateTurnReticle(); }, + owns: function(id) { + var s = state[SCRIPT_NAME]; + return s.hud.reticleData && s.hud.reticleData.id === id; + }, + events: { + pathv2: { change: onReticleChanged, destroy: onHudPathDestroyed }, + }, }, }; @@ -5209,156 +5439,31 @@ var Gaslight = Gaslight || (() => { on('change:graphic', onGraphicPropChanged); on('change:graphic:gmnotes', onGmNotesChanged); on('change:campaign:turnorder', onTurnOrderChanged); - on('change:text', onHudTextChanged); - on('destroy:text', onHudTextDestroyed); on('destroy:graphic', function(obj) { onTokenDestroyed(obj); }); - on('destroy:pathv2', onHudPathDestroyed); - on('change:pathv2', function(obj, prev) { - var s = state[SCRIPT_NAME]; - if (!s.hud.initiative || !s.hud.initData) return; - if (obj.get('id') === s.hud.initData.frameId) { - var data = s.hud.initData; - var points = JSON.parse(obj.get('points') || '[]'); - var newWidth = points.length >= 2 ? points[1][0] - points[0][0] : 0; - var newHeight = points.length >= 2 ? points[1][1] - points[0][1] : 0; - var oldWidth = data.frameSize ? data.frameSize.width : newWidth; - - // Width change logic: shrink → reduce padding first, then tokens; grow → grow padding - if (newWidth !== oldWidth) { - var delta = newWidth - oldWidth; - var currentHPad = data.hPadding || defaultInitHud.hPadding; - var currentTokenSize = data.tokenSize || defaultInitHud.tokenSize; - var minHPad = 5; - - if (delta < 0) { - // Frame got narrower — reduce padding first, then shrink tokens - var padReduction = Math.min(Math.abs(delta) / 2, currentHPad - minHPad); - if (padReduction > 0) { - data.hPadding = currentHPad - padReduction; - delta += padReduction * 2; // remaining delta - } - if (delta < 0) { - // Still need to shrink — reduce token size - data.tokenSize = Math.max(10, currentTokenSize + delta); - } - } else { - // Frame got wider — increase h_padding - data.hPadding = currentHPad + delta / 2; - } - } - // Save position and size to state - data.pos = { left: obj.get('x'), top: obj.get('y') }; - data.frameSize = { width: newWidth, height: newHeight }; - // Track frame styling - var stroke = obj.get('stroke'); - var fill = obj.get('fill'); - var strokeWidth = obj.get('stroke_width'); - if (stroke !== undefined) data.frameStroke = stroke; - if (fill !== undefined) data.frameFill = fill; - if (strokeWidth !== undefined) data.frameStrokeWidth = strokeWidth; - reflowInitiativeHud('none'); - } else if (obj.get('id') === s.hud.initData.highlightId) { - var data = s.hud.initData; - // Track highlight styling (parent/source of truth) - var hlStroke = obj.get('stroke'); - var hlFill = obj.get('fill'); - var hlStrokeWidth = obj.get('stroke_width'); - if (hlStroke !== undefined) data.highlightStroke = hlStroke; - if (hlFill !== undefined) data.highlightFill = hlFill; - if (hlStrokeWidth !== undefined) data.highlightStrokeWidth = hlStrokeWidth; - var hlRotation = obj.get('rotation'); - if (hlRotation !== undefined) data.highlightRotation = hlRotation; - // Cascade to reticle if not overridden - var rd = s.hud.reticleData || {}; - var ti = rd.id ? getObj('pathv2', rd.id) : null; - if (ti) { - if (hlStroke !== undefined && !rd.stroke) ti.set('stroke', hlStroke); - if (hlStrokeWidth !== undefined && !rd.strokeWidth) ti.set('stroke_width', hlStrokeWidth); - if (hlRotation !== undefined) { - var reticleOffset = rd.rotationOffset != null ? rd.rotationOffset : -(data.highlightRotation != null ? data.highlightRotation : 45); - ti.set('rotation', hlRotation + reticleOffset); - } - } - // Compute normalized Y offset (0=top+tokenSize/2+vPadding, 1=bottom-tokenSize/2-vPadding) - var frame = getObj('pathv2', data.frameId); - if (frame) { - var fTop = frame.get('y'); - var fPts = JSON.parse(frame.get('points') || '[]'); - var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; - var tknSz = data.tokenSize || defaultInitHud.tokenSize; - var vPad = data.vPadding || defaultInitHud.vPadding; - var usableTop = fTop - fHeight / 2 + tknSz / 2 + vPad; - var usableBot = fTop + fHeight / 2 - tknSz / 2 - vPad; - var hlY = obj.get('y'); - var norm = (hlY - usableTop) / (usableBot - usableTop); - data.currentTurnOffset = Math.max(0, Math.min(1, norm)); - } - reflowInitiativeHud('none'); - } else if (s.hud.reticleData && obj.get('id') === s.hud.reticleData.id) { - var rd = s.hud.reticleData; - var data = s.hud.initData || {}; - // Check if reticle properties are overrides or match the highlight (inherit) - var tiStroke = obj.get('stroke'); - var tiStrokeWidth = obj.get('stroke_width'); - var tiRotation = obj.get('rotation'); - if (tiStroke !== undefined) { - if (tiStroke === (data.highlightStroke || defaultInitHud.highlightStroke)) { rd.stroke = null; } - else { rd.stroke = tiStroke; } - } - if (tiStrokeWidth !== undefined) { - if (tiStrokeWidth === (data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth)) { rd.strokeWidth = null; } - else { rd.strokeWidth = tiStrokeWidth; } - } - if (tiRotation !== undefined) { - // Store as offset from highlight rotation - var hlRot = data.highlightRotation != null ? data.highlightRotation : 45; - rd.rotationOffset = tiRotation - hlRot; - } - // Track proportional size relative to tracked token - var tiPoints = JSON.parse(obj.get('points') || '[]'); - var prevPoints = prev && prev.points ? JSON.parse(prev.points) : tiPoints; - var wasResized = JSON.stringify(tiPoints) !== JSON.stringify(prevPoints); - if (wasResized && tiPoints.length >= 2 && rd.tokenId) { - var token = getObj('graphic', rd.tokenId); - if (token) { - var tokenW = token.get('width'); - var tokenH = token.get('height'); - var reticleW = tiPoints[1][0] - tiPoints[0][0]; - var reticleH = tiPoints[1][1] - tiPoints[0][1]; - if (tokenW > 0) rd.widthScale = reticleW / tokenW; - if (tokenH > 0) rd.heightScale = reticleH / tokenH; - // Snap back to stored offset position after resize - var targetX = token.get('left') + (rd.xOffset || 0) * tokenW; - var targetY = token.get('top') + (rd.yOffset || 0) * tokenH; - obj.set({ x: targetX, y: targetY }); - } - } - // Track proportional position offset from token center (move without resize) - if (!wasResized && rd.tokenId) { - var tiX = obj.get('x'); - var tiY = obj.get('y'); - var token = getObj('graphic', rd.tokenId); - if (token) { - var tokenW = token.get('width'); - var tokenH = token.get('height'); - var dx = tiX - token.get('left'); - var dy = tiY - token.get('top'); - var deadzone = 3; // pixels - var maxThreshold = Math.max(tokenW, tokenH) * 1.5; - var dist = Math.sqrt(dx * dx + dy * dy); - if (dist < deadzone) { - rd.xOffset = null; - rd.yOffset = null; - } else if (dist <= maxThreshold) { - rd.xOffset = tokenW > 0 ? dx / tokenW : 0; - rd.yOffset = tokenH > 0 ? dy / tokenH : 0; - } + // HUD registry event routing + var hudEventHandlers = {}; + Object.keys(hudRegistry).forEach(function(element) { + var events = hudRegistry[element].events; + if (!events) return; + Object.keys(events).forEach(function(objType) { + Object.keys(events[objType]).forEach(function(event) { + if (!events[objType][event]) return; + var key = event + ':' + objType; + if (!hudEventHandlers[key]) hudEventHandlers[key] = []; + hudEventHandlers[key].push({ element: element, handler: events[objType][event] }); + }); + }); + }); + Object.keys(hudEventHandlers).forEach(function(key) { + on(key, function(obj, prev) { + hudEventHandlers[key].forEach(function(entry) { + if (hudRegistry[entry.element].owns(obj.get('id'))) { + entry.handler(obj, prev); } - } - } + }); + }); }); - on('destroy:pin', onHudGraphicDestroyed); on('change:pin', function(obj, prev) { var s = state[SCRIPT_NAME]; if (!s.hud.initiative || !s.hud.initData) return; From 90a7373fb7ea323bee0a4fe5cb5d1b4ab31c0473 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 19 Jul 2026 11:22:54 -0400 Subject: [PATCH 09/37] Gaslight: fix text positioning (vOffset from highlight Y, creation guard, delayed initial reflow), text rotation tracking --- Gaslight/Gaslight.js | 82 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index b4b344ee8..fab92281a 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4626,8 +4626,8 @@ var Gaslight = Gaslight || (() => { _pageid: pageId, layer: 'foreground', text: String(entry.pr || ''), - left: frameLeft + frameWidth / 2 + (data.textOffset || 15), - top: frameTopEdge + (data.tokenPadding || defaultInitHud.tokenPadding) + tokenSize / 2, + left: 0, + top: 0, font_size: data.textFontSize || defaultInitHud.textFontSize, color: data.textColor || defaultInitHud.textColor, stroke: data.textStroke || defaultInitHud.textStroke, @@ -4643,8 +4643,13 @@ var Gaslight = Gaslight || (() => { } }); - reflowInitiativeHud(direction); - updateTurnReticle(); + if (direction === undefined) { + // Initial creation — delay reflow to give Roll20 time to register objects + setTimeout(function() { reflowInitiativeHud('none'); updateTurnReticle(); }, 100); + } else { + reflowInitiativeHud(direction); + updateTurnReticle(); + } }; /** @@ -4839,6 +4844,41 @@ var Gaslight = Gaslight || (() => { } }); } + + // DEBUG: log slot/pin/text positions (re-read after set) + var dbgSlots = []; + var dbgPins = []; + var dbgTexts = []; + for (var di = 0; di < data.entries.length; di++) { + var dOffset = di <= slotsBelow ? di : di - data.entries.length; + var dVisible = Math.abs(dOffset) <= (dOffset >= 0 ? slotsBelow : slotsAbove); + if (dVisible) dbgSlots.push({ idx: di, offset: dOffset, slotY: hudSlotY(frameCenter, dOffset, tokenSize, tokenPadding), pinY: hudPinY(frameCenter, dOffset, tokenSize, tokenPadding), pinId: data.entries[di].tokenId, textId: data.entries[di].textId }); + } + data.entries.forEach(function(e, i) { + var pin = getObj('pin', e.tokenId); + var txt = getObj('text', e.textId); + if (pin && pin.get('x') > -1000) dbgPins.push({ idx: i, id: e.tokenId, x: pin.get('x'), y: pin.get('y') }); + if (txt && txt.get('color') !== 'transparent') dbgTexts.push({ idx: i, id: e.textId, left: txt.get('left'), top: txt.get('top'), text: txt.get('text') }); + }); + log(SCRIPT_NAME + ' [reflow] direction=' + direction + ' frameCenter=' + frameCenter + ' slotsAbove=' + slotsAbove + ' slotsBelow=' + slotsBelow + ' textVOffset=' + (data.textVOffset || 0)); + log(SCRIPT_NAME + ' [reflow] expected=' + JSON.stringify(dbgSlots)); + log(SCRIPT_NAME + ' [reflow] pins=' + JSON.stringify(dbgPins)); + log(SCRIPT_NAME + ' [reflow] texts=' + JSON.stringify(dbgTexts)); + + // Delayed re-read to compare actual client state + var capturedEntries = data.entries.slice(); + setTimeout(function() { + var dbgPins2 = []; + var dbgTexts2 = []; + capturedEntries.forEach(function(e, i) { + var pin = getObj('pin', e.tokenId); + var txt = getObj('text', e.textId); + if (pin && pin.get('x') > -1000) dbgPins2.push({ idx: i, id: e.tokenId, x: pin.get('x'), y: pin.get('y') }); + if (txt && txt.get('color') !== 'transparent') dbgTexts2.push({ idx: i, id: e.textId, left: txt.get('left'), top: txt.get('top'), text: txt.get('text') }); + }); + log(SCRIPT_NAME + ' [reflow +5s] pins=' + JSON.stringify(dbgPins2)); + log(SCRIPT_NAME + ' [reflow +5s] texts=' + JSON.stringify(dbgTexts2)); + }, 5000); }; /** @@ -4879,6 +4919,7 @@ var Gaslight = Gaslight || (() => { const onHudTextChanged = (obj) => { var s = state[SCRIPT_NAME]; var id = obj.get('id'); + log(SCRIPT_NAME + ' [TEXT CHANGE EVENT] t=' + Date.now() + ' id=' + id + ' top=' + obj.get('top') + ' left=' + obj.get('left')); if (id === s.hud.viewId) { if (!s.hud.viewData) s.hud.viewData = {}; var vPageId = getHudPageId(); @@ -4966,27 +5007,34 @@ var Gaslight = Gaslight || (() => { var newTop = obj.get('top'); var tokenSize = data.tokenSize || defaultInitHud.tokenSize; var tokenPadding = data.tokenPadding || defaultInitHud.tokenPadding; - var frame = getObj('pathv2', data.frameId); - if (frame) { + var highlight = data.highlightId ? getObj('pathv2', data.highlightId) : null; + var frame = data.frameId ? getObj('pathv2', data.frameId) : null; + if (highlight && frame) { + var hlY = highlight.get('y'); var frameTop = frame.get('y'); var fPts = JSON.parse(frame.get('points') || '[]'); var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; var vPadding = data.vPadding || defaultInitHud.vPadding; - var frameTopEdge = frameTop - fHeight / 2 + vPadding; - // Find this entry's slot index + var step = tokenSize + tokenPadding; + // Find this entry's offset in the circular order var entryIdx = data.entries.indexOf(match); - var slotY = frameTopEdge + tokenSize / 2 + entryIdx * (tokenSize + tokenPadding); + var numEntries = data.entries.length; + var slotsBelow = 0; + while (hlY + (slotsBelow + 1) * step + tokenSize / 2 <= frameTop + fHeight / 2 - vPadding) slotsBelow++; + var entryOffset = entryIdx <= slotsBelow ? entryIdx : entryIdx - numEntries; + var slotY = hudSlotY(hlY, entryOffset, tokenSize, tokenPadding); var vOffset = newTop - slotY; - if (Math.abs(vOffset - (data.textVOffset || 0)) > 1) { + if (newTop === 0) { + // Text at creation position — ignore + } else if (Math.abs(vOffset - (data.textVOffset || 0)) > 1) { data.textVOffset = vOffset; data.entries.forEach(function(e) { if (e.textId === id) return; + var otherIdx = data.entries.indexOf(e); + var otherOffset = otherIdx <= slotsBelow ? otherIdx : otherIdx - numEntries; + var otherSlotY = hudSlotY(hlY, otherOffset, tokenSize, tokenPadding); var otherTxt = getObj('text', e.textId); - if (otherTxt) { - var otherIdx = data.entries.indexOf(e); - var otherSlotY = frameTopEdge + tokenSize / 2 + otherIdx * (tokenSize + tokenPadding); - otherTxt.set('top', otherSlotY + vOffset); - } + if (otherTxt) otherTxt.set('top', otherSlotY + vOffset); }); } } @@ -5424,6 +5472,10 @@ var Gaslight = Gaslight || (() => { }; const registerEventHandlers = () => { + // DEBUG: test if add:text and change:text fire on API creation + on('add:text', function(obj) { + log(SCRIPT_NAME + ' [TEXT ADD EVENT] t=' + Date.now() + ' id=' + obj.get('id') + ' top=' + obj.get('top') + ' left=' + obj.get('left')); + }); on('chat:message', handleInput); on('chat:message', viewInterceptor); on('chat:message', function(msg) { From eac64708cabdb979bf8a63d30deffa979c9b9cb5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 19 Jul 2026 11:46:03 -0400 Subject: [PATCH 10/37] Remove debug logging and test command --- Gaslight/Gaslight.js | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index fab92281a..e76e64e1f 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4845,40 +4845,6 @@ var Gaslight = Gaslight || (() => { }); } - // DEBUG: log slot/pin/text positions (re-read after set) - var dbgSlots = []; - var dbgPins = []; - var dbgTexts = []; - for (var di = 0; di < data.entries.length; di++) { - var dOffset = di <= slotsBelow ? di : di - data.entries.length; - var dVisible = Math.abs(dOffset) <= (dOffset >= 0 ? slotsBelow : slotsAbove); - if (dVisible) dbgSlots.push({ idx: di, offset: dOffset, slotY: hudSlotY(frameCenter, dOffset, tokenSize, tokenPadding), pinY: hudPinY(frameCenter, dOffset, tokenSize, tokenPadding), pinId: data.entries[di].tokenId, textId: data.entries[di].textId }); - } - data.entries.forEach(function(e, i) { - var pin = getObj('pin', e.tokenId); - var txt = getObj('text', e.textId); - if (pin && pin.get('x') > -1000) dbgPins.push({ idx: i, id: e.tokenId, x: pin.get('x'), y: pin.get('y') }); - if (txt && txt.get('color') !== 'transparent') dbgTexts.push({ idx: i, id: e.textId, left: txt.get('left'), top: txt.get('top'), text: txt.get('text') }); - }); - log(SCRIPT_NAME + ' [reflow] direction=' + direction + ' frameCenter=' + frameCenter + ' slotsAbove=' + slotsAbove + ' slotsBelow=' + slotsBelow + ' textVOffset=' + (data.textVOffset || 0)); - log(SCRIPT_NAME + ' [reflow] expected=' + JSON.stringify(dbgSlots)); - log(SCRIPT_NAME + ' [reflow] pins=' + JSON.stringify(dbgPins)); - log(SCRIPT_NAME + ' [reflow] texts=' + JSON.stringify(dbgTexts)); - - // Delayed re-read to compare actual client state - var capturedEntries = data.entries.slice(); - setTimeout(function() { - var dbgPins2 = []; - var dbgTexts2 = []; - capturedEntries.forEach(function(e, i) { - var pin = getObj('pin', e.tokenId); - var txt = getObj('text', e.textId); - if (pin && pin.get('x') > -1000) dbgPins2.push({ idx: i, id: e.tokenId, x: pin.get('x'), y: pin.get('y') }); - if (txt && txt.get('color') !== 'transparent') dbgTexts2.push({ idx: i, id: e.textId, left: txt.get('left'), top: txt.get('top'), text: txt.get('text') }); - }); - log(SCRIPT_NAME + ' [reflow +5s] pins=' + JSON.stringify(dbgPins2)); - log(SCRIPT_NAME + ' [reflow +5s] texts=' + JSON.stringify(dbgTexts2)); - }, 5000); }; /** @@ -4919,7 +4885,6 @@ var Gaslight = Gaslight || (() => { const onHudTextChanged = (obj) => { var s = state[SCRIPT_NAME]; var id = obj.get('id'); - log(SCRIPT_NAME + ' [TEXT CHANGE EVENT] t=' + Date.now() + ' id=' + id + ' top=' + obj.get('top') + ' left=' + obj.get('left')); if (id === s.hud.viewId) { if (!s.hud.viewData) s.hud.viewData = {}; var vPageId = getHudPageId(); @@ -5472,10 +5437,6 @@ var Gaslight = Gaslight || (() => { }; const registerEventHandlers = () => { - // DEBUG: test if add:text and change:text fire on API creation - on('add:text', function(obj) { - log(SCRIPT_NAME + ' [TEXT ADD EVENT] t=' + Date.now() + ' id=' + obj.get('id') + ' top=' + obj.get('top') + ' left=' + obj.get('left')); - }); on('chat:message', handleInput); on('chat:message', viewInterceptor); on('chat:message', function(msg) { From ed5ffc482797e9474a2dee3d5d5db6b53312db8b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 19 Jul 2026 11:52:25 -0400 Subject: [PATCH 11/37] Remove _suppressTurnSync (Campaign().set does not fire change events) --- Gaslight/Gaslight.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index e76e64e1f..26b560a15 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3833,8 +3833,6 @@ var Gaslight = Gaslight || (() => { // Initiative Tracking — sync turn order across linked tokens // ========================================================================= - var _suppressTurnSync = false; - /** * Get all linked token IDs for a given token across all active groups. * Returns { linkedIds: [...], isMaster: bool, masterPageId: string|null } @@ -3889,7 +3887,6 @@ var Gaslight = Gaslight || (() => { * Handle turnorder changes: sync linked tokens, apply [GM] tags, auto-skip children. */ const onTurnOrderChanged = (obj, prev) => { - if (_suppressTurnSync) return; var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; @@ -4061,9 +4058,7 @@ var Gaslight = Gaslight || (() => { if (modified) { var finalJson = JSON.stringify(newOrder); - _suppressTurnSync = true; Campaign().set('turnorder', finalJson); - _suppressTurnSync = false; } // Update initiative HUD if enabled @@ -4191,10 +4186,7 @@ var Gaslight = Gaslight || (() => { order = order.concat(toAdd); order = reorderInitiative(order); } - - _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(order)); - _suppressTurnSync = false; var parts = []; if (doTrim) parts.push(trimmed + ' stale entry(s) trimmed'); @@ -5040,9 +5032,7 @@ var Gaslight = Gaslight || (() => { var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); if (customIdx !== -1) order.splice(customIdx, 1); } - _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(order)); - _suppressTurnSync = false; reflowInitiativeHud('none'); sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); @@ -5080,9 +5070,7 @@ var Gaslight = Gaslight || (() => { var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); if (customIdx !== -1) order.splice(customIdx, 1); } - _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(order)); - _suppressTurnSync = false; reflowInitiativeHud('none'); sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); @@ -5595,10 +5583,7 @@ var Gaslight = Gaslight || (() => { } else { order.splice.apply(order, [targetFullIdx, 0].concat(removed)); } - - _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(order)); - _suppressTurnSync = false; } } @@ -5675,9 +5660,7 @@ var Gaslight = Gaslight || (() => { } } } - _suppressTurnSync = true; Campaign().set('turnorder', JSON.stringify(fullOrder)); - _suppressTurnSync = false; } reflowInitiativeHud('none'); From fb0c1f13b8e7313e7f088f09627855c6ff601c63 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 19 Jul 2026 12:56:28 -0400 Subject: [PATCH 12/37] Gaslight: createHudObj pattern (skip creation change events), fix pin destroy cascade, hudRegistry stateKey/defaults/ids abstraction --- Gaslight/Gaslight.js | 103 +++++++++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 39 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 26b560a15..57216b63d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4205,6 +4205,13 @@ var Gaslight = Gaslight || (() => { const HUD_PREFIX = 'gaslight_hud_'; + var _hudCreatedIds = new Set(); + const createHudObj = (type, props) => { + var obj = createObj(type, props); + if (obj) _hudCreatedIds.add(obj.get('id')); + return obj; + }; + /** * Get the master page ID from the first active group. */ @@ -4269,7 +4276,7 @@ var Gaslight = Gaslight || (() => { rotation: reticleRotation, }); } else { - reticle = createObj('pathv2', { + reticle = createHudObj('pathv2', { _pageid: pageId, layer: 'foreground', shape: 'rec', @@ -4350,7 +4357,7 @@ var Gaslight = Gaslight || (() => { // Use stored position/size or defaults var vd = s.hud.viewData || {}; var pageWidth = page.get('width') * 70; - var obj = createObj('text', { + var obj = createHudObj('text', { _pageid: pageId, layer: 'foreground', text: label, @@ -4448,7 +4455,7 @@ var Gaslight = Gaslight || (() => { * Draw a rectangle path on the foreground layer. */ const createFramePath = (pageId, left, top, width, height, data) => { - return createObj('pathv2', { + return createHudObj('pathv2', { _pageid: pageId, layer: 'foreground', shape: 'rec', @@ -4506,7 +4513,7 @@ var Gaslight = Gaslight || (() => { var usableTopHL = frame.get('y') - fH / 2 + hlTokenSize / 2 + vPadHL; var usableBotHL = frame.get('y') + fH / 2 - hlTokenSize / 2 - vPadHL; var hlY = usableTopHL + (usableBotHL - usableTopHL) * hlOffset; - var highlight = createObj('pathv2', { + var highlight = createHudObj('pathv2', { _pageid: pageId, layer: 'foreground', shape: 'rec', @@ -4564,7 +4571,7 @@ var Gaslight = Gaslight || (() => { if (customsToAdd <= 0) return; customsToAdd--; - var hudPin = createObj('pin', { + var hudPin = createHudObj('pin', { _pageid: pageId, x: frameLeft, y: -5000, @@ -4577,7 +4584,7 @@ var Gaslight = Gaslight || (() => { visibleTo: 'all', }); - var pinText = createObj('text', { + var pinText = createHudObj('text', { _pageid: pageId, layer: 'foreground', text: String(entry.pr || ''), @@ -4601,7 +4608,7 @@ var Gaslight = Gaslight || (() => { var sourceToken = getObj('graphic', entry.id); if (!sourceToken) return; - var hudPin = createObj('pin', { + var hudPin = createHudObj('pin', { _pageid: pageId, x: frameLeft, y: -5000, @@ -4614,7 +4621,7 @@ var Gaslight = Gaslight || (() => { visibleTo: 'all', }); - var hudText = createObj('text', { + var hudText = createHudObj('text', { _pageid: pageId, layer: 'foreground', text: String(entry.pr || ''), @@ -4981,9 +4988,7 @@ var Gaslight = Gaslight || (() => { var entryOffset = entryIdx <= slotsBelow ? entryIdx : entryIdx - numEntries; var slotY = hudSlotY(hlY, entryOffset, tokenSize, tokenPadding); var vOffset = newTop - slotY; - if (newTop === 0) { - // Text at creation position — ignore - } else if (Math.abs(vOffset - (data.textVOffset || 0)) > 1) { + if (Math.abs(vOffset - (data.textVOffset || 0)) > 1) { data.textVOffset = vOffset; data.entries.forEach(function(e) { if (e.textId === id) return; @@ -5016,11 +5021,10 @@ var Gaslight = Gaslight || (() => { var matchIdx = data.entries.findIndex(function(e) { return e.textId === id; }); if (matchIdx !== -1) { var match = data.entries[matchIdx]; - // Remove associated pin + // Splice entry first to prevent cascading destroy handlers from re-matching + data.entries.splice(matchIdx, 1); var pin = getObj('pin', match.tokenId); if (pin) pin.remove(); - // Remove from entries - data.entries.splice(matchIdx, 1); // Remove from turn order var order = JSON.parse(Campaign().get('turnorder') || '[]'); @@ -5052,11 +5056,10 @@ var Gaslight = Gaslight || (() => { if (matchIdx === -1) return; var match = data.entries[matchIdx]; - // Remove associated text + // Splice entry first — associated text will be orphaned and cleaned up on next reflow + data.entries.splice(matchIdx, 1); var txt = getObj('text', match.textId); if (txt) txt.remove(); - // Remove from entries - data.entries.splice(matchIdx, 1); // Remove from turn order var order = JSON.parse(Campaign().get('turnorder') || '[]'); @@ -5256,6 +5259,8 @@ var Gaslight = Gaslight || (() => { const hudRegistry = { view: { aliases: ['relay'], + stateKey: 'viewData', + defaults: {}, enable: function() { updateViewHud(); }, disable: function() { removeViewHud(); }, reset: function() { @@ -5278,9 +5283,9 @@ var Gaslight = Gaslight || (() => { updateViewHud(); } }, - owns: function(id) { + ids: function() { var s = state[SCRIPT_NAME]; - return s.hud.viewId === id; + return s.hud.viewId ? [s.hud.viewId] : []; }, events: { text: { change: onHudTextChanged, destroy: onHudTextDestroyed }, @@ -5288,21 +5293,22 @@ var Gaslight = Gaslight || (() => { }, initiative: { aliases: ['init', 'turn', 'turns'], + stateKey: 'initData', + defaults: function() { return Object.assign({}, defaultInitHud, { entries: [] }); }, enable: function() { updateInitiativeHud(); }, disable: function() { removeInitiativeHud(); }, - reset: function() { - var s = state[SCRIPT_NAME]; - removeInitiativeHud(); - s.hud.initData = Object.assign({}, defaultInitHud, { entries: [] }); - updateInitiativeHud(); - }, - owns: function(id) { + ids: function() { var s = state[SCRIPT_NAME]; var data = s.hud.initData; - if (!data) return false; - if (id === data.frameId || id === data.highlightId) return true; - if (data.entries && data.entries.some(function(e) { return e.tokenId === id || e.textId === id; })) return true; - return false; + if (!data) return []; + var result = []; + if (data.frameId) result.push(data.frameId); + if (data.highlightId) result.push(data.highlightId); + if (data.entries) data.entries.forEach(function(e) { + if (e.tokenId) result.push(e.tokenId); + if (e.textId) result.push(e.textId); + }); + return result; }, events: { pathv2: { @@ -5321,19 +5327,18 @@ var Gaslight = Gaslight || (() => { }, reticle: { aliases: ['indicator', 'current'], - enable: function() { updateTurnReticle(); }, - disable: function() { removeTurnReticle(); }, - reset: function() { + stateKey: 'reticleData', + defaults: function() { var s = state[SCRIPT_NAME]; var data = s.hud.initData || {}; var hlRot = data.highlightRotation != null ? data.highlightRotation : 45; - removeTurnReticle(); - s.hud.reticleData = { rotationOffset: -hlRot }; - updateTurnReticle(); + return { rotationOffset: -hlRot }; }, - owns: function(id) { + enable: function() { updateTurnReticle(); }, + disable: function() { removeTurnReticle(); }, + ids: function() { var s = state[SCRIPT_NAME]; - return s.hud.reticleData && s.hud.reticleData.id === id; + return s.hud.reticleData && s.hud.reticleData.id ? [s.hud.reticleData.id] : []; }, events: { pathv2: { change: onReticleChanged, destroy: onHudPathDestroyed }, @@ -5341,6 +5346,24 @@ var Gaslight = Gaslight || (() => { }, }; + // Generic owns check derived from ids() + Object.keys(hudRegistry).forEach(function(key) { + var entry = hudRegistry[key]; + if (!entry.owns) { + entry.owns = function(id) { return entry.ids().indexOf(id) !== -1; }; + } + // Generic reset if not overridden: disable → reset state → enable + if (!entry.reset) { + entry.reset = function() { + var s = state[SCRIPT_NAME]; + entry.disable(); + var defs = typeof entry.defaults === 'function' ? entry.defaults() : Object.assign({}, entry.defaults); + s.hud[entry.stateKey] = defs; + entry.enable(); + }; + } + }); + const resolveHudElement = (name) => { if (hudRegistry[name]) return name; for (var key in hudRegistry) { @@ -5458,8 +5481,10 @@ var Gaslight = Gaslight || (() => { }); Object.keys(hudEventHandlers).forEach(function(key) { on(key, function(obj, prev) { + var id = obj.get('id'); + if (key.startsWith('change:') && _hudCreatedIds.delete(id)) return; hudEventHandlers[key].forEach(function(entry) { - if (hudRegistry[entry.element].owns(obj.get('id'))) { + if (hudRegistry[entry.element].owns(id)) { entry.handler(obj, prev); } }); From 7a637e9ce3bbd9b220d3fd638c71423790d6ddb0 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 20 Jul 2026 06:39:18 -0400 Subject: [PATCH 13/37] Fix merge: disable all HUD elements via registry, return all players on group pages to banner --- Gaslight/Gaslight.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 57216b63d..5697544bb 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1120,8 +1120,11 @@ var Gaslight = Gaslight || (() => { } var psp = Campaign().get('playerspecificpages') || {}; - Object.keys(active.playerPages).forEach(function(playerId) { - delete psp[playerId]; + // Remove all players assigned to any page in this group + var groupPageIds = new Set([active.masterPageId]); + Object.values(active.playerPages).forEach(function(p) { groupPageIds.add(p.pageId); }); + Object.keys(psp).forEach(function(playerId) { + if (groupPageIds.has(psp[playerId])) delete psp[playerId]; }); Campaign().set('playerspecificpages', Object.keys(psp).length > 0 ? psp : false); delete s.activeGroups[gn]; @@ -1130,8 +1133,7 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Merge', 'Merged ' + groupsToMerge.length + ' group(s). Players returned to shared page.'); // Destroy HUD elements (preference preserved, will recreate on next split) - removeViewHud(); - removeInitiativeHud(); + Object.keys(hudRegistry).forEach(function(el) { hudRegistry[el].disable(); }); }; const doTest = (msg, args) => { From 67499a4576c9dbc72be2679ad05f6ce1e8cdfe9b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 20 Jul 2026 07:07:20 -0400 Subject: [PATCH 14/37] Fix getHudTurnOrder: show all group-page tokens, dedup linked children --- Gaslight/Gaslight.js | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 5697544bb..382878f83 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4446,10 +4446,43 @@ var Gaslight = Gaslight || (() => { * Get the deduped turn order (master tokens only, skip children). */ const getHudTurnOrder = () => { + var s = state[SCRIPT_NAME]; var order = JSON.parse(Campaign().get('turnorder') || '[]'); + // Collect all page IDs belonging to active groups + var groupPageIds = new Set(); + Object.values(s.activeGroups).forEach(function(active) { + groupPageIds.add(active.masterPageId); + Object.values(active.playerPages).forEach(function(p) { groupPageIds.add(p.pageId); }); + }); + // Track which linked groups we've already included (by master ID or first seen ID) + var seenGroups = new Set(); return order.filter(function(entry) { - if (!entry.id || entry.id === '-1') return true; - return isMasterToken(entry.id); + if (!entry.id || entry.id === '-1') return true; // customs always + var token = getObj('graphic', entry.id); + if (!token) return false; // stale entry + var pageId = token.get('_pageid'); + if (!groupPageIds.has(pageId)) return false; // not on a group page + // Check if this token is part of a linked group + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return true; // unlinked token, always show + // For linked tokens: only show if this is the master, or if master isn't in the order + if (isMasterToken(entry.id)) { + seenGroups.add(entry.id); + return true; + } + // It's a child — check if its master is in the order + var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (masterId && seenGroups.has(masterId)) return false; // master already shown + if (masterId && order.some(function(e) { return e.id === masterId; })) { + seenGroups.add(masterId); + return false; // master is in order, it will be shown when we reach it + } + // No master in order — show this child as representative + if (!seenGroups.has(entry.id)) { + seenGroups.add(entry.id); + return true; + } + return false; }); }; From b2e62682c74d146f27de5ad06da5c2d43e7e72bb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 20 Jul 2026 07:57:08 -0400 Subject: [PATCH 15/37] Initiative HUD: tag pins with missing-view indicator (icon in title, details in notes) --- Gaslight/Gaslight.js | 62 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 382878f83..f0dd468fd 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4198,7 +4198,22 @@ var Gaslight = Gaslight || (() => { out += '

    ⚠️ ' + warnings.join('
    ⚠️ '); } reply(msg, 'Init', out); - if (s.hud.initiative) updateInitiativeHud(); + if (s.hud.initiative) { + // Refresh tags on existing entries + if (s.hud.initData && s.hud.initData.entries) { + s.hud.initData.entries.forEach(function(e) { + if (e.sourceId.startsWith('custom:')) return; + var pin = getObj('pin', e.tokenId); + if (!pin) return; + var tagStr = computeHudTag(e.sourceId); + var sourceToken = getObj('graphic', e.sourceId); + var name = sourceToken ? (sourceToken.get('name') || '') : ''; + pin.set('title', ((tagStr ? '⚠️ ' : '') + name).trim()); + pin.set('notes', tagStr || ''); + }); + } + updateInitiativeHud(); + } }; // ========================================================================= @@ -4486,6 +4501,43 @@ var Gaslight = Gaslight || (() => { }); }; + /** + * Compute the tag string for a HUD entry (which views are missing this token's turn). + * Returns '' if present on all views, or a "Missing from" list. + */ + const computeHudTag = (sourceId) => { + var s = state[SCRIPT_NAME]; + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var activeGroup = Object.values(s.activeGroups)[0]; + if (!activeGroup) return ''; + + // Get all view names: GM + each player + var allViews = ['GM']; + var viewPageMap = { GM: activeGroup.masterPageId }; + Object.entries(activeGroup.playerPages).forEach(function(entry) { + var name = entry[1].name || entry[0].slice(0, 6); + allViews.push(name); + viewPageMap[name] = entry[1].pageId; + }); + + // Find which pages have a turn for this token (or its linked copies) + var info = getLinkedInfo(sourceId); + var groupIds = new Set([sourceId].concat(info.linkedIds)); + var pagesWithTurn = new Set(); + order.forEach(function(e) { + if (groupIds.has(e.id)) { + var token = getObj('graphic', e.id); + if (token) pagesWithTurn.add(token.get('_pageid')); + } + }); + + // Find which views are missing + var missingViews = allViews.filter(function(v) { return !pagesWithTurn.has(viewPageMap[v]); }); + + if (missingViews.length === 0) return ''; // on all views + return 'No Turns For:\n' + missingViews.map(function(v) { return v.length > 70 ? v.slice(0, 67) + '...' : v; }).join('\n'); + }; + /** * Draw a rectangle path on the foreground layer. */ @@ -4617,6 +4669,7 @@ var Gaslight = Gaslight || (() => { textIcon: '', scale: 1.25, visibleTo: 'all', + notesDesynced: true, }); var pinText = createHudObj('text', { @@ -4643,17 +4696,22 @@ var Gaslight = Gaslight || (() => { var sourceToken = getObj('graphic', entry.id); if (!sourceToken) return; + var tagStr = computeHudTag(entry.id); + var pinTitle = ((tagStr ? '⚠️ ' : '') + (sourceToken.get('name') || '')).trim(); + var hudPin = createHudObj('pin', { _pageid: pageId, x: frameLeft, y: -5000, - title: sourceToken.get('name') || '', + title: pinTitle, shape: 'circle', bgColor: 'transparent', customizationType: 'image', pinImage: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), scale: 1.25, visibleTo: 'all', + notesDesynced: true, + notes: tagStr || '', }); var hudText = createHudObj('text', { From 3bbe6ff97057ee09bee742cd6efc1ab1cadeae63 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 20 Jul 2026 08:52:22 -0400 Subject: [PATCH 16/37] Remove first-map example (to be rewritten), plan new example structure --- Gaslight/Gaslight.js | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index f0dd468fd..a95a57ce6 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3474,27 +3474,6 @@ var Gaslight = Gaslight || (() => { }; // Register examples - ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { - name: 'first-map', - description: 'Walk through setting up your first gaslighted map (no handout generated)', - guide: [ - { prompt: 'This guide walks you through creating your first **per-player split**. No handout is generated — just follow the steps and click Continue when ready.' }, - { prompt: 'Create your **master page** with all tokens placed (NPCs, players, objects). This is the "truth" page.' }, - { prompt: 'Duplicate the page once per player using Roll20\'s built-in **Duplicate Page**. Leave the page names as they are — the *"Copy of"* prefix is how Gaslight auto-detects them.' }, - { prompt: 'Select all party tokens on the master page and run `!gaslight setup mygroup`. This auto-detects the duplicates, assigns each to a player, and configures the group.', - onContinue: () => { - var groups = discoverAllGroups(); - if (Object.keys(groups).length === 0) return 'No group configured yet. Run !gaslight setup first.'; - } - }, - { prompt: 'Run `!gaslight split mygroup`. This activates the group — players are moved to individual pages and tokens begin syncing.', - onContinue: () => { - if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run !gaslight split first.'; - } - }, - { prompt: '**Done!** Move tokens on the master page — they\'ll sync to all player pages. Make per-player changes on individual pages (hide/show tokens, swap images, etc). When finished: `!gaslight merge`' }, - ], - }); ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'stealth', From d9c41dfef749edf0fa7f423eefd3200f55b90d64 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 20 Jul 2026 12:17:26 -0400 Subject: [PATCH 17/37] Gaslight: add getting-started example (guide-only) with offerExamples --- Gaslight/Gaslight.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index a95a57ce6..2e22217ad 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3475,6 +3475,36 @@ var Gaslight = Gaslight || (() => { // Register examples + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'getting-started', + description: 'Set up your first per-player split: pages, setup, split, and merge (guide only)', + guide: [ + { prompt: 'This guide walks you through creating your first **per-player split**.\n\nYou\'ll need at least 2 players in your game, but those players do not need to be online.' }, + { prompt: 'Create your **master page** with all tokens placed (NPCs, player characters, objects). This page is the GM\'s "ground truth" — all changes start here.' }, + { prompt: 'Duplicate the page **once per player** using Roll20\'s built-in **Duplicate Page** button. Leave the page names as they are — the *"Copy of"* prefix is how Gaslight auto-detects them.' }, + { prompt: 'Navigate to the master page and run `!gaslight setup mygroup` using one of these methods:\n\n**Option 1:** Select player-character tokens on the master page, then run `!gaslight setup mygroup` — uses controlling players of selected tokens.\n\n**Option 2:** Set the master page as the banner page and run `!gaslight setup mygroup "Player1" "Player2" ...` with no selection — uses the specified player names.\n\n**Option 3:** Define a Roll20 party, set the master page as the banner page, and run `!gaslight setup mygroup` with no selection — uses controlling players of party tokens.\n\nAll options auto-detect duplicated pages and assign one per player.', + ...ScriptKit.waitForCommand('!gaslight setup'), + onContinue: () => { + var groups = discoverAllGroups(); + if (Object.keys(groups).length === 0) return 'No group configured yet. Run `!gaslight setup ` using one of the methods above.'; + } + }, + { prompt: 'Now run:\n\n`!gaslight split mygroup`\n\nThis activates the group — players are moved to their individual pages and token syncing begins.', + ...ScriptKit.waitForCommand('!gaslight split'), + onContinue: () => { + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first.'; + } + }, + { prompt: 'You may notice a **HUD** appeared on the master page. We\'ll cover that in a later example. For now, disable it with:\n\n`!gaslight hud off`', + ...ScriptKit.waitForCommand('!gaslight hud') + }, + { prompt: 'Your split is now active. Try moving an **NPC token** on the master page — it syncs to all player pages automatically. NPC tokens only sync when updated on the master page (one-directional).\n\nThis means you can change an NPC on a specific player\'s page without affecting anyone else — useful for hiding tokens, swapping images, or showing per-player information.' }, + { prompt: 'Now try moving a **player-controlled token** on that player\'s page. It syncs back to the master and out to other player pages — players can move their own tokens and everyone sees it (bidirectional).' }, + { prompt: 'When you\'re done with the split (e.g. combat ends, scene changes), run:\n\n`!gaslight merge`.\n\nThis tears down all links and returns players to the banner page.' }, + { prompt: '**That\'s the basics!** From here, explore the other examples to learn more about how to use gaslight.', offerExamples: ['core-mechanics', 'initiative-hud', 'relay', 'scripting'] }, + ], + }); + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'stealth', description: 'Hide/show NPCs per player based on passive perception vs stealth DC (RollCapture)', From 77309392984345fa01b6de743881aa833ce2dc98 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 20 Jul 2026 15:04:27 -0400 Subject: [PATCH 18/37] Gaslight: core-mechanics example, --default flags, asymmetric unlink/delete, fix controlledby fallback --- Gaslight/Gaslight.js | 310 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 294 insertions(+), 16 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 2e22217ad..d8290dd77 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1167,6 +1167,26 @@ var Gaslight = Gaslight || (() => { }; const doLink = (msg, args) => { + // --default []: set gaslight_link on selected tokens' characters + var defaultIdx = args.indexOf('--default'); + if (defaultIdx !== -1) { + var name = args[defaultIdx + 1] && !args[defaultIdx + 1].startsWith('-') ? args[defaultIdx + 1] : null; + 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) representing characters.'); return; } + var count = 0; + tokens.forEach(function(t) { + var charId = t.get('represents'); + if (!charId) return; + var linkId = name || getLinkId(t) || genId(); + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr) attr.set('current', linkId); + else createObj('attribute', { characterid: charId, name: LINK_KEY, current: linkId }); + count++; + }); + reply(msg, 'Link', 'Set ' + LINK_KEY + ' default on ' + count + ' character(s). New tokens representing these characters will auto-link with the stored name.'); + return; + } + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; args = args.filter(function(a) { return a !== '--ignore-selected'; }); @@ -1209,6 +1229,22 @@ var Gaslight = Gaslight || (() => { }; const doUnlink = (msg, args) => { + // --default: remove gaslight_link from selected tokens' characters + var defaultIdx = args.indexOf('--default'); + if (defaultIdx !== -1) { + 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) representing characters.'); return; } + var count = 0; + tokens.forEach(function(t) { + var charId = t.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr) { attr.remove(); count++; } + }); + reply(msg, 'Unlink', 'Removed ' + LINK_KEY + ' default from ' + count + ' character(s).'); + return; + } + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; args = args.filter(function(a) { return a !== '--ignore-selected'; }); @@ -1243,7 +1279,93 @@ var Gaslight = Gaslight || (() => { }); if (tokens.length === 0) { reply(msg, 'Error', 'No tokens specified.'); return; } - tokens.forEach(removeLinkId); + var s = state[SCRIPT_NAME]; + tokens.forEach(function(t) { + var tokenId = t.get('id'); + var tokenPageId = t.get('_pageid'); + + // Find the active group this token belongs to + var activeGroup = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId] || Object.values(active.linkedTokens).some(function(peers) { return peers.indexOf(tokenId) !== -1; })) { + activeGroup = active; + } + }); + + // Determine if this is a parent token + var isParent = false; + if (activeGroup) { + if (tokenPageId === activeGroup.masterPageId) { + isParent = true; + } else { + var controlledBy = t.get('controlledby') || ''; + if (!controlledBy) { + var charId = t.get('represents'); + var character = charId ? getObj('character', charId) : null; + if (character) controlledBy = character.get('controlledby') || ''; + } + if (controlledBy) { + var controllerIds = controlledBy.split(',').map(function(c) { return c.trim(); }).filter(Boolean); + var playerEntry = Object.entries(activeGroup.playerPages).find(function(e) { + return controllerIds.indexOf(e[0]) !== -1 && e[1].pageId === tokenPageId; + }); + if (playerEntry) isParent = true; + } + } + } + + if (isParent && activeGroup) { + // Parent unlink: dissolve the entire link group + var allIds = [tokenId].concat(activeGroup.linkedTokens[tokenId] || []); + // Also gather any peers that list this token + Object.entries(activeGroup.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1 && allIds.indexOf(entry[0]) === -1) allIds.push(entry[0]); + }); + // Break anchor chain ring (if token is part of one) + if (typeof Anchor !== 'undefined') Anchor.unchainAnchorObjs(tokenId); + // Remove anchor from all child tokens (non-chain members anchored to a chain member) + allIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + }); + // Break mirror chain and links + if (typeof Mirror !== 'undefined') { + Mirror.unchain(allIds); + Mirror.unlink(allIds); + } + // Remove gaslight_link and clean up tracking + allIds.forEach(function(id) { + var tok = getObj('graphic', id); + if (tok) removeLinkId(tok); + delete activeGroup.linkedTokens[id]; + }); + // Clean up references in peers + Object.keys(activeGroup.linkedTokens).forEach(function(key) { + activeGroup.linkedTokens[key] = activeGroup.linkedTokens[key].filter(function(id) { return allIds.indexOf(id) === -1; }); + }); + } else { + // Non-parent unlink: just remove this one token + removeLinkId(t); + if (typeof Mirror !== 'undefined') Mirror.unlink([tokenId]); + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(tokenId); + // Remove from activeGroups tracking + if (activeGroup) { + if (activeGroup.linkedTokens[tokenId]) { + var peers = activeGroup.linkedTokens[tokenId]; + delete activeGroup.linkedTokens[tokenId]; + peers.forEach(function(peerId) { + if (activeGroup.linkedTokens[peerId]) { + activeGroup.linkedTokens[peerId] = activeGroup.linkedTokens[peerId].filter(function(id) { return id !== tokenId; }); + } + }); + } else { + Object.keys(activeGroup.linkedTokens).forEach(function(key) { + var idx = activeGroup.linkedTokens[key].indexOf(tokenId); + if (idx !== -1) activeGroup.linkedTokens[key].splice(idx, 1); + }); + } + } + } + }); reply(msg, 'Unlink', tokens.length + ' token(s) unlinked.'); }; @@ -1251,10 +1373,32 @@ var Gaslight = Gaslight || (() => { var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + var useDefault = args.indexOf('--default') !== -1; + args = args.filter(function(a) { return a !== '--default'; }); + + var getVal = useDefault + ? function(t) { + var charId = t.get('represents'); if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + return attr ? (attr.get('current') || '') : null; + } + : function(t) { return getSyncConfigRaw(t); }; + + var setVal = useDefault + ? function(t, value) { + var charId = t.get('represents'); if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr) attr.set('current', value); + else createObj('attribute', { characterid: charId, name: 'gaslight_sync', current: value }); + } + : function(t, value) { setSyncConfig(t, value); }; + + var label = useDefault ? 'character default' : 'token'; + // No args — show current config if (args.length === 0) { var results = tokens.map(function(t) { - var raw = getSyncConfigRaw(t); + var raw = getVal(t); var name = t.get('name') || t.get('id'); return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); }); @@ -1264,8 +1408,9 @@ var Gaslight = Gaslight || (() => { var subCmd = args[0].toLowerCase(); - // Reset — re-copy from character attribute + // Reset — re-copy from character attribute (only makes sense for token mode) if (subCmd === 'reset') { + if (useDefault) { reply(msg, 'Error', 'Reset only applies to token sync, not --default.'); return; } tokens.forEach(function(t) { var charId = t.get('represents'); var charVal = ''; @@ -1283,43 +1428,66 @@ var Gaslight = Gaslight || (() => { // "all" — explicitly sync everything if (subCmd === 'all') { - tokens.forEach(function(t) { setSyncConfig(t, 'all'); }); - reply(msg, 'Sync', 'Set to sync all properties on ' + tokens.length + ' token(s).'); + tokens.forEach(function(t) { setVal(t, 'all'); }); + reply(msg, 'Sync', 'Set to sync all properties on ' + tokens.length + ' ' + label + '(s).'); return; } // Otherwise, treat all args as props to add to sync list var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); tokens.forEach(function(t) { - var raw = getSyncConfigRaw(t) || ''; + var raw = getVal(t) || ''; var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); props.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); - setSyncConfig(t, existing.join(', ')); + setVal(t, existing.join(', ')); }); - reply(msg, 'Sync', 'Added [' + props.join(', ') + '] to sync on ' + tokens.length + ' token(s).'); + reply(msg, 'Sync', 'Added [' + props.join(', ') + '] to sync on ' + tokens.length + ' ' + label + '(s).'); }; const doDesync = (msg, args) => { var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } - if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync '); return; } + + var useDefault = args.indexOf('--default') !== -1; + args = args.filter(function(a) { return a !== '--default'; }); + + var getVal = useDefault + ? function(t) { + var charId = t.get('represents'); if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + return attr ? (attr.get('current') || '') : null; + } + : function(t) { return getSyncConfigRaw(t); }; + + var setVal = useDefault + ? function(t, value) { + var charId = t.get('represents'); if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr) attr.set('current', value); + else createObj('attribute', { characterid: charId, name: 'gaslight_sync', current: value }); + } + : function(t, value) { setSyncConfig(t, value); }; + + var label = useDefault ? 'character default' : 'token'; + + if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync [--default] '); return; } // "all" — disable all syncing (set empty string = no sync) if (args[0].toLowerCase() === 'all') { - tokens.forEach(function(t) { setSyncConfig(t, ''); }); - reply(msg, 'Desync', 'Disabled all syncing on ' + tokens.length + ' token(s). Use !gaslight sync reset to restore.'); + tokens.forEach(function(t) { setVal(t, ''); }); + reply(msg, 'Desync', 'Disabled all syncing on ' + tokens.length + ' ' + label + '(s).' + (useDefault ? '' : ' Use !gaslight sync reset to restore.')); return; } var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); tokens.forEach(function(t) { - var raw = getSyncConfigRaw(t) || ''; + var raw = getVal(t) || ''; var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); excludeProps.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); - setSyncConfig(t, existing.join(', ')); + setVal(t, existing.join(', ')); }); - reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' token(s).'); + reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' ' + label + '(s).'); }; /** @@ -1744,6 +1912,31 @@ var Gaslight = Gaslight || (() => { */ const doStage = (msg, args) => { var s = state[SCRIPT_NAME]; + + // --default on|off: set gaslight_stage on selected tokens' characters + var defaultIdx = args.indexOf('--default'); + if (defaultIdx !== -1) { + var val = args[defaultIdx + 1]; + if (val !== 'on' && val !== 'off') { reply(msg, 'Error', 'Usage: !gaslight stage --default on|off'); return; } + 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) representing characters.'); return; } + var count = 0; + tokens.forEach(function(t) { + var charId = t.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_stage' })[0]; + if (val === 'on') { + if (attr) attr.set('current', '1'); + else createObj('attribute', { characterid: charId, name: 'gaslight_stage', current: '1' }); + } else { + if (attr) attr.remove(); + } + count++; + }); + reply(msg, 'Stage', 'Set gaslight_stage=' + (val === 'on' ? '1' : '(removed)') + ' on ' + count + ' character(s). Tokens representing these characters will ' + (val === 'on' ? 'auto-stage when placed on a gaslighted page.' : 'no longer auto-stage.')); + return; + } + 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; } @@ -3505,6 +3698,54 @@ var Gaslight = Gaslight || (() => { ], }); + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'core-mechanics', + description: 'Staging, linking, syncing, and token lifecycle', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Core Mechanics** — This guide walks through the fundamental operations that make Gaslight tick: staging tokens, managing links, controlling sync, and what happens when tokens are deleted.\n\n**Prerequisite:** You need an active split. If you haven\'t set one up yet, run the **getting-started** guide first.', + onContinue: () => { + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first, or complete the getting-started guide.'; + } + }, + { prompt: 'During an active split, anything new placed on a page stays only on that page until it\'s been **staged**. This ensures tokens aren\'t visible to players before you\'re ready.\n\nAdd a new token to the master page, select it, and click Continue.', + select: 'token', as: 'unlinked', min: 1, + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; + var unstaged = tokens.some(token => isOnMasterPage(token.get('id')) && !isMasterToken(token.get('id'))); + if (!unstaged) return tokens.length == 1 ? 'The selected token is already linked. Select a new (unstaged) token on the master page.' : 'All selected tokens are already linked. Select a new (unstaged) token on the master page.'; + } + }, + { prompt: 'Now with the token selected, run:\n\n`!gaslight stage`\n\nThis creates copies on each player page and establishes links for automatic syncing.\n\n**Note:** If staging fails, it\'s likely a marketplace image. Gaslight will warn you which tokens couldn\'t be staged. Re-upload the image to your library and swap it to fix this.', + ...ScriptKit.waitForCommand('!gaslight stage'), + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selections.unlinked) ? ctx.selections.unlinked : [ctx.selections.unlinked]; + var allStaged = tokens.every(token => isMasterToken(token.get('id'))); + if (!allStaged) return 'The previously selected token has not been staged yet. Select it and run `!gaslight stage`.'; + } + }, + { prompt: 'Your token is now staged — copies exist on each player page and sync automatically.\n\nYou can also set a **default** so tokens representing a character auto-stage whenever placed on a gaslighted page:\n\n`!gaslight stage --default on`\n\nTo remove the default: `!gaslight stage --default off`' }, + { prompt: '**Unlinking** disconnects a player\'s copy from the master without deleting it. Useful for making permanent per-player differences.\n\nSelect a staged token on the master page and run:\n\n`!gaslight unlink`', + ...ScriptKit.waitForCommand('!gaslight unlink') + }, + { prompt: 'The player copy now lives independently — changes on the master won\'t reach it.' }, + { prompt: '**Relinking** re-establishes the connection. Select the same master token and run:\n\n`!gaslight link`\n\nThe player copy snaps back to match the master.', + ...ScriptKit.waitForCommand('!gaslight link') + }, + { prompt: '**Desyncing** keeps tokens linked but pauses sync for specific properties. For example, you can move a token independently on one player\'s page while bars and status markers still sync.\n\nSelect a staged token and run:\n\n`!gaslight desync left,top`\n\nThis excludes position from syncing.', + ...ScriptKit.waitForCommand('!gaslight desync') + }, + { prompt: '**Resyncing** resumes sync for a desynced property. The player copy snaps back to the master\'s current value.\n\nSelect the same token and run:\n\n`!gaslight sync left,top`', + ...ScriptKit.waitForCommand('!gaslight sync') + }, + { prompt: 'You can also set sync/desync **defaults** on a character so new tokens inherit the config:\n\n`!gaslight sync --default all` — sync everything (the default)\n`!gaslight sync --default left,top,bar1_value` — sync only these props\n`!gaslight desync --default left,top` — exclude position from sync\n`!gaslight desync --default all` — disable all syncing by default\n\nUse `!gaslight sync reset` on a token to re-read the character default.' }, + { prompt: '**Token deletion behavior:**\n\n• Delete a **parent token** (master page, or controlling player\'s page for PCs) → all linked copies are removed\n• Delete a **non-parent copy** (e.g. an NPC on a player page) → only that copy is removed, others remain' }, + { prompt: '**That\'s the core!** You now know how to stage, link/unlink, sync/desync, and manage token lifecycle.\n\nNext, learn how the initiative HUD works or how to relay commands to player pages.', + offerExamples: ['initiative-hud', 'relay', 'scripting'] + }, + ], + }); + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'stealth', description: 'Hide/show NPCs per player based on passive perception vs stealth DC (RollCapture)', @@ -3700,12 +3941,15 @@ var Gaslight = Gaslight || (() => { if (destroying) return; var s = state[SCRIPT_NAME]; var tokenId = obj.get('id'); + var tokenPageId = obj.get('_pageid'); // Find if this token is tracked in any active group var linkedIds = null; + var activeGroup = null; Object.values(s.activeGroups).forEach(function(active) { if (active.linkedTokens[tokenId]) { linkedIds = active.linkedTokens[tokenId]; + activeGroup = active; // Clean up tracking delete active.linkedTokens[tokenId]; linkedIds.forEach(function(id) { @@ -3719,15 +3963,49 @@ var Gaslight = Gaslight || (() => { 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 = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + activeGroup = active; + } } }); } }); if (!linkedIds || linkedIds.length === 0) return; + if (!activeGroup) return; + + // Determine if this is a "parent" token: + // - On the master page (parent for NPCs and player tokens) + // - On the controlling player's page (parent for player-controlled tokens) + var isParent = false; + if (tokenPageId === activeGroup.masterPageId) { + isParent = true; + } else { + // Check if this token is on its controlling player's page + var controlledBy = obj.get('controlledby') || ''; + if (!controlledBy) { + var charId = obj.get('represents'); + var character = charId ? getObj('character', charId) : null; + if (character) controlledBy = character.get('controlledby') || ''; + } + if (controlledBy) { + var controllerIds = controlledBy.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var playerEntry = Object.entries(activeGroup.playerPages).find(function(e) { + return controllerIds.indexOf(e[0]) !== -1 && e[1].pageId === tokenPageId; + }); + if (playerEntry) isParent = true; + } + } + + if (!isParent) { + // Non-parent deletion: just clean up Mirror/Anchor links, don't cascade + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(tokenId); + if (typeof Mirror !== 'undefined') Mirror.unlink([tokenId]); + return; + } - // Remove Anchor/Mirror links and delete counterparts + // Parent deletion: cascade to all linked copies destroying = true; linkedIds.forEach(function(id) { if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); From 1a03318e60dfd75d2692e7bd733ca39af18f1dcb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 24 Jul 2026 11:31:31 -0400 Subject: [PATCH 19/37] Gaslight: initiative-hud example, toPinCenter helper, tokenSize fix (40*scale) --- Gaslight/Gaslight.js | 277 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d8290dd77..db0f91f38 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,4 +1,4 @@ -// ============================================================================= +// ============================================================================= // Gaslight v2.1.0 // Last Updated: 2026-07-05 // Author: Kenan Millet @@ -3746,6 +3746,257 @@ var Gaslight = Gaslight || (() => { ], }); + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'initiative-hud', + description: 'Interactive initiative tracker HUD — gestures, customization, reticle', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Initiative HUD** — This guide walks through the on-screen initiative tracker: how to interact with it, customize its appearance, and use the current turn reticle.\n\n**Prerequisites:** You need an active split with the HUD enabled.', + onContinue: () => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; + if (!s.hud.initiative) return 'Initiative HUD is disabled. Run `!gaslight hud init on` to enable it.'; + } + }, + { prompt: 'Before we begin, clear any existing entries from the turn order. You can do this from Roll20\'s Turn Tracker panel (⚙).\n\nClear the turn order and click Continue.', + when: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + return turnOrder.length > 0; + }, + onEnter: (ctx, advance) => { + ctx._clearFired = false; + on('change:campaign:turnorder', function() { + if (ctx._clearFired) return; + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length === 0) { ctx._clearFired = true; advance(); } + }); + }, + onContinue: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; + } + }, + { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (⚙ in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative — we\'ll cover that later.', + onEnter: (ctx, advance) => { + ctx._turnFired = false; + on('change:campaign:turnorder', function() { + if (ctx._turnFired) return; + var s = state[SCRIPT_NAME]; + var entries = (s.hud.initData && s.hud.initData.entries) || []; + if (entries.length >= 6) { ctx._turnFired = true; advance(); } + }); + }, + onContinue: () => { + var s = state[SCRIPT_NAME]; + var entries = (s.hud.initData && s.hud.initData.entries) || []; + if (entries.length < 6) return 'Only ' + entries.length + ' token(s) in the HUD. Add at least 6.'; + } + }, + { prompt: '**Why duplicates?**\n\nYou may notice the Roll20 Turn Tracker panel shows what appears to be duplicate entries — multiple entries for the same combatant. This is because each linked token (one per player page) has its own turn order entry.\n\nThe HUD **deduplicates** these automatically and shows one pin per combatant. The raw turn order is correct — it\'s just how Gaslight tracks initiative across pages.' }, + { prompt: '**HUD Layout — The Frame:**\n\nThe large rectangle behind the HUD is the **frame**. It defines the HUD\'s position and how many slots are visible. You\'ll learn how to customize it later.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.frameId) return; + var frame = getObj('pathv2', d.frameId); + if (!frame) return; + var pageId = frame.get('_pageid'); + var x = frame.get('x'), y = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var w = points.length >= 2 ? Math.abs(points[1][0] - points[0][0]) : 100; + var h = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 500; + ScriptKit.ping(pageId, x, y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', x + w / 2, y + h / 2, { fromX: x + w / 2 + 100, fromY: y + h / 2 + 100, color: '#ff0000' }); + } + }, + { prompt: '**HUD Layout — The Diamond:**\n\nThe diamond-shaped highlight marks the **current turn**. The pin inside it is the active combatant.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + var x = hl.get('x'), y = hl.get('y'); + var color = (d.highlightStroke || defaultInitHud.highlightStroke); + ScriptKit.ping(pageId, x, y, { color: color, moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', x - 80, y, { fromX: x - 180, fromY: y, color: '#ff0000' }); + } + }, + { prompt: '**HUD Layout — The Pins:**\n\nEach pin represents one combatant. Pins **above** the diamond have already gone this round; pins **below** are upcoming turns. The HUD scrolls to keep the current turn visible.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.entries || d.entries.length === 0) return; + // Get frame bounds to filter visible pins + var frame = d.frameId ? getObj('pathv2', d.frameId) : null; + var frameY = frame ? frame.get('y') : 0; + var points = frame ? JSON.parse(frame.get('points') || '[]') : []; + var frameH = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 5000; + var minY = frameY - frameH / 2; + var maxY = frameY + frameH / 2; + // Gather pins within frame bounds, sorted top to bottom + var pins = d.entries.map(function(e) { + var p = getObj('pin', e.tokenId); + if (!p) return null; + var center = toPinCenter(p); + return { x: center.x, y: center.y, rawY: p.get('y'), pageId: p.get('_pageid') }; + }).filter(function(p) { return p && p.rawY >= minY && p.rawY <= maxY; }); + pins.sort(function(a, b) { return a.y - b.y; }); + // Pan to the frame center first + var frameX = frame ? frame.get('x') : (pins[0] ? pins[0].x : 0); + ScriptKit.ping(pins[0].pageId, frameX, frameY, { color: 'transparent', moveAll: true, player: ctx.player }); + pins.forEach(function(p, i) { + setTimeout(function() { + ScriptKit.ping(p.pageId, p.x, p.y, { color: '#ff0000', moveAll: false, player: ctx.player }); + }, (i + 1) * 500); + }); + } + }, + { prompt: '**Gestures — Next turn:**\n\nSwipe the **current turn** pin (the one inside the diamond) to the **right** to advance to the next combatant.\n\nTry it now.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId || !d.entries || d.entries.length === 0) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + // Find the current turn pin (closest to highlight Y) + var hlY = hl.get('y'); + var current = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(Boolean).sort(function(a, b) { return Math.abs(a.y - hlY) - Math.abs(b.y - hlY); })[0]; + if (!current) return; + var center = toPinCenter(current.pin); + ScriptKit.ping(pageId, center.x, center.y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); + } + }, + { prompt: '**Gestures — Previous turn:**\n\nSwipe the **current turn** pin to the **left** to go back to the previous combatant.\n\nTry it now.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId || !d.entries || d.entries.length === 0) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + var hlY = hl.get('y'); + var current = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(Boolean).sort(function(a, b) { return Math.abs(a.y - hlY) - Math.abs(b.y - hlY); })[0]; + if (!current) return; + var center = toPinCenter(current.pin); + ScriptKit.ping(pageId, center.x, center.y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', center.x - 100, center.y, { fromX: center.x - 20, fromY: center.y, color: '#ff4444' }); + } + }, + { prompt: '**Gestures — Jump to turn:**\n\nSwipe any **non-current** pin to the **right** to jump forward to that combatant\'s turn. Swipe **left** to jump backward to it.\n\nTry swiping a non-current pin.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId || !d.entries || d.entries.length < 2) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + var hlY = hl.get('y'); + var frame = d.frameId ? getObj('pathv2', d.frameId) : null; + var frameY = frame ? frame.get('y') : 0; + var points = frame ? JSON.parse(frame.get('points') || '[]') : []; + var frameH = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 5000; + var minY = frameY - frameH / 2; + var maxY = frameY + frameH / 2; + // Find a non-current pin that's in the frame (smallest Y = topmost) + var nonCurrent = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(function(e) { return e && Math.abs(e.y - hlY) > 30 && e.y >= minY && e.y <= maxY; }).sort(function(a, b) { return a.y - b.y; })[0]; + if (!nonCurrent) return; + var center = toPinCenter(nonCurrent.pin); + ScriptKit.ping(pageId, center.x, center.y, { color: '#ff0000', moveAll: false, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); + } + }, + { prompt: '**Gestures — Reordering:**\n\nDrag a HUD pin **up or down** to reorder it in initiative. The pin will snap into its new position.\n\nTry dragging a pin to a different slot.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.entries || d.entries.length < 2) return; + var frame = d.frameId ? getObj('pathv2', d.frameId) : null; + var frameY = frame ? frame.get('y') : 0; + var points = frame ? JSON.parse(frame.get('points') || '[]') : []; + var frameH = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 5000; + var minY = frameY - frameH / 2; + var maxY = frameY + frameH / 2; + var visible = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(function(e) { return e && e.y >= minY && e.y <= maxY; }); + if (visible.length < 2) return; + var pin = visible[visible.length - 1]; + var pageId = pin.pin.get('_pageid'); + var center = toPinCenter(pin.pin); + ScriptKit.ping(pageId, center.x, center.y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', center.x, center.y - 80, { fromX: center.x, fromY: center.y - 10, color: '#00ccff' }); + ScriptKit.annotate(pageId, 'arrow', center.x, center.y + 80, { fromX: center.x, fromY: center.y + 10, color: '#00ccff' }); + } + }, + { prompt: '**Current Turn Reticle:**\n\nThe rectangle on the map highlighting the current turn\'s token is the **reticle**. It follows the active combatant wherever they are.\n\n• **Drag up/down** on the reticle to offset it from the token\n• **Resize** the reticle to change its proportional size\n• **Rotate** it to add a rotation offset\n• **Delete** the reticle to turn it off (or `!gaslight hud reticle off`)', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.reticleData; + if (!d || !d.id) return; + var reticle = getObj('pathv2', d.id); + if (!reticle) return; + var pageId = reticle.get('_pageid'); + var x = reticle.get('x'), y = reticle.get('y'); + ScriptKit.ping(pageId, x, y, { color: 'transparent', moveAll: true, player: ctx.player }); + } + }, + { prompt: '**Customization — Scaling:**\n\nResize any HUD pin and all pins scale together. The padding between pins stays fixed.\n\nTry resizing one of the pins.' }, + { prompt: '**Customization — Text:**\n\nThe initiative value text next to each pin can be customized:\n\n• Change **font**, **size**, **color**, or **stroke** directly on any text element in Roll20 — all will update to match\n• **Drag** a text element to adjust its position relative to the frame (closer, further, up, down)\n• **Rotate** it to change the text angle\n\nTry changing the font or position of one of the text elements.' }, + { prompt: '**The Frame — Vertical resize:**\n\nNotice that not all 6+ pins fit in the frame — some are hidden. **Resize the frame vertically** (drag its bottom edge down) to reveal more slots.\n\nTry making the frame taller to show all your pins.' }, + { prompt: '**The Frame — Diamond position:**\n\nYou can drag the **diamond highlight up or down** within the frame to shift where the current turn is displayed. Pins above and below will adjust accordingly.\n\nTry moving the diamond to a different position in the frame.' }, + { prompt: '**The Frame — Other customization:**\n\nThe frame supports additional tweaks:\n\n• **Drag** the frame to move the entire HUD\n• **Resize horizontally** to adjust padding between pins\n• Change the frame\'s **stroke color** or **fill** directly in Roll20\'s shape properties\n\nThe **diamond** can also be customized:\n\n• Change its **color**, **stroke width**, **fill**, or **rotation** directly in Roll20\'s shape properties\n\nThe HUD remembers all of these choices.' }, + { prompt: '**Reticle — Inheritance:**\n\nThe reticle inherits its **color** and **stroke width** from the diamond. It also inherits **rotation**, offset by 45° (so the diamond\'s 45° rotation becomes the reticle\'s 90°, etc.).\n\nIf you changed the diamond\'s appearance in the previous steps, the reticle should reflect those changes.\n\nTo override the reticle independently, just change its properties directly. Once overridden, it stops inheriting that property from the diamond.' }, + { prompt: '**Gestures — Removing:**\n\nDelete a HUD pin to remove that combatant from initiative. Try deleting one now.' }, + { prompt: '**Clearing initiative:**\n\nNow clear the rest of the initiative — delete the remaining HUD pins or clear initiative from Roll20\'s Turn Tracker panel.\n\nClear initiative before continuing.', + when: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + return turnOrder.length > 0; + }, + onEnter: (ctx, advance) => { + ctx._clearFired = false; + on('change:campaign:turnorder', function() { + if (ctx._clearFired) return; + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length === 0) { ctx._clearFired = true; advance(); } + }); + }, + onContinue: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; + } + }, + { prompt: '**Plugin-added initiative:**\n\nIf you use a plugin (GroupInitiative, etc.) to roll initiative, go ahead and use it now.\n\nIf you don\'t have one, select token(s) and click **Continue** — Gaslight will add them to initiative for you.', + onContinue: (ctx) => { + var s = state[SCRIPT_NAME]; + var entries = (s.hud.initData && s.hud.initData.entries) || []; + if (entries.length > 0) return 'HUD still has pins from before. Clear initiative first (previous step).'; + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length >= 1) return; // Plugin already added them + var tokens = (ctx.msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) return 'No tokens in initiative and none selected. Either use a plugin or select token(s).'; + // Add selected tokens to initiative + tokens.forEach(function(t) { + var roll = Math.ceil(Math.random() * 20); + turnOrder.push({ id: t.get('id'), pr: roll, custom: '', _pageid: t.get('_pageid') }); + }); + turnOrder.sort(function(a, b) { return (b.pr || 0) - (a.pr || 0); }); + Campaign().set('turnorder', JSON.stringify(turnOrder)); + } + }, + { prompt: '**The problem:** When a plugin adds tokens to initiative, it sets `Campaign().set(\'turnorder\')` — but Gaslight cannot detect that change automatically.\n\nTo sync the HUD with the current turn order, run:\n\n`!gaslight init`\n\nThis is needed whenever initiative changes via a plugin (not via the HUD gestures).', + ...ScriptKit.waitForCommand('!gaslight init') + }, + { prompt: '**Reset:**\n\nIf the HUD ever gets into a weird state (misplaced elements, corrupted data), you can reset it:\n\n`!gaslight hud init reset` — destroys and recreates the initiative HUD from defaults\n`!gaslight hud reticle reset` — destroys and recreates the reticle from defaults\n\nThis preserves your turn order but resets all visual customization.' }, + { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` — hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` — shows it again with your saved customization\n`!gaslight hud reticle off` / `on` — same for the reticle independently\n`!gaslight hud off` / `on` — toggles all HUD elements at once' }, + { prompt: '**⚠️ Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ⚠️ icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, + { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` — toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` — toggle/reset the reticle\n`!gaslight init` — sync turn order into HUD\n`!gaslight init sync` — add missing linked tokens\n`!gaslight init trim` — remove stale entries\n\n**That\'s the initiative HUD!**', + offerExamples: ['relay', 'scripting'] + }, + ], + }); + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'stealth', description: 'Hide/show NPCs per player based on passive perception vs stealth DC (RollCapture)', @@ -4701,6 +4952,27 @@ var Gaslight = Gaslight || (() => { stroke: '#000000', }; + /** + * Convert a pin's x/y (the "point" where the teardrop points) to its visual center. + * Pin scale 0.25 = 10px, scale 2.0 = 80px → size = scale * 40. + * Visual center is offset upward by half the pin's visual height. + */ + const toPinCenter = (pin) => { + var scale = pin.get('scale') || 1; + var size = scale * 40; + return { x: pin.get('x'), y: pin.get('y') - size / 2 }; + }; + + /** + * Convert a target center coordinate to the pin x/y that would place + * the pin's visual center at that point. + */ + const toPinPosition = (x, y, scale) => { + scale = scale || 1; + var size = scale * 40; + return { x: x, y: y + size / 2 }; + }; + const defaultInitHud = { tokenSize: 50, tokenPadding: 20, @@ -6077,8 +6349,7 @@ var Gaslight = Gaslight || (() => { if (newScale !== oldScale && newScale != null) { newScale = Math.max(0.25, Math.min(2.0, newScale)); obj.set('scale', newScale); - var baseTokenSize = defaultInitHud.tokenSize; - data.tokenSize = Math.round(baseTokenSize * newScale); + data.tokenSize = Math.round(40 * newScale); // Apply scale to all HUD entry pins data.entries.forEach(function(e) { var pin = getObj('pin', e.tokenId); From b545576bc140ac778591e1b6381f2de034a7661e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 24 Jul 2026 12:17:38 -0400 Subject: [PATCH 20/37] Gaslight: HUD proportional slot distribution, slot-snapped diamond, temporary offset for extra space --- Gaslight/Gaslight.js | 78 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index db0f91f38..69c405be7 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -5004,6 +5004,39 @@ var Gaslight = Gaslight || (() => { return hudSlotY(frameCenter, offset, tokenSize, tokenPadding) + tokenSize / 2; }; + /** + * Compute proportionally distributed offsets for HUD entries. + * Entry 0 = offset 0 (diamond). Remaining entries alternate below/above + * proportional to available slots on each side. + * If one side runs out of room, stack on the other side. + * Returns an array of offsets, one per entry. + */ + const computeHudOffsets = (count, slotsBelow, slotsAbove) => { + var offsets = [0]; + var below = 0, above = 0; + var totalSlots = slotsBelow + slotsAbove; + if (totalSlots === 0) return offsets; + + for (var i = 1; i < count; i++) { + // Determine which side should get the next entry based on fill ratio + var belowRatio = slotsBelow > 0 ? below / slotsBelow : 1; + var aboveRatio = slotsAbove > 0 ? above / slotsAbove : 1; + + if (belowRatio <= aboveRatio) { + // Below is less filled proportionally — try below + if (below < slotsBelow) { below++; offsets.push(below); } + else if (above < slotsAbove) { above++; offsets.push(-above); } + else offsets.push(below + above + 1); // overflow (hidden) + } else { + // Above is less filled proportionally — try above + if (above < slotsAbove) { above++; offsets.push(-above); } + else if (below < slotsBelow) { below++; offsets.push(below); } + else offsets.push(below + above + 1); // overflow (hidden) + } + } + return offsets; + }; + /** * Compute token size from frame width or stored override. */ @@ -5158,7 +5191,11 @@ var Gaslight = Gaslight || (() => { var vPadHL = data.vPadding || defaultInitHud.vPadding; var usableTopHL = frame.get('y') - fH / 2 + hlTokenSize / 2 + vPadHL; var usableBotHL = frame.get('y') + fH / 2 - hlTokenSize / 2 - vPadHL; - var hlY = usableTopHL + (usableBotHL - usableTopHL) * hlOffset; + var hlStep = hlTokenSize + (data.tokenPadding || defaultInitHud.tokenPadding); + var hlMid = (usableTopHL + usableBotHL) / 2; + var rawHlCenter = usableTopHL + (usableBotHL - usableTopHL) * hlOffset; + var hlSlotFromCenter = hlStep > 0 ? Math.round((rawHlCenter - hlMid) / hlStep) : 0; + var hlY = hlMid + hlSlotFromCenter * hlStep; var highlight = createHudObj('pathv2', { _pageid: pageId, layer: 'foreground', @@ -5324,14 +5361,43 @@ var Gaslight = Gaslight || (() => { var vPadding = data.vPadding || defaultInitHud.vPadding; var frameTopEdge = frameTop - frameHeight / 2 + vPadding; var frameBotEdge = frameTop + frameHeight / 2 - vPadding; - var frameCenter = (frameTopEdge + tokenSize / 2) + (frameBotEdge - frameTopEdge - tokenSize) * (data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset); + var step = tokenSize + tokenPadding; + var frameMid = (frameTopEdge + frameBotEdge) / 2; + var rawOffset = data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset; + var rawCenter = (frameTopEdge + tokenSize / 2) + (frameBotEdge - frameTopEdge - tokenSize) * rawOffset; + // Snap to nearest slot from frame center + var slotFromCenter = step > 0 ? Math.round((rawCenter - frameMid) / step) : 0; + var frameCenter = frameMid + slotFromCenter * step; // Calculate how many slots fit below and above the indicator var slotsBelow = 0; var slotsAbove = 0; - var step = tokenSize + tokenPadding; while (frameCenter + (slotsBelow + 1) * step + tokenSize / 2 <= frameBotEdge) slotsBelow++; while (frameCenter - (slotsAbove + 1) * step - tokenSize / 2 >= frameTopEdge) slotsAbove++; + // Temporary diamond offset: if there are hidden entries and the combined + // "extra" space (fractional leftover on each side) can fit one more slot, + // shift the diamond to make room. + var totalVisible = 1 + slotsBelow + slotsAbove; + if (order.length > totalVisible) { + var extraBelow = frameBotEdge - (frameCenter + slotsBelow * step + tokenSize / 2); + var extraAbove = (frameCenter - slotsAbove * step - tokenSize / 2) - frameTopEdge; + if (extraBelow + extraAbove >= step) { + // We can fit one more. Shift diamond toward the side with more slots. + var shiftAmount; + if (slotsBelow >= slotsAbove) { + // More below (or equal) — shift down to make room above + shiftAmount = Math.min(step - extraAbove, extraBelow); + frameCenter += shiftAmount; + slotsAbove++; + } else { + // More above — shift up to make room below + shiftAmount = Math.min(step - extraBelow, extraAbove); + frameCenter -= shiftAmount; + slotsBelow++; + } + } + } + // Update highlight position and size var highlight = data.highlightId ? getObj('pathv2', data.highlightId) : null; if (highlight) { @@ -5413,6 +5479,7 @@ var Gaslight = Gaslight || (() => { }); // Reflow only token entries by ID + var tokenOffsets = computeHudOffsets(order.length, slotsBelow, slotsAbove); order.forEach(function(entry, i) { if (!entry.id || entry.id === '-1') return; var hudEntry = tokenMap[entry.id]; @@ -5422,7 +5489,7 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!pin) return; - var offset = i <= slotsBelow ? i : i - order.length; + var offset = tokenOffsets[i] !== undefined ? tokenOffsets[i] : i; var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); @@ -5469,6 +5536,7 @@ var Gaslight = Gaslight || (() => { sortedCustoms = sortedCustoms.concat(hiddenCustoms); var customIdx = 0; + var offsets = computeHudOffsets(order.length, slotsBelow, slotsAbove); order.forEach(function(entry, i) { var isCustom = !entry.id || entry.id === '-1'; var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; @@ -5478,7 +5546,7 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!pin) return; - var offset = i <= slotsBelow ? i : i - order.length; + var offset = offsets[i] !== undefined ? offsets[i] : i; var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); From 26b87c615cbf31810de53b1415aaa8945de1f897 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 24 Jul 2026 13:54:33 -0400 Subject: [PATCH 21/37] Gaslight: fix HUD reorder with collapse/expand approach, sequential slot assignment, slot-snapped diamond --- Gaslight/Gaslight.js | 673 ++++++++++++++++++++++++------------------- 1 file changed, 378 insertions(+), 295 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 69c405be7..db0fd2e45 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,4 +1,4 @@ -// ============================================================================= +// ============================================================================= // Gaslight v2.1.0 // Last Updated: 2026-07-05 // Author: Kenan Millet @@ -92,7 +92,7 @@ var Gaslight = Gaslight || (() => { if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: true, initiative: true, reticle: true }; if (state[SCRIPT_NAME].hud.initiative === undefined) state[SCRIPT_NAME].hud.initiative = false; if (state[SCRIPT_NAME].hud.reticle === undefined) state[SCRIPT_NAME].hud.reticle = true; - // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" + // Migration: v2.0.0 -> v2.1.0 view null used to mean "relay to all", now null means "off" if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; state[SCRIPT_NAME].version = SCRIPT_VERSION; @@ -100,7 +100,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Config Storage — GM layer text objects + // Config Storage GM layer text objects // ========================================================================= const getConfigsOnPage = (pageId) => { @@ -164,7 +164,7 @@ var Gaslight = Gaslight || (() => { const discoverGroup = (groupName) => { const pages = findObjs({ _type: 'page' }); - const result = { master: null, players: {} }; // players keyed by playerid → { pageId, name } + const result = { master: null, players: {} }; // players keyed by playerid ? { pageId, name } pages.forEach(page => { const cfg = getGroupConfigOnPage(page.get('_id'), groupName); if (!cfg) return; @@ -264,7 +264,7 @@ var Gaslight = Gaslight || (() => { return null; } - // Ambiguous — show disambiguation buttons + // Ambiguous show disambiguation buttons var out = 'Multiple players named "' + playerArg + '":
    '; matches.forEach(function(p) { var chars = findObjs({ _type: 'character' }).filter(function(c) { @@ -288,7 +288,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Token GM Notes — gaslight_link + // Token GM Notes gaslight_link // ========================================================================= const getLinkId = (token) => { @@ -469,16 +469,16 @@ var Gaslight = Gaslight || (() => { /** * Read the gaslight_sync config for a token (from gmnotes only). * Returns: - * null — not configured (default: sync all non-spatial) - * '' — explicitly empty (no sync) - * ['prop1','prop2',...] — specific props to sync + * null not configured (default: sync all non-spatial) + * '' explicitly empty (no sync) + * ['prop1','prop2',...] specific props to sync */ const getGaslightSync = (token) => { var rawVal = null; if (token && typeof token === 'object' && typeof token.get === 'function') { rawVal = getSyncConfigRaw(token); } else if (typeof token === 'string') { - // Legacy: character ID — read attribute directly (used internally only) + // Legacy: character ID read attribute directly (used internally only) var attr = findObjs({ _type: 'attribute', _characterid: token, name: 'gaslight_sync' })[0]; if (!attr) return null; rawVal = (attr.get('current') || '').trim(); @@ -589,7 +589,7 @@ var Gaslight = Gaslight || (() => { } }); - // Step 4: unlinked — only master-page represents tokens + // 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')) { @@ -610,8 +610,8 @@ var Gaslight = Gaslight || (() => { 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 + 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 = {}; @@ -656,7 +656,7 @@ var Gaslight = Gaslight || (() => { if (warnings.length === 0) return ''; var out = '
    Warnings:
    '; warnings.forEach(function(w) { - var icon = w.severity === 'error' ? '🔴' : w.severity === 'warning' ? '🟡' : 'ℹ️'; + var icon = w.severity === 'error' ? '??' : w.severity === 'warning' ? '??' : '??'; out += icon + ' ' + w.message + '
    '; }); return out; @@ -686,7 +686,7 @@ var Gaslight = Gaslight || (() => { const controlledBy = character.get('controlledby') || ''; if (!controlledBy) return null; if (controlledBy === 'all') { - // All players control it — return first group player as representative + // All players control it return first group player as representative var firstPlayer = Object.keys(groupInfo.players)[0]; return firstPlayer || null; } @@ -747,7 +747,7 @@ var Gaslight = Gaslight || (() => { // 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 + // 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; } @@ -767,7 +767,7 @@ var Gaslight = Gaslight || (() => { var ids = tokens.map(function(t) { return t.get('id'); }); - // Check gaslight_sync — read from first token's gmnotes (falls back to character attr) + // Check gaslight_sync read from first token's gmnotes (falls back to character attr) var syncProps = getGaslightSync(tokens[0]); // syncProps: null = default (base spatial), '' = no sync at all, array = specific @@ -790,7 +790,7 @@ var Gaslight = Gaslight || (() => { } mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; } - // Always pass explicit components — never let Anchor default to ALL_COMPONENTS + // Always pass explicit components never let Anchor default to ALL_COMPONENTS if (!anchorComponents) { anchorComponents = {}; allAnchorProps.forEach(function(p) { anchorComponents[p] = true; }); @@ -975,7 +975,7 @@ var Gaslight = Gaslight || (() => { 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')); + assignments.push(playerName + ' ? ' + page.get('name')); }); var out = 'Group "' + groupName + '" set up:
    '; @@ -1023,7 +1023,7 @@ var Gaslight = Gaslight || (() => { 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: ' + + 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); @@ -1146,15 +1146,15 @@ var Gaslight = Gaslight || (() => { var out = 'Link Test: ' + groupName + '
    '; Object.entries(groupInfo.players).forEach(function(entry) { var playerId = entry[0], pInfo = entry[1]; - out += '
    Master → ' + pInfo.name + ':
    '; + 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 + ')
    '; + out += '? ' + srcName + ' ? ' + tgtName + ' (step ' + l.step + ')
    '; } else { - out += '🟡 ' + srcName + ' — no match found
    '; + out += '?? ' + srcName + ' no match found
    '; } }); if (links.length === 0) out += '(no linkable tokens)
    '; @@ -1395,12 +1395,12 @@ var Gaslight = Gaslight || (() => { var label = useDefault ? 'character default' : 'token'; - // No args — show current config + // No args show current config if (args.length === 0) { var results = tokens.map(function(t) { var raw = getVal(t); var name = t.get('name') || t.get('id'); - return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); + return '' + name + ': ' + (raw === null ? '(default sync all)' : raw || '(empty no sync)'); }); reply(msg, 'Sync', results.join('
    ')); return; @@ -1408,7 +1408,7 @@ var Gaslight = Gaslight || (() => { var subCmd = args[0].toLowerCase(); - // Reset — re-copy from character attribute (only makes sense for token mode) + // Reset re-copy from character attribute (only makes sense for token mode) if (subCmd === 'reset') { if (useDefault) { reply(msg, 'Error', 'Reset only applies to token sync, not --default.'); return; } tokens.forEach(function(t) { @@ -1426,7 +1426,7 @@ var Gaslight = Gaslight || (() => { return; } - // "all" — explicitly sync everything + // "all" explicitly sync everything if (subCmd === 'all') { tokens.forEach(function(t) { setVal(t, 'all'); }); reply(msg, 'Sync', 'Set to sync all properties on ' + tokens.length + ' ' + label + '(s).'); @@ -1472,7 +1472,7 @@ var Gaslight = Gaslight || (() => { if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync [--default] '); return; } - // "all" — disable all syncing (set empty string = no sync) + // "all" disable all syncing (set empty string = no sync) if (args[0].toLowerCase() === 'all') { tokens.forEach(function(t) { setVal(t, ''); }); reply(msg, 'Desync', 'Disabled all syncing on ' + tokens.length + ' ' + label + '(s).' + (useDefault ? '' : ' Use !gaslight sync reset to restore.')); @@ -1541,7 +1541,7 @@ var Gaslight = Gaslight || (() => { } } - // Normalize names — ensure gl_ prefix + // Normalize names ensure gl_ prefix actions.forEach(function(a) { if (!a.name.startsWith('gl_')) a.name = 'gl_' + a.name; }); @@ -1623,9 +1623,9 @@ var Gaslight = Gaslight || (() => { if (target.get('_pageid') === e[1].pageId) pageName = e[1].name || e[0]; }); }); - return pageName + ': ' + (val || ''); + return pageName + ': ' + (val || ''); }); - output.push('' + tokenName + ' ' + a.name + ' — ' + vals.join(', ')); + output.push('' + tokenName + ' ' + a.name + ' ' + vals.join(', ')); } else { var target = getTargets[0]; var tNotes = target.get('gmnotes') || ''; @@ -2007,14 +2007,14 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); if (marketplaceFailures.length > 0) { - var failMsg = '⚠️ ' + marketplaceFailures.length + ' token(s) could not be staged because their images are from the Roll20 Marketplace and not in your library.

    '; + var failMsg = '?? ' + marketplaceFailures.length + ' token(s) could not be staged because their images are from the Roll20 Marketplace and not in your library.

    '; marketplaceFailures.forEach(function(f) { failMsg += '
    ' + '
    ' + '' + f.name + '
    '; }); failMsg += '

    To fix:
      ' - + '
    1. In the Art Library tab, find the asset under Premium Assets → Marketplace Purchases, right-click it (or the folder), and select Copy to Library.
    2. ' + + '
    3. In the Art Library tab, find the asset under Premium Assets ? Marketplace Purchases, right-click it (or the folder), and select Copy to Library.
    4. ' + '
    5. Replace the token(s) on the master page with the library copy (drag from your library).
    6. ' + '
    7. Re-run !gaslight stage with the new token(s) selected.
    8. ' + '
    '; @@ -2218,7 +2218,7 @@ var Gaslight = Gaslight || (() => { if (!entry[1].master) dangling.push(entry[0]); }); if (dangling.length > 0) { - var out = '⚠️ Dangling groups with no master page:
    '; + var out = '?? Dangling groups with no master page:
    '; dangling.forEach(function(gn) { out += '' + gn + ': '; out += '!gaslight ungroup ' + gn + ' --all to remove, or '; @@ -2258,7 +2258,7 @@ var Gaslight = Gaslight || (() => { + 'Aliases: init/turn/turns = initiative, relay = view
    '; // ========================================================================= - // Scripting Engine — Fetch Integration + // Scripting Engine Fetch Integration // ========================================================================= // Module-level evaluation context for Fetch compProp resolution @@ -2334,10 +2334,10 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Scripting Engine — Trigger Map + // Scripting Engine Trigger Map // ========================================================================= - // triggerMap: attributeName → [{ pinId, pageId }] + // triggerMap: attributeName ? [{ pinId, pageId }] var triggerMap = {}; var knownScriptPins = []; // cached pin IDs + handout links from last buildTriggerMap @@ -2350,7 +2350,7 @@ var Gaslight = Gaslight || (() => { // 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) + // Find content inside {& if ...} blocks (simple regex catches most cases) var ifRx = /\{&\s*if\s+(.+?)\}/gi; var match; while ((match = ifRx.exec(text)) !== null) { @@ -2450,7 +2450,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle attribute changes — check trigger map and re-evaluate affected pins. + * Handle attribute changes check trigger map and re-evaluate affected pins. */ const onAttributeChanged = (obj) => { var attrName = obj.get('name'); @@ -2466,7 +2466,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle token property changes — check trigger map for graphic properties. + * 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'; }); @@ -2551,7 +2551,7 @@ var Gaslight = Gaslight || (() => { // ========================================================================= /** - * Read a handout's notes content (async → callback pattern). + * Read a handout's notes content (async ? callback pattern). * Returns content via callback since Roll20 requires it for notes/gmnotes. */ const getHandoutContent = (handoutId, callback) => { @@ -2719,7 +2719,7 @@ 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 ──────────────────────────────────────────────────── + // --- Viewer Aggregation ---------------------------------------------------- const OPS = ['>=', '<=', '!=', '!~', '=', '~', '>', '<']; @@ -2936,7 +2936,7 @@ var Gaslight = Gaslight || (() => { evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page var content = scriptContent; - // Replace remaining @(target.*) with token ID — Fetch resolves native props + // Replace remaining @(target.*) with token ID Fetch resolves native props content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); // Replace @(gm_target.*) with master page token ID content = content.replace(/@\(gm_target\./g, '@(' + targetToken.get('id') + '.'); @@ -2969,11 +2969,11 @@ var Gaslight = Gaslight || (() => { // 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()'); + 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()'); + whisper('?? Script error: @(gm.*) must be inside any(), all(), max(), or min()'); return; } @@ -3306,7 +3306,7 @@ var Gaslight = Gaslight || (() => { }); } - // Token assignment — only count tokens representing this character + // 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; }); @@ -3382,33 +3382,33 @@ var Gaslight = Gaslight || (() => { '
      ', '
    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. ', + '
    13. Select party tokens on the master page, run: !gaslight setup mygroup this auto-detects duplicates, assigns pages to players, and configures the group.
    14. ', + '
    15. Run !gaslight test mygroup dry-run that shows how tokens will link without activating anything. Fix any warnings before proceeding.
    16. ', + '
    17. Run !gaslight split mygroup activates the group: links tokens across pages, moves players to their individual pages, and begins syncing.
    18. ', + '
    19. When done: !gaslight merge tears down all links, returns players to the banner page.
    20. ', '
    ', '

    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

    ', + '

    !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).

    ', + '

    !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:

    ', '
      ', @@ -3419,15 +3419,15 @@ var Gaslight = Gaslight || (() => { '

      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.
      • ', + '
      • 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.

      ', + '

      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.

      ', @@ -3437,16 +3437,16 @@ var Gaslight = Gaslight || (() => { '
      // Comments start with //\n!token-mod --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)
      • ', + '
      • @(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
      • ', + '
      • 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.

      ', @@ -3467,14 +3467,14 @@ var Gaslight = Gaslight || (() => { aliases: { help: ['help', '--help'], man: 'man', examples: 'examples', whatsnew: 'whatsnew', genHelp: 'gen-help', genDev: 'gen-dev-docs' }, newSince: '2.1.0', help: { - description: 'Per-player map perception. Split players onto individual page copies with synchronized tokens — each player can see different things while movement stays consistent.', + description: 'Per-player map perception. Split players onto individual page copies with synchronized tokens each player can see different things while movement stays consistent.', quickStart: [ 'Create your master page with all tokens placed.', 'Duplicate it once per player (Roll20 built-in Duplicate Page).', - 'Select party tokens on the master page, run `!gaslight setup mygroup` — auto-detects duplicates, assigns pages, configures the group.', - '`!gaslight test mygroup` — dry-run showing how tokens will link. Fix any warnings.', - '`!gaslight split mygroup` — activates the group: links tokens, moves players, begins syncing.', - 'When done: `!gaslight merge` — tears down all links, returns players.', + 'Select party tokens on the master page, run `!gaslight setup mygroup` auto-detects duplicates, assigns pages, configures the group.', + '`!gaslight test mygroup` dry-run showing how tokens will link. Fix any warnings.', + '`!gaslight split mygroup` activates the group: links tokens, moves players, begins syncing.', + 'When done: `!gaslight merge` tears down all links, returns players.', ], changelog: [ { version: '2.1.0', changes: [ @@ -3485,7 +3485,7 @@ var Gaslight = Gaslight || (() => { 'Scripting engine fixes: anchor component fix, triggerMap auto-rebuild', ]}, { version: '2.0.0', changes: [ - 'Scripting engine — reactive per-player automation via map pins', + 'Scripting engine reactive per-player automation via map pins', 'RollCapture integration for automatic trigger evaluation', 'Auto-relay system for cross-page command forwarding', 'Aggregation functions: any(), all(), max(), min(), join()', @@ -3498,7 +3498,7 @@ var Gaslight = Gaslight || (() => { details: 'Auto-detects page copies by name, assigns master + player pages. Players resolved from selected tokens or named explicitly.', items: [ { name: '', description: 'Name for the group configuration', version: '2.0.0' }, - { name: '[players...]', description: 'Player names to include (optional — auto-detected from selected tokens or party-tagged characters)', version: '2.0.0' }, + { name: '[players...]', description: 'Player names to include (optional auto-detected from selected tokens or party-tagged characters)', version: '2.0.0' }, ]}, { syntax: 'split [--force]', description: 'Activate a prepared group', version: '1.0.0', details: 'Links tokens across pages, moves players to individual pages, begins syncing. Runs test-first unless --force.', @@ -3578,7 +3578,7 @@ var Gaslight = Gaslight || (() => { { name: 'Step 1: gaslight_link', description: 'Explicit match via gaslight_link ID in token GM notes', version: '1.0.0' }, { name: 'Step 2: represents + name', description: 'Same character + token name (unique pair per page)', version: '1.0.0' }, { name: 'Step 3: represents + fingerprint', description: 'Same character + position/property fingerprint', version: '1.0.0' }, - { name: 'Step 4: Unlinked', description: 'Has represents but no match found — not synced', version: '1.0.0' }, + { name: 'Step 4: Unlinked', description: 'Has represents but no match found not synced', version: '1.0.0' }, ], }, sync: { @@ -3599,12 +3599,12 @@ var Gaslight = Gaslight || (() => { description: 'Reactive per-player automation via map pins', version: '2.0.0', details: 'Scripts are stored in handouts and activated by placing map pins on the page. Each pin evaluates per-viewer per-target with conditional logic to fire different commands for different players.', - body: 'Create a handout with script content. Place a map pin on the page — title it with the handout name (prefix [GLS] is stripped). The pin evaluates whenever triggered attributes change.', + body: 'Create a handout with script content. Place a map pin on the page title it with the handout name (prefix [GLS] is stripped). The pin evaluates whenever triggered attributes change.', items: [ { name: '@(target.field)', description: 'Reference a target token field (bar values, GM notes gl_* vars)', version: '2.0.0' }, { name: '@(viewer.field)', description: 'Reference the viewer\'s token field', version: '2.0.0' }, { name: '@(gm.field)', description: 'Reference the GM master token field', version: '2.0.0' }, - { name: '{& if condition}...{& end}', description: 'Conditional block — evaluates per viewer', version: '2.0.0' }, + { name: '{& if condition}...{& end}', description: 'Conditional block evaluates per viewer', version: '2.0.0' }, { name: '{& select target_id}', description: 'Target selection (SelectManager integration)', version: '2.0.0' }, { name: 'any(), all(), max(), min()', description: 'Aggregate functions across viewer tokens', version: '2.0.0' }, { name: 'join()', description: 'Space-separated IDs of matching viewer tokens', version: '2.0.0' }, @@ -3644,7 +3644,7 @@ var Gaslight = Gaslight || (() => { title: 'Troubleshooting', description: 'Common issues and solutions', version: '1.0.0', - body: '**Tokens not linking** — Run `!gaslight test ` to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.\n\n**Movement not syncing** — Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with `!gaslight status`.\n\n**Relay not working** — Confirm the command is in the relay list: `!gaslight config relay-list`. Commands must start with ! to be intercepted.\n\n**Scripts not evaluating** — Pin must be on a page with an active split. Check pin title matches handout name. Run `!gaslight eval --dry-run` to debug.\n\n**Players see the same thing** — Make sure the group is actually split (`!gaslight status`). Player-specific changes must be made on the player\'s page, not the master.', + body: '**Tokens not linking** Run `!gaslight test ` to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.\n\n**Movement not syncing** Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with `!gaslight status`.\n\n**Relay not working** Confirm the command is in the relay list: `!gaslight config relay-list`. Commands must start with ! to be intercepted.\n\n**Scripts not evaluating** Pin must be on a page with an active split. Check pin title matches handout name. Run `!gaslight eval --dry-run` to debug.\n\n**Players see the same thing** Make sure the group is actually split (`!gaslight status`). Player-specific changes must be made on the player\'s page, not the master.', }, }, }, @@ -3673,16 +3673,16 @@ var Gaslight = Gaslight || (() => { description: 'Set up your first per-player split: pages, setup, split, and merge (guide only)', guide: [ { prompt: 'This guide walks you through creating your first **per-player split**.\n\nYou\'ll need at least 2 players in your game, but those players do not need to be online.' }, - { prompt: 'Create your **master page** with all tokens placed (NPCs, player characters, objects). This page is the GM\'s "ground truth" — all changes start here.' }, - { prompt: 'Duplicate the page **once per player** using Roll20\'s built-in **Duplicate Page** button. Leave the page names as they are — the *"Copy of"* prefix is how Gaslight auto-detects them.' }, - { prompt: 'Navigate to the master page and run `!gaslight setup mygroup` using one of these methods:\n\n**Option 1:** Select player-character tokens on the master page, then run `!gaslight setup mygroup` — uses controlling players of selected tokens.\n\n**Option 2:** Set the master page as the banner page and run `!gaslight setup mygroup "Player1" "Player2" ...` with no selection — uses the specified player names.\n\n**Option 3:** Define a Roll20 party, set the master page as the banner page, and run `!gaslight setup mygroup` with no selection — uses controlling players of party tokens.\n\nAll options auto-detect duplicated pages and assign one per player.', + { prompt: 'Create your **master page** with all tokens placed (NPCs, player characters, objects). This page is the GM\'s "ground truth" all changes start here.' }, + { prompt: 'Duplicate the page **once per player** using Roll20\'s built-in **Duplicate Page** button. Leave the page names as they are the *"Copy of"* prefix is how Gaslight auto-detects them.' }, + { prompt: 'Navigate to the master page and run `!gaslight setup mygroup` using one of these methods:\n\n**Option 1:** Select player-character tokens on the master page, then run `!gaslight setup mygroup` uses controlling players of selected tokens.\n\n**Option 2:** Set the master page as the banner page and run `!gaslight setup mygroup "Player1" "Player2" ...` with no selection uses the specified player names.\n\n**Option 3:** Define a Roll20 party, set the master page as the banner page, and run `!gaslight setup mygroup` with no selection uses controlling players of party tokens.\n\nAll options auto-detect duplicated pages and assign one per player.', ...ScriptKit.waitForCommand('!gaslight setup'), onContinue: () => { var groups = discoverAllGroups(); if (Object.keys(groups).length === 0) return 'No group configured yet. Run `!gaslight setup ` using one of the methods above.'; } }, - { prompt: 'Now run:\n\n`!gaslight split mygroup`\n\nThis activates the group — players are moved to their individual pages and token syncing begins.', + { prompt: 'Now run:\n\n`!gaslight split mygroup`\n\nThis activates the group players are moved to their individual pages and token syncing begins.', ...ScriptKit.waitForCommand('!gaslight split'), onContinue: () => { if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first.'; @@ -3691,8 +3691,8 @@ var Gaslight = Gaslight || (() => { { prompt: 'You may notice a **HUD** appeared on the master page. We\'ll cover that in a later example. For now, disable it with:\n\n`!gaslight hud off`', ...ScriptKit.waitForCommand('!gaslight hud') }, - { prompt: 'Your split is now active. Try moving an **NPC token** on the master page — it syncs to all player pages automatically. NPC tokens only sync when updated on the master page (one-directional).\n\nThis means you can change an NPC on a specific player\'s page without affecting anyone else — useful for hiding tokens, swapping images, or showing per-player information.' }, - { prompt: 'Now try moving a **player-controlled token** on that player\'s page. It syncs back to the master and out to other player pages — players can move their own tokens and everyone sees it (bidirectional).' }, + { prompt: 'Your split is now active. Try moving an **NPC token** on the master page it syncs to all player pages automatically. NPC tokens only sync when updated on the master page (one-directional).\n\nThis means you can change an NPC on a specific player\'s page without affecting anyone else useful for hiding tokens, swapping images, or showing per-player information.' }, + { prompt: 'Now try moving a **player-controlled token** on that player\'s page. It syncs back to the master and out to other player pages players can move their own tokens and everyone sees it (bidirectional).' }, { prompt: 'When you\'re done with the split (e.g. combat ends, scene changes), run:\n\n`!gaslight merge`.\n\nThis tears down all links and returns players to the banner page.' }, { prompt: '**That\'s the basics!** From here, explore the other examples to learn more about how to use gaslight.', offerExamples: ['core-mechanics', 'initiative-hud', 'relay', 'scripting'] }, ], @@ -3703,7 +3703,7 @@ var Gaslight = Gaslight || (() => { description: 'Staging, linking, syncing, and token lifecycle', source: SCRIPT_NAME, guide: [ - { prompt: '**Core Mechanics** — This guide walks through the fundamental operations that make Gaslight tick: staging tokens, managing links, controlling sync, and what happens when tokens are deleted.\n\n**Prerequisite:** You need an active split. If you haven\'t set one up yet, run the **getting-started** guide first.', + { prompt: '**Core Mechanics** This guide walks through the fundamental operations that make Gaslight tick: staging tokens, managing links, controlling sync, and what happens when tokens are deleted.\n\n**Prerequisite:** You need an active split. If you haven\'t set one up yet, run the **getting-started** guide first.', onContinue: () => { if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first, or complete the getting-started guide.'; } @@ -3724,11 +3724,11 @@ var Gaslight = Gaslight || (() => { if (!allStaged) return 'The previously selected token has not been staged yet. Select it and run `!gaslight stage`.'; } }, - { prompt: 'Your token is now staged — copies exist on each player page and sync automatically.\n\nYou can also set a **default** so tokens representing a character auto-stage whenever placed on a gaslighted page:\n\n`!gaslight stage --default on`\n\nTo remove the default: `!gaslight stage --default off`' }, + { prompt: 'Your token is now staged copies exist on each player page and sync automatically.\n\nYou can also set a **default** so tokens representing a character auto-stage whenever placed on a gaslighted page:\n\n`!gaslight stage --default on`\n\nTo remove the default: `!gaslight stage --default off`' }, { prompt: '**Unlinking** disconnects a player\'s copy from the master without deleting it. Useful for making permanent per-player differences.\n\nSelect a staged token on the master page and run:\n\n`!gaslight unlink`', ...ScriptKit.waitForCommand('!gaslight unlink') }, - { prompt: 'The player copy now lives independently — changes on the master won\'t reach it.' }, + { prompt: 'The player copy now lives independently changes on the master won\'t reach it.' }, { prompt: '**Relinking** re-establishes the connection. Select the same master token and run:\n\n`!gaslight link`\n\nThe player copy snaps back to match the master.', ...ScriptKit.waitForCommand('!gaslight link') }, @@ -3738,8 +3738,8 @@ var Gaslight = Gaslight || (() => { { prompt: '**Resyncing** resumes sync for a desynced property. The player copy snaps back to the master\'s current value.\n\nSelect the same token and run:\n\n`!gaslight sync left,top`', ...ScriptKit.waitForCommand('!gaslight sync') }, - { prompt: 'You can also set sync/desync **defaults** on a character so new tokens inherit the config:\n\n`!gaslight sync --default all` — sync everything (the default)\n`!gaslight sync --default left,top,bar1_value` — sync only these props\n`!gaslight desync --default left,top` — exclude position from sync\n`!gaslight desync --default all` — disable all syncing by default\n\nUse `!gaslight sync reset` on a token to re-read the character default.' }, - { prompt: '**Token deletion behavior:**\n\n• Delete a **parent token** (master page, or controlling player\'s page for PCs) → all linked copies are removed\n• Delete a **non-parent copy** (e.g. an NPC on a player page) → only that copy is removed, others remain' }, + { prompt: 'You can also set sync/desync **defaults** on a character so new tokens inherit the config:\n\n`!gaslight sync --default all` sync everything (the default)\n`!gaslight sync --default left,top,bar1_value` sync only these props\n`!gaslight desync --default left,top` exclude position from sync\n`!gaslight desync --default all` disable all syncing by default\n\nUse `!gaslight sync reset` on a token to re-read the character default.' }, + { prompt: '**Token deletion behavior:**\n\n Delete a **parent token** (master page, or controlling player\'s page for PCs) ? all linked copies are removed\n Delete a **non-parent copy** (e.g. an NPC on a player page) ? only that copy is removed, others remain' }, { prompt: '**That\'s the core!** You now know how to stage, link/unlink, sync/desync, and manage token lifecycle.\n\nNext, learn how the initiative HUD works or how to relay commands to player pages.', offerExamples: ['initiative-hud', 'relay', 'scripting'] }, @@ -3748,17 +3748,17 @@ var Gaslight = Gaslight || (() => { ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'initiative-hud', - description: 'Interactive initiative tracker HUD — gestures, customization, reticle', + description: 'Interactive initiative tracker HUD gestures, customization, reticle', source: SCRIPT_NAME, guide: [ - { prompt: '**Initiative HUD** — This guide walks through the on-screen initiative tracker: how to interact with it, customize its appearance, and use the current turn reticle.\n\n**Prerequisites:** You need an active split with the HUD enabled.', + { prompt: '**Initiative HUD** This guide walks through the on-screen initiative tracker: how to interact with it, customize its appearance, and use the current turn reticle.\n\n**Prerequisites:** You need an active split with the HUD enabled.', onContinue: () => { var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; if (!s.hud.initiative) return 'Initiative HUD is disabled. Run `!gaslight hud init on` to enable it.'; } }, - { prompt: 'Before we begin, clear any existing entries from the turn order. You can do this from Roll20\'s Turn Tracker panel (⚙).\n\nClear the turn order and click Continue.', + { prompt: 'Before we begin, clear any existing entries from the turn order. You can do this from Roll20\'s Turn Tracker panel (?).\n\nClear the turn order and click Continue.', when: () => { var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); return turnOrder.length > 0; @@ -3776,7 +3776,7 @@ var Gaslight = Gaslight || (() => { if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; } }, - { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (⚙ in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative — we\'ll cover that later.', + { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (? in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative we\'ll cover that later.', onEnter: (ctx, advance) => { ctx._turnFired = false; on('change:campaign:turnorder', function() { @@ -3792,8 +3792,8 @@ var Gaslight = Gaslight || (() => { if (entries.length < 6) return 'Only ' + entries.length + ' token(s) in the HUD. Add at least 6.'; } }, - { prompt: '**Why duplicates?**\n\nYou may notice the Roll20 Turn Tracker panel shows what appears to be duplicate entries — multiple entries for the same combatant. This is because each linked token (one per player page) has its own turn order entry.\n\nThe HUD **deduplicates** these automatically and shows one pin per combatant. The raw turn order is correct — it\'s just how Gaslight tracks initiative across pages.' }, - { prompt: '**HUD Layout — The Frame:**\n\nThe large rectangle behind the HUD is the **frame**. It defines the HUD\'s position and how many slots are visible. You\'ll learn how to customize it later.', + { prompt: '**Why duplicates?**\n\nYou may notice the Roll20 Turn Tracker panel shows what appears to be duplicate entries multiple entries for the same combatant. This is because each linked token (one per player page) has its own turn order entry.\n\nThe HUD **deduplicates** these automatically and shows one pin per combatant. The raw turn order is correct it\'s just how Gaslight tracks initiative across pages.' }, + { prompt: '**HUD Layout The Frame:**\n\nThe large rectangle behind the HUD is the **frame**. It defines the HUD\'s position and how many slots are visible. You\'ll learn how to customize it later.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3809,7 +3809,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', x + w / 2, y + h / 2, { fromX: x + w / 2 + 100, fromY: y + h / 2 + 100, color: '#ff0000' }); } }, - { prompt: '**HUD Layout — The Diamond:**\n\nThe diamond-shaped highlight marks the **current turn**. The pin inside it is the active combatant.', + { prompt: '**HUD Layout The Diamond:**\n\nThe diamond-shaped highlight marks the **current turn**. The pin inside it is the active combatant.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3823,7 +3823,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', x - 80, y, { fromX: x - 180, fromY: y, color: '#ff0000' }); } }, - { prompt: '**HUD Layout — The Pins:**\n\nEach pin represents one combatant. Pins **above** the diamond have already gone this round; pins **below** are upcoming turns. The HUD scrolls to keep the current turn visible.', + { prompt: '**HUD Layout The Pins:**\n\nEach pin represents one combatant. Pins **above** the diamond have already gone this round; pins **below** are upcoming turns. The HUD scrolls to keep the current turn visible.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3853,7 +3853,7 @@ var Gaslight = Gaslight || (() => { }); } }, - { prompt: '**Gestures — Next turn:**\n\nSwipe the **current turn** pin (the one inside the diamond) to the **right** to advance to the next combatant.\n\nTry it now.', + { prompt: '**Gestures Next turn:**\n\nSwipe the **current turn** pin (the one inside the diamond) to the **right** to advance to the next combatant.\n\nTry it now.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3870,7 +3870,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); } }, - { prompt: '**Gestures — Previous turn:**\n\nSwipe the **current turn** pin to the **left** to go back to the previous combatant.\n\nTry it now.', + { prompt: '**Gestures Previous turn:**\n\nSwipe the **current turn** pin to the **left** to go back to the previous combatant.\n\nTry it now.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3886,7 +3886,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', center.x - 100, center.y, { fromX: center.x - 20, fromY: center.y, color: '#ff4444' }); } }, - { prompt: '**Gestures — Jump to turn:**\n\nSwipe any **non-current** pin to the **right** to jump forward to that combatant\'s turn. Swipe **left** to jump backward to it.\n\nTry swiping a non-current pin.', + { prompt: '**Gestures Jump to turn:**\n\nSwipe any **non-current** pin to the **right** to jump forward to that combatant\'s turn. Swipe **left** to jump backward to it.\n\nTry swiping a non-current pin.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3909,7 +3909,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); } }, - { prompt: '**Gestures — Reordering:**\n\nDrag a HUD pin **up or down** to reorder it in initiative. The pin will snap into its new position.\n\nTry dragging a pin to a different slot.', + { prompt: '**Gestures Reordering:**\n\nDrag a HUD pin **up or down** to reorder it in initiative. The pin will snap into its new position.\n\nTry dragging a pin to a different slot.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3930,7 +3930,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', center.x, center.y + 80, { fromX: center.x, fromY: center.y + 10, color: '#00ccff' }); } }, - { prompt: '**Current Turn Reticle:**\n\nThe rectangle on the map highlighting the current turn\'s token is the **reticle**. It follows the active combatant wherever they are.\n\n• **Drag up/down** on the reticle to offset it from the token\n• **Resize** the reticle to change its proportional size\n• **Rotate** it to add a rotation offset\n• **Delete** the reticle to turn it off (or `!gaslight hud reticle off`)', + { prompt: '**Current Turn Reticle:**\n\nThe rectangle on the map highlighting the current turn\'s token is the **reticle**. It follows the active combatant wherever they are.\n\n **Drag up/down** on the reticle to offset it from the token\n **Resize** the reticle to change its proportional size\n **Rotate** it to add a rotation offset\n **Delete** the reticle to turn it off (or `!gaslight hud reticle off`)', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.reticleData; @@ -3942,14 +3942,14 @@ var Gaslight = Gaslight || (() => { ScriptKit.ping(pageId, x, y, { color: 'transparent', moveAll: true, player: ctx.player }); } }, - { prompt: '**Customization — Scaling:**\n\nResize any HUD pin and all pins scale together. The padding between pins stays fixed.\n\nTry resizing one of the pins.' }, - { prompt: '**Customization — Text:**\n\nThe initiative value text next to each pin can be customized:\n\n• Change **font**, **size**, **color**, or **stroke** directly on any text element in Roll20 — all will update to match\n• **Drag** a text element to adjust its position relative to the frame (closer, further, up, down)\n• **Rotate** it to change the text angle\n\nTry changing the font or position of one of the text elements.' }, - { prompt: '**The Frame — Vertical resize:**\n\nNotice that not all 6+ pins fit in the frame — some are hidden. **Resize the frame vertically** (drag its bottom edge down) to reveal more slots.\n\nTry making the frame taller to show all your pins.' }, - { prompt: '**The Frame — Diamond position:**\n\nYou can drag the **diamond highlight up or down** within the frame to shift where the current turn is displayed. Pins above and below will adjust accordingly.\n\nTry moving the diamond to a different position in the frame.' }, - { prompt: '**The Frame — Other customization:**\n\nThe frame supports additional tweaks:\n\n• **Drag** the frame to move the entire HUD\n• **Resize horizontally** to adjust padding between pins\n• Change the frame\'s **stroke color** or **fill** directly in Roll20\'s shape properties\n\nThe **diamond** can also be customized:\n\n• Change its **color**, **stroke width**, **fill**, or **rotation** directly in Roll20\'s shape properties\n\nThe HUD remembers all of these choices.' }, - { prompt: '**Reticle — Inheritance:**\n\nThe reticle inherits its **color** and **stroke width** from the diamond. It also inherits **rotation**, offset by 45° (so the diamond\'s 45° rotation becomes the reticle\'s 90°, etc.).\n\nIf you changed the diamond\'s appearance in the previous steps, the reticle should reflect those changes.\n\nTo override the reticle independently, just change its properties directly. Once overridden, it stops inheriting that property from the diamond.' }, - { prompt: '**Gestures — Removing:**\n\nDelete a HUD pin to remove that combatant from initiative. Try deleting one now.' }, - { prompt: '**Clearing initiative:**\n\nNow clear the rest of the initiative — delete the remaining HUD pins or clear initiative from Roll20\'s Turn Tracker panel.\n\nClear initiative before continuing.', + { prompt: '**Customization Scaling:**\n\nResize any HUD pin and all pins scale together. The padding between pins stays fixed.\n\nTry resizing one of the pins.' }, + { prompt: '**Customization Text:**\n\nThe initiative value text next to each pin can be customized:\n\n Change **font**, **size**, **color**, or **stroke** directly on any text element in Roll20 all will update to match\n **Drag** a text element to adjust its position relative to the frame (closer, further, up, down)\n **Rotate** it to change the text angle\n\nTry changing the font or position of one of the text elements.' }, + { prompt: '**The Frame Vertical resize:**\n\nNotice that not all 6+ pins fit in the frame some are hidden. **Resize the frame vertically** (drag its bottom edge down) to reveal more slots.\n\nTry making the frame taller to show all your pins.' }, + { prompt: '**The Frame Diamond position:**\n\nYou can drag the **diamond highlight up or down** within the frame to shift where the current turn is displayed. Pins above and below will adjust accordingly.\n\nTry moving the diamond to a different position in the frame.' }, + { prompt: '**The Frame Other customization:**\n\nThe frame supports additional tweaks:\n\n **Drag** the frame to move the entire HUD\n **Resize horizontally** to adjust padding between pins\n Change the frame\'s **stroke color** or **fill** directly in Roll20\'s shape properties\n\nThe **diamond** can also be customized:\n\n Change its **color**, **stroke width**, **fill**, or **rotation** directly in Roll20\'s shape properties\n\nThe HUD remembers all of these choices.' }, + { prompt: '**Reticle Inheritance:**\n\nThe reticle inherits its **color** and **stroke width** from the diamond. It also inherits **rotation**, offset by 45 (so the diamond\'s 45 rotation becomes the reticle\'s 90, etc.).\n\nIf you changed the diamond\'s appearance in the previous steps, the reticle should reflect those changes.\n\nTo override the reticle independently, just change its properties directly. Once overridden, it stops inheriting that property from the diamond.' }, + { prompt: '**Gestures Removing:**\n\nDelete a HUD pin to remove that combatant from initiative. Try deleting one now.' }, + { prompt: '**Clearing initiative:**\n\nNow clear the rest of the initiative delete the remaining HUD pins or clear initiative from Roll20\'s Turn Tracker panel.\n\nClear initiative before continuing.', when: () => { var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); return turnOrder.length > 0; @@ -3967,7 +3967,7 @@ var Gaslight = Gaslight || (() => { if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; } }, - { prompt: '**Plugin-added initiative:**\n\nIf you use a plugin (GroupInitiative, etc.) to roll initiative, go ahead and use it now.\n\nIf you don\'t have one, select token(s) and click **Continue** — Gaslight will add them to initiative for you.', + { prompt: '**Plugin-added initiative:**\n\nIf you use a plugin (GroupInitiative, etc.) to roll initiative, go ahead and use it now.\n\nIf you don\'t have one, select token(s) and click **Continue** Gaslight will add them to initiative for you.', onContinue: (ctx) => { var s = state[SCRIPT_NAME]; var entries = (s.hud.initData && s.hud.initData.entries) || []; @@ -3985,13 +3985,13 @@ var Gaslight = Gaslight || (() => { Campaign().set('turnorder', JSON.stringify(turnOrder)); } }, - { prompt: '**The problem:** When a plugin adds tokens to initiative, it sets `Campaign().set(\'turnorder\')` — but Gaslight cannot detect that change automatically.\n\nTo sync the HUD with the current turn order, run:\n\n`!gaslight init`\n\nThis is needed whenever initiative changes via a plugin (not via the HUD gestures).', + { prompt: '**The problem:** When a plugin adds tokens to initiative, it sets `Campaign().set(\'turnorder\')` but Gaslight cannot detect that change automatically.\n\nTo sync the HUD with the current turn order, run:\n\n`!gaslight init`\n\nThis is needed whenever initiative changes via a plugin (not via the HUD gestures).', ...ScriptKit.waitForCommand('!gaslight init') }, - { prompt: '**Reset:**\n\nIf the HUD ever gets into a weird state (misplaced elements, corrupted data), you can reset it:\n\n`!gaslight hud init reset` — destroys and recreates the initiative HUD from defaults\n`!gaslight hud reticle reset` — destroys and recreates the reticle from defaults\n\nThis preserves your turn order but resets all visual customization.' }, - { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` — hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` — shows it again with your saved customization\n`!gaslight hud reticle off` / `on` — same for the reticle independently\n`!gaslight hud off` / `on` — toggles all HUD elements at once' }, - { prompt: '**⚠️ Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ⚠️ icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, - { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` — toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` — toggle/reset the reticle\n`!gaslight init` — sync turn order into HUD\n`!gaslight init sync` — add missing linked tokens\n`!gaslight init trim` — remove stale entries\n\n**That\'s the initiative HUD!**', + { prompt: '**Reset:**\n\nIf the HUD ever gets into a weird state (misplaced elements, corrupted data), you can reset it:\n\n`!gaslight hud init reset` destroys and recreates the initiative HUD from defaults\n`!gaslight hud reticle reset` destroys and recreates the reticle from defaults\n\nThis preserves your turn order but resets all visual customization.' }, + { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` shows it again with your saved customization\n`!gaslight hud reticle off` / `on` same for the reticle independently\n`!gaslight hud off` / `on` toggles all HUD elements at once' }, + { prompt: '**?? Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ?? icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, + { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` toggle/reset the reticle\n`!gaslight init` sync turn order into HUD\n`!gaslight init sync` add missing linked tokens\n`!gaslight init trim` remove stale entries\n\n**That\'s the initiative HUD!**', offerExamples: ['relay', 'scripting'] }, ], @@ -4007,10 +4007,10 @@ var Gaslight = Gaslight || (() => { } }, { prompt: 'Run `!rollcapture dissect` in chat, then roll stealth for any NPC. This shows you the template fields available for capture.' }, - { prompt: 'Create a RollCapture rule for skills. Run `!rollcapture rule skills` — this creates a *[RC] skills* handout. Open it in the journal.' }, - { prompt: 'Add the line `template: ` — where `` is the roll template shown by dissect (e.g. "simple", "atk", "npc").' }, - { prompt: 'Add the line `name_field: ` — where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, - { prompt: 'Add the line `result: ` — where `` is the inline roll field holding the total (e.g. "r1", "roll1").' }, + { prompt: 'Create a RollCapture rule for skills. Run `!rollcapture rule skills` this creates a *[RC] skills* handout. Open it in the journal.' }, + { prompt: 'Add the line `template: ` where `` is the roll template shown by dissect (e.g. "simple", "atk", "npc").' }, + { prompt: 'Add the line `name_field: ` where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, + { prompt: 'Add the line `result: ` where `` is the inline roll field holding the total (e.g. "r1", "roll1").' }, { prompt: 'Save the handout and run `!rollcapture reload` to load your new rule.', ...ScriptKit.waitForCommand('!rollcapture reload') }, @@ -4126,7 +4126,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'madness', - description: 'An afflicted player sees all tokens as enemies — allies swap to a chosen enemy image', + description: 'An afflicted player sees all tokens as enemies allies swap to a chosen enemy image', guide: [ { prompt: 'Select a token that has the **status marker** you want to represent madness applied to it (e.g. "screaming"). This marker will trigger the effect when placed on a viewer\'s token.', select: 'token', as: 'markerToken', min: 1, max: 1, @@ -4153,7 +4153,7 @@ var Gaslight = Gaslight || (() => { { prompt: (ctx) => '**Test:** Apply the **' + ctx.params._marker + '** marker to a viewer token, then run `!gaslight eval --all`. All tokens on that viewer\'s page should swap to the enemy image. Remove the marker and re-eval to restore.' }, ], handout: (ctx) => ({ - notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|ŧ̈́h̶̛ë̵̡ý̸̲{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', + notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|th?????_{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', gmnotes: '---GASLIGHT-SCRIPT---\nfilter: characters\ntrigger: on change statusmarkers', archived: false, }), @@ -4270,8 +4270,8 @@ var Gaslight = Gaslight || (() => { /** * 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. + * 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; @@ -4344,7 +4344,7 @@ var Gaslight = Gaslight || (() => { // Universal relay: master-page refs, no player-page refs if (masterTokens.length > 0 && !hasPlayerPageRef) { var viewPlayerId = s.view; - if (!viewPlayerId) return; // view off — no relay + if (!viewPlayerId) return; // view off no relay var targetPlayerIds = viewPlayerId === 'master' ? Object.keys(activeEntry[1].playerPages) : [viewPlayerId]; @@ -4370,7 +4370,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Initiative Tracking — sync turn order across linked tokens + // Initiative Tracking sync turn order across linked tokens // ========================================================================= /** @@ -4504,7 +4504,7 @@ var Gaslight = Gaslight || (() => { if (!entry.id || entry.id === '-1') return; var oldEntry = oldOrder.find(function(e) { return e.id === entry.id; }); if (!oldEntry || oldEntry.pr === entry.pr) return; - // Value changed — propagate to linked tokens + // Value changed propagate to linked tokens var info = getLinkedInfo(entry.id); if (info.linkedIds.length === 0) return; info.linkedIds.forEach(function(linkedId) { @@ -4562,7 +4562,7 @@ var Gaslight = Gaslight || (() => { } modified = true; } else { - // Not forward/backward — manual drag. Reorder to group children after master. + // Not forward/backward manual drag. Reorder to group children after master. var reordered = reorderInitiative(newOrder); if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { newOrder = reordered; @@ -4619,19 +4619,19 @@ var Gaslight = Gaslight || (() => { if (placed.has(entry.id)) return; if (!entry.id || entry.id === '-1') { result.push(entry); - // Don't add '-1' to placed — multiple custom entries share this ID + // Don't add '-1' to placed multiple custom entries share this ID return; } var info = getLinkedInfo(entry.id); if (info.linkedIds.length === 0) { - // Not a linked token — just add it + // Not a linked token just add it result.push(entry); placed.add(entry.id); return; } - // Skip children — they'll be pulled in when we reach their master + // Skip children they'll be pulled in when we reach their master if (!isMasterToken(entry.id)) return; // Find all entries in this link group @@ -4733,7 +4733,7 @@ var Gaslight = Gaslight || (() => { if (doSync) parts.push(added + ' entry(s) added'); var out = parts.join(', ') + '.'; if (warnings.length > 0) { - out += '

      ⚠️ ' + warnings.join('
      ⚠️ '); + out += '

      ?? ' + warnings.join('
      ?? '); } reply(msg, 'Init', out); if (s.hud.initiative) { @@ -4746,7 +4746,7 @@ var Gaslight = Gaslight || (() => { var tagStr = computeHudTag(e.sourceId); var sourceToken = getObj('graphic', e.sourceId); var name = sourceToken ? (sourceToken.get('name') || '') : ''; - pin.set('title', ((tagStr ? '⚠️ ' : '') + name).trim()); + pin.set('title', ((tagStr ? '?? ' : '') + name).trim()); pin.set('notes', tagStr || ''); }); } @@ -4755,7 +4755,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // HUD System — on-canvas indicators + // HUD System on-canvas indicators // ========================================================================= const HUD_PREFIX = 'gaslight_hud_'; @@ -4891,9 +4891,9 @@ var Gaslight = Gaslight || (() => { // Determine display text var label; if (s.view === null) { - label = '🔴 RELAY OFF'; + label = '?? RELAY OFF'; } else if (s.view === 'master') { - label = '🟢 RELAY: ALL'; + label = '?? RELAY: ALL'; } else { // Resolve player name var playerName = s.view; @@ -4901,7 +4901,7 @@ var Gaslight = Gaslight || (() => { var entry = g.playerPages[s.view]; if (entry && entry.name) playerName = entry.name; }); - label = '🔵 VIEW: ' + playerName; + label = '?? VIEW: ' + playerName; } // Find existing or create new @@ -4954,7 +4954,7 @@ var Gaslight = Gaslight || (() => { /** * Convert a pin's x/y (the "point" where the teardrop points) to its visual center. - * Pin scale 0.25 = 10px, scale 2.0 = 80px → size = scale * 40. + * Pin scale 0.25 = 10px, scale 2.0 = 80px ? size = scale * 40. * Visual center is offset upward by half the pin's visual height. */ const toPinCenter = (pin) => { @@ -5005,36 +5005,25 @@ var Gaslight = Gaslight || (() => { }; /** - * Compute proportionally distributed offsets for HUD entries. - * Entry 0 = offset 0 (diamond). Remaining entries alternate below/above - * proportional to available slots on each side. - * If one side runs out of room, stack on the other side. - * Returns an array of offsets, one per entry. + * Compute how many slots should be above vs below the diamond, + * distributing proportionally based on available space on each side. + * Returns { above, below } counts. */ - const computeHudOffsets = (count, slotsBelow, slotsAbove) => { - var offsets = [0]; - var below = 0, above = 0; + const computeHudSlotDistribution = (count, slotsBelow, slotsAbove) => { + // Entry 0 is in the diamond. Remaining entries fill below then above proportionally. + var remaining = Math.min(count - 1, slotsBelow + slotsAbove); + if (remaining <= 0) return { below: 0, above: 0 }; var totalSlots = slotsBelow + slotsAbove; - if (totalSlots === 0) return offsets; - - for (var i = 1; i < count; i++) { - // Determine which side should get the next entry based on fill ratio - var belowRatio = slotsBelow > 0 ? below / slotsBelow : 1; - var aboveRatio = slotsAbove > 0 ? above / slotsAbove : 1; - - if (belowRatio <= aboveRatio) { - // Below is less filled proportionally — try below - if (below < slotsBelow) { below++; offsets.push(below); } - else if (above < slotsAbove) { above++; offsets.push(-above); } - else offsets.push(below + above + 1); // overflow (hidden) - } else { - // Above is less filled proportionally — try above - if (above < slotsAbove) { above++; offsets.push(-above); } - else if (below < slotsBelow) { below++; offsets.push(below); } - else offsets.push(below + above + 1); // overflow (hidden) - } + if (totalSlots === 0) return { below: 0, above: 0 }; + var below = Math.min(slotsBelow, Math.round(remaining * slotsBelow / totalSlots)); + var above = Math.min(slotsAbove, remaining - below); + // If rounding left us short, add to whichever side has room + while (below + above < remaining) { + if (below < slotsBelow) below++; + else if (above < slotsAbove) above++; + else break; } - return offsets; + return { below: below, above: above }; }; /** @@ -5077,14 +5066,14 @@ var Gaslight = Gaslight || (() => { seenGroups.add(entry.id); return true; } - // It's a child — check if its master is in the order + // It's a child check if its master is in the order var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); if (masterId && seenGroups.has(masterId)) return false; // master already shown if (masterId && order.some(function(e) { return e.id === masterId; })) { seenGroups.add(masterId); return false; // master is in order, it will be shown when we reach it } - // No master in order — show this child as representative + // No master in order show this child as representative if (!seenGroups.has(entry.id)) { seenGroups.add(entry.id); return true; @@ -5293,7 +5282,7 @@ var Gaslight = Gaslight || (() => { if (!sourceToken) return; var tagStr = computeHudTag(entry.id); - var pinTitle = ((tagStr ? '⚠️ ' : '') + (sourceToken.get('name') || '')).trim(); + var pinTitle = ((tagStr ? '?? ' : '') + (sourceToken.get('name') || '')).trim(); var hudPin = createHudObj('pin', { _pageid: pageId, @@ -5332,7 +5321,7 @@ var Gaslight = Gaslight || (() => { }); if (direction === undefined) { - // Initial creation — delay reflow to give Roll20 time to register objects + // Initial creation delay reflow to give Roll20 time to register objects setTimeout(function() { reflowInitiativeHud('none'); updateTurnReticle(); }, 100); } else { reflowInitiativeHud(direction); @@ -5385,12 +5374,12 @@ var Gaslight = Gaslight || (() => { // We can fit one more. Shift diamond toward the side with more slots. var shiftAmount; if (slotsBelow >= slotsAbove) { - // More below (or equal) — shift down to make room above + // More below (or equal) shift down to make room above shiftAmount = Math.min(step - extraAbove, extraBelow); frameCenter += shiftAmount; slotsAbove++; } else { - // More above — shift up to make room below + // More above shift up to make room below shiftAmount = Math.min(step - extraBelow, extraAbove); frameCenter -= shiftAmount; slotsBelow++; @@ -5479,7 +5468,9 @@ var Gaslight = Gaslight || (() => { }); // Reflow only token entries by ID - var tokenOffsets = computeHudOffsets(order.length, slotsBelow, slotsAbove); + var tokenDist = computeHudSlotDistribution(order.length, slotsBelow, slotsAbove); + var tUsedBelow = tokenDist.below; + var tUsedAbove = tokenDist.above; order.forEach(function(entry, i) { if (!entry.id || entry.id === '-1') return; var hudEntry = tokenMap[entry.id]; @@ -5489,8 +5480,11 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!pin) return; - var offset = tokenOffsets[i] !== undefined ? tokenOffsets[i] : i; - var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); + var offset; + if (i === 0) offset = 0; + else if (i <= tUsedBelow) offset = i; + else offset = -(tUsedBelow + tUsedAbove - i + 1); + var visible = (i === 0) || (i <= tUsedBelow) || (i <= tUsedBelow + tUsedAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); pin.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000 }); @@ -5536,7 +5530,9 @@ var Gaslight = Gaslight || (() => { sortedCustoms = sortedCustoms.concat(hiddenCustoms); var customIdx = 0; - var offsets = computeHudOffsets(order.length, slotsBelow, slotsAbove); + var offsets = computeHudSlotDistribution(order.length, slotsBelow, slotsAbove); + var usedBelow = offsets.below; + var usedAbove = offsets.above; order.forEach(function(entry, i) { var isCustom = !entry.id || entry.id === '-1'; var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; @@ -5546,8 +5542,12 @@ var Gaslight = Gaslight || (() => { var txt = getObj('text', hudEntry.textId); if (!pin) return; - var offset = offsets[i] !== undefined ? offsets[i] : i; - var visible = Math.abs(offset) <= (offset >= 0 ? slotsBelow : slotsAbove); + // Sequential: 0 = diamond, 1..usedBelow = below, above entries in order (last = closest to diamond) + var offset; + if (i === 0) offset = 0; + else if (i <= usedBelow) offset = i; + else offset = -(usedBelow + usedAbove - i + 1); + var visible = (i === 0) || (i <= usedBelow) || (i <= usedBelow + usedAbove); var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); pin.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000 }); @@ -5599,7 +5599,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle change:text — persist HUD element position/size if moved by GM. + * Handle change:text persist HUD element position/size if moved by GM. */ const onHudTextChanged = (obj) => { var s = state[SCRIPT_NAME]; @@ -5620,7 +5620,7 @@ var Gaslight = Gaslight || (() => { var vRotation = obj.get('rotation'); if (vRotation !== undefined) s.hud.viewData.rotation = vRotation; } - // Initiative HUD text moved or styled — update stored settings + // Initiative HUD text moved or styled update stored settings if (s.hud.initiative && s.hud.initData && s.hud.initData.entries) { var match = s.hud.initData.entries.find(function(e) { return e.textId === id; }); if (match) { @@ -5725,7 +5725,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle destroy:text — if a HUD element is deleted, treat as turning it off. + * Handle destroy:text if a HUD element is deleted, treat as turning it off. */ const onHudTextDestroyed = (obj) => { var s = state[SCRIPT_NAME]; @@ -5765,7 +5765,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle destroy:pin — if an initiative HUD pin is deleted, remove from turn order. + * Handle destroy:pin if an initiative HUD pin is deleted, remove from turn order. */ const onHudGraphicDestroyed = (obj) => { var s = state[SCRIPT_NAME]; @@ -5776,7 +5776,7 @@ var Gaslight = Gaslight || (() => { if (matchIdx === -1) return; var match = data.entries[matchIdx]; - // Splice entry first — associated text will be orphaned and cleaned up on next reflow + // Splice entry first associated text will be orphaned and cleaned up on next reflow data.entries.splice(matchIdx, 1); var txt = getObj('text', match.textId); if (txt) txt.remove(); @@ -5784,12 +5784,12 @@ var Gaslight = Gaslight || (() => { // Remove from turn order var order = JSON.parse(Campaign().get('turnorder') || '[]'); if (match.sourceId && !match.sourceId.startsWith('custom:')) { - // Token — remove it and linked children from turn order + // Token remove it and linked children from turn order var info = getLinkedInfo(match.sourceId); var groupIds = new Set([match.sourceId].concat(info.linkedIds)); order = order.filter(function(e) { return !groupIds.has(e.id); }); } else { - // Custom turn — remove first matching custom entry + // Custom turn remove first matching custom entry var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); if (customIdx !== -1) order.splice(customIdx, 1); } @@ -5800,7 +5800,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle destroy:path — if the initiative frame is deleted, turn off. + * Handle destroy:path if the initiative frame is deleted, turn off. */ const onHudPathDestroyed = (obj) => { var s = state[SCRIPT_NAME]; @@ -5811,10 +5811,10 @@ var Gaslight = Gaslight || (() => { removeInitiativeHud(); sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); } else if (obj.get('id') === s.hud.initData.highlightId) { - // Highlight deleted — just clear ID, will be recreated on next update + // Highlight deleted just clear ID, will be recreated on next update s.hud.initData.highlightId = null; } else if (s.hud.reticleData && obj.get('id') === s.hud.reticleData.id) { - // Turn indicator deleted — turn off reticle + // Turn indicator deleted turn off reticle s.hud.reticleData.id = null; s.hud.reticleData.tokenId = null; s.hud.reticle = false; @@ -5823,7 +5823,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle frame pathv2 change — resize logic, position/style tracking. + * Handle frame pathv2 change resize logic, position/style tracking. */ const onFrameChanged = (obj, prev) => { var s = state[SCRIPT_NAME]; @@ -5866,7 +5866,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle highlight pathv2 change — style tracking, cascade to reticle, Y offset. + * Handle highlight pathv2 change style tracking, cascade to reticle, Y offset. */ const onHighlightChanged = (obj, prev) => { var s = state[SCRIPT_NAME]; @@ -5909,7 +5909,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle reticle pathv2 change — style override, size scale, position offset. + * Handle reticle pathv2 change style override, size scale, position offset. */ const onReticleChanged = (obj, prev) => { var s = state[SCRIPT_NAME]; @@ -6072,7 +6072,7 @@ var Gaslight = Gaslight || (() => { if (!entry.owns) { entry.owns = function(id) { return entry.ids().indexOf(id) !== -1; }; } - // Generic reset if not overridden: disable → reset state → enable + // Generic reset if not overridden: disable ? reset state ? enable if (!entry.reset) { entry.reset = function() { var s = state[SCRIPT_NAME]; @@ -6108,7 +6108,7 @@ var Gaslight = Gaslight || (() => { return; } - // Single arg that's a toggle word — apply to all elements + // Single arg that's a toggle word apply to all elements if (args.length === 1 && toggleWords.has(args[0].toLowerCase())) { var toggle = args[0].toLowerCase(); if (toggle === 'reset') { @@ -6231,108 +6231,191 @@ var Gaslight = Gaslight || (() => { horizontalEscapePin = newX > frameLeftPin + fwPin / 2 || newX < frameLeftPin - fwPin / 2; } - // Pin dragged vertically — reorder turn in initiative + // Pin dragged vertically reorder turn in initiative if (!horizontalEscapePin && newY !== oldY) { - var order = JSON.parse(Campaign().get('turnorder') || '[]'); - var hudOrder = getHudTurnOrder(); - var isCustomEntry = match.sourceId && match.sourceId.startsWith('custom:'); - - // Find current index in hudOrder - var currentIdx = -1; - if (isCustomEntry) { - // For customs, find rank among customs and map to hudOrder position - var customsInData = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); - var myCustomRank = customsInData.findIndex(function(e) { return e.tokenId === obj.get('id'); }); - if (myCustomRank !== -1) { - var cCount = 0; - for (var hi = 0; hi < hudOrder.length; hi++) { - if (!hudOrder[hi].id || hudOrder[hi].id === '-1') { - if (cCount === myCustomRank) { currentIdx = hi; break; } - cCount++; - } - } - } - } else { - currentIdx = hudOrder.findIndex(function(e) { return e.id === match.sourceId; }); - } - if (currentIdx < 0 || currentIdx >= hudOrder.length) { reflowInitiativeHud('none'); return; } - - // Determine target slot based on drag delta - var tknSize = data.tokenSize || defaultInitHud.tokenSize; - var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; - var deltaSlots = Math.round((newY - oldY) / (tknSize + tknPad)); - var targetIdx = currentIdx + deltaSlots; - // Wrap around circular turn order - if (targetIdx < 0) targetIdx = hudOrder.length + targetIdx; - if (targetIdx >= hudOrder.length) targetIdx = targetIdx - hudOrder.length; - targetIdx = Math.max(0, Math.min(hudOrder.length - 1, targetIdx)); - - if (targetIdx !== currentIdx) { - // Find source entry in the full order - var sourceFullIdx = -1; + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var hudOrder = getHudTurnOrder(); + var isCustomEntry = match.sourceId && match.sourceId.startsWith('custom:'); + + // Find current index in hudOrder + var currentIdx = -1; if (isCustomEntry) { - // Find the Nth custom in full order (where N = this custom's rank) + // For customs, find rank among customs and map to hudOrder position var customsInData = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); - var myRank = customsInData.findIndex(function(e) { return e.tokenId === obj.get('id'); }); - var cIdx = 0; - for (var fi = 0; fi < order.length; fi++) { - if (!order[fi].id || order[fi].id === '-1') { - if (cIdx === myRank) { sourceFullIdx = fi; break; } - cIdx++; + var myCustomRank = customsInData.findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (myCustomRank !== -1) { + var cCount = 0; + for (var hi = 0; hi < hudOrder.length; hi++) { + if (!hudOrder[hi].id || hudOrder[hi].id === '-1') { + if (cCount === myCustomRank) { currentIdx = hi; break; } + cCount++; + } } } } else { - sourceFullIdx = order.findIndex(function(e) { return e.id === match.sourceId; }); + currentIdx = hudOrder.findIndex(function(e) { return e.id === match.sourceId; }); } - if (sourceFullIdx === -1) { reflowInitiativeHud('none'); return; } + if (currentIdx < 0 || currentIdx >= hudOrder.length) { reflowInitiativeHud('none'); return; } + + // Build a map of hudOrder index ? current visual Y position + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + var pinYByIdx = []; + hudOrder.forEach(function(entry, idx) { + var hudEntry; + if (!entry.id || entry.id === '-1') { + var customsInData2 = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + var cRank = hudOrder.slice(0, idx + 1).filter(function(e) { return !e.id || e.id === '-1'; }).length - 1; + hudEntry = customsInData2[cRank]; + } else { + hudEntry = data.entries.find(function(e) { return e.sourceId === entry.id; }); + } + if (hudEntry) { + var pin2 = getObj('pin', hudEntry.tokenId); + pinYByIdx.push({ idx: idx, y: pin2 ? pin2.get('y') : -5000 }); + } else { + pinYByIdx.push({ idx: idx, y: -5000 }); + } + }); + // Sort visible pins by Y to get visual order (top to bottom) + var visiblePins = pinYByIdx.filter(function(p) { return p.y > -1000; }); + visiblePins.sort(function(a, b) { return a.y - b.y; }); + // Minimum drag threshold + if (Math.abs(newY - oldY) < (tknSize + tknPad) / 3) { reflowInitiativeHud('none'); return; } - // Remove source group from order (master + linked tokens) - var sourceGroupSize = 1; - if (!isCustomEntry) { - var sInfo = getLinkedInfo(match.sourceId); - sourceGroupSize = 1 + sInfo.linkedIds.filter(function(lid) { - return order.some(function(e) { return e.id === lid; }); - }).length; - } - var removed = order.splice(sourceFullIdx, sourceGroupSize); + // Find which two pins the dragged pin is between (excluding itself) + var others = visiblePins.filter(function(p) { return p.idx !== currentIdx; }); + if (others.length === 0) { reflowInitiativeHud('none'); return; } - // Find target position in full order - var targetHudEntry = hudOrder[targetIdx]; - var targetFullIdx = -1; - if (!targetHudEntry.id || targetHudEntry.id === '-1') { - // Targeting a custom — find the Nth custom in the full order - var targetCustomRank = hudOrder.slice(0, targetIdx + 1).filter(function(e) { return !e.id || e.id === '-1'; }).length - 1; - var cCount = 0; - for (var fi = 0; fi < order.length; fi++) { - if (!order[fi].id || order[fi].id === '-1') { - if (cCount === targetCustomRank) { targetFullIdx = fi; break; } - cCount++; + var insertAfterIdx = -1; // hudOrder idx to insert after + var insertBeforeIdx = -1; // hudOrder idx to insert before + + if (newY <= others[0].y) { + // Above all others insert before the topmost pin + insertBeforeIdx = others[0].idx; + } else if (newY >= others[others.length - 1].y) { + // Below all others insert after the bottommost pin + insertAfterIdx = others[others.length - 1].idx; + } else { + // Between two pins + for (var vi = 0; vi < others.length - 1; vi++) { + if (newY >= others[vi].y && newY <= others[vi + 1].y) { + insertAfterIdx = others[vi].idx; + insertBeforeIdx = others[vi + 1].idx; + break; } } - } else { - targetFullIdx = order.findIndex(function(e) { return e.id === targetHudEntry.id; }); } - if (targetFullIdx === -1) targetFullIdx = order.length; + // Skip if no valid insertion found or same position + var targetIdx = insertAfterIdx !== -1 ? insertAfterIdx : insertBeforeIdx; + if (targetIdx === -1 || targetIdx === currentIdx) { reflowInitiativeHud('none'); return; } + + if (true) { + + // Collapse turn order to one entry per combatant, preserving group info + var collapsed = []; + var groupMap = {}; // collapsed index ? array of full entries for that group + var seen = new Set(); + order.forEach(function(entry) { + if (!entry.id || entry.id === '-1') { + var ci = collapsed.length; + collapsed.push(entry); + groupMap[ci] = [entry]; + return; + } + // Find the master for this token + var masterId = entry.id; + if (!isMasterToken(entry.id)) { + var info = getLinkedInfo(entry.id); + var mid = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (mid) masterId = mid; + } + if (seen.has(masterId)) { + // Add to existing group + var existingIdx = collapsed.findIndex(function(e) { return e._masterId === masterId; }); + if (existingIdx !== -1) groupMap[existingIdx].push(entry); + return; + } + seen.add(masterId); + var ci = collapsed.length; + var colEntry = Object.assign({}, entry, { _masterId: masterId }); + collapsed.push(colEntry); + groupMap[ci] = [entry]; + }); + + // Find source and target in collapsed order + var colSourceIdx = collapsed.findIndex(function(e) { + if (isCustomEntry) return e.id === '-1' || !e.id; // TODO: custom rank matching + return e._masterId === match.sourceId || e.id === match.sourceId; + }); + if (colSourceIdx === -1) { reflowInitiativeHud('none'); return; } + + // Determine insertion point in collapsed order + var colTargetIdx; + if (insertAfterIdx === -1) { + // Insert before the target + colTargetIdx = collapsed.findIndex(function(e) { + var hEntry = hudOrder[insertBeforeIdx]; + if (!hEntry.id || hEntry.id === '-1') return !e.id || e.id === '-1'; + return e._masterId === hEntry.id || e.id === hEntry.id; + }); + if (colTargetIdx === -1) colTargetIdx = 0; + } else { + // Insert after the target + var afterEntry = hudOrder[insertAfterIdx]; + colTargetIdx = collapsed.findIndex(function(e) { + if (!afterEntry.id || afterEntry.id === '-1') return !e.id || e.id === '-1'; + return e._masterId === afterEntry.id || e.id === afterEntry.id; + }); + if (colTargetIdx === -1) colTargetIdx = collapsed.length - 1; + colTargetIdx++; // insert AFTER + } + + // Remove source from collapsed + var removedGroup = groupMap[colSourceIdx]; + collapsed.splice(colSourceIdx, 1); + // Adjust target index if source was before target + if (colSourceIdx < colTargetIdx) colTargetIdx--; + // Rebuild groupMap indices after removal + var newGroupMap = {}; + var gi = 0; + Object.keys(groupMap).sort(function(a, b) { return Number(a) - Number(b); }).forEach(function(key) { + if (Number(key) === colSourceIdx) return; + newGroupMap[gi] = groupMap[key]; + gi++; + }); + // Insert at target position + collapsed.splice(colTargetIdx, 0, { _placeholder: true }); + // Rebuild final groupMap + var finalGroupMap = {}; + var fgi = 0; + Object.keys(newGroupMap).sort(function(a, b) { return Number(a) - Number(b); }).forEach(function(key) { + var idx = Number(key); + if (idx >= colTargetIdx) idx++; + finalGroupMap[idx] = newGroupMap[key]; + fgi++; + }); + finalGroupMap[colTargetIdx] = removedGroup; - // Insert: when moving down, place after target group; when moving up, place before target - if (targetIdx > currentIdx) { - var tInfo = getLinkedInfo(targetHudEntry.id || ''); - var tGroupSize = (targetHudEntry.id && targetHudEntry.id !== '-1') ? 1 + tInfo.linkedIds.filter(function(lid) { - return order.some(function(e) { return e.id === lid; }); - }).length : 1; - order.splice.apply(order, [targetFullIdx + tGroupSize, 0].concat(removed)); - } else { - order.splice.apply(order, [targetFullIdx, 0].concat(removed)); + // Expand back to full order + var newOrder = []; + for (var ci = 0; ci < collapsed.length; ci++) { + var group = finalGroupMap[ci]; + if (group) group.forEach(function(e) { newOrder.push(e); }); + } + + // Clean up temp properties + newOrder.forEach(function(e) { delete e._masterId; delete e._placeholder; }); + + Campaign().set('turnorder', JSON.stringify(newOrder)); } - Campaign().set('turnorder', JSON.stringify(order)); - } } - // Pin dragged horizontally — make it this turn's turn + // Pin dragged horizontally make it this turn's turn var oldX = prev.x; var verticalDrift = Math.abs(newY - oldY); var tknSizeP = data.tokenSize || defaultInitHud.tokenSize; @@ -6359,7 +6442,7 @@ var Gaslight = Gaslight || (() => { if (targetFullIdx !== -1) { if (targetFullIdx === 0) { - // This pin is the current turn — advance/retreat to next/prev master or custom + // This pin is the current turn advance/retreat to next/prev master or custom if (swipeDir === 'forward') { fullOrder.push(fullOrder.shift()); var skip = fullOrder.length; @@ -6386,7 +6469,7 @@ var Gaslight = Gaslight || (() => { fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); } } else { - // Non-current pin — rotate to it, applying formulas to customs passed along the way + // Non-current pin rotate to it, applying formulas to customs passed along the way var rotCount = swipeDir === 'forward' ? targetFullIdx : fullOrder.length - targetFullIdx; for (var ri = 0; ri < rotCount; ri++) { if (swipeDir === 'forward') { @@ -6411,7 +6494,7 @@ var Gaslight = Gaslight || (() => { reflowInitiativeHud('none'); updateTurnReticle(); - // Scale change — adjust all HUD pins to match and update tokenSize + // Scale change adjust all HUD pins to match and update tokenSize var newScale = obj.get('scale'); var oldScale = prev.scale; if (newScale !== oldScale && newScale != null) { @@ -6438,7 +6521,7 @@ var Gaslight = Gaslight || (() => { }); }; - // 1. Handout notes change → rebuild triggerMap from cache + // 1. Handout notes change ? rebuild triggerMap from cache on('change:handout:notes', function(obj) { var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; @@ -6448,7 +6531,7 @@ var Gaslight = Gaslight || (() => { } }); - // 2. Pin gmnotes change → rebuild from cache if pin is known + // 2. Pin gmnotes change ? rebuild from cache if pin is known on('change:pin:gmNotes', function(obj) { if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { @@ -6456,7 +6539,7 @@ var Gaslight = Gaslight || (() => { } }); - // 3. Pin link/linkType change → rebuild from cache + // 3. Pin link/linkType change ? rebuild from cache on('change:pin:link', function(obj) { if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; // Update cache entry with new handout link @@ -6466,7 +6549,7 @@ var Gaslight = Gaslight || (() => { cached.handoutId = obj.get('link') || null; rebuildTriggerMapFromCache(); } else if (isActivePage(obj.get('_pageid')) && obj.get('link') && obj.get('linkType') === 'handout') { - // New script pin — add to cache and rebuild + // New script pin add to cache and rebuild knownScriptPins.push({ pinId: pinId, pageId: obj.get('_pageid'), handoutId: obj.get('link') }); rebuildTriggerMapFromCache(); } @@ -6478,7 +6561,7 @@ var Gaslight = Gaslight || (() => { } }); - // 4. New pin added to active page → add to cache and rebuild + // 4. New pin added to active page ? add to cache and rebuild on('add:pin', function(obj) { if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; if (!isActivePage(obj.get('_pageid'))) return; From 8bebd1971209f9b9bd31713550359d2f088d2bd2 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 24 Jul 2026 14:02:46 -0400 Subject: [PATCH 22/37] Gaslight: move reorder arrows to frame border, fix encoding --- Gaslight/Gaslight.js | 310 ++++++++++++++++++++++--------------------- 1 file changed, 157 insertions(+), 153 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index db0fd2e45..7c4e7d437 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -92,7 +92,7 @@ var Gaslight = Gaslight || (() => { if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: true, initiative: true, reticle: true }; if (state[SCRIPT_NAME].hud.initiative === undefined) state[SCRIPT_NAME].hud.initiative = false; if (state[SCRIPT_NAME].hud.reticle === undefined) state[SCRIPT_NAME].hud.reticle = true; - // Migration: v2.0.0 -> v2.1.0 view null used to mean "relay to all", now null means "off" + // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; state[SCRIPT_NAME].version = SCRIPT_VERSION; @@ -100,7 +100,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Config Storage GM layer text objects + // Config Storage — GM layer text objects // ========================================================================= const getConfigsOnPage = (pageId) => { @@ -264,7 +264,7 @@ var Gaslight = Gaslight || (() => { return null; } - // Ambiguous show disambiguation buttons + // Ambiguous — show disambiguation buttons var out = 'Multiple players named "' + playerArg + '":
      '; matches.forEach(function(p) { var chars = findObjs({ _type: 'character' }).filter(function(c) { @@ -288,7 +288,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Token GM Notes gaslight_link + // Token GM Notes — gaslight_link // ========================================================================= const getLinkId = (token) => { @@ -469,16 +469,16 @@ var Gaslight = Gaslight || (() => { /** * Read the gaslight_sync config for a token (from gmnotes only). * Returns: - * null not configured (default: sync all non-spatial) - * '' explicitly empty (no sync) - * ['prop1','prop2',...] specific props to sync + * null — not configured (default: sync all non-spatial) + * '' — explicitly empty (no sync) + * ['prop1','prop2',...] — specific props to sync */ const getGaslightSync = (token) => { var rawVal = null; if (token && typeof token === 'object' && typeof token.get === 'function') { rawVal = getSyncConfigRaw(token); } else if (typeof token === 'string') { - // Legacy: character ID read attribute directly (used internally only) + // Legacy: character ID — read attribute directly (used internally only) var attr = findObjs({ _type: 'attribute', _characterid: token, name: 'gaslight_sync' })[0]; if (!attr) return null; rawVal = (attr.get('current') || '').trim(); @@ -589,7 +589,7 @@ var Gaslight = Gaslight || (() => { } }); - // Step 4: unlinked only master-page represents tokens + // 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')) { @@ -686,7 +686,7 @@ var Gaslight = Gaslight || (() => { const controlledBy = character.get('controlledby') || ''; if (!controlledBy) return null; if (controlledBy === 'all') { - // All players control it return first group player as representative + // All players control it — return first group player as representative var firstPlayer = Object.keys(groupInfo.players)[0]; return firstPlayer || null; } @@ -747,7 +747,7 @@ var Gaslight = Gaslight || (() => { // 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 + // 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; } @@ -767,7 +767,7 @@ var Gaslight = Gaslight || (() => { var ids = tokens.map(function(t) { return t.get('id'); }); - // Check gaslight_sync read from first token's gmnotes (falls back to character attr) + // Check gaslight_sync — read from first token's gmnotes (falls back to character attr) var syncProps = getGaslightSync(tokens[0]); // syncProps: null = default (base spatial), '' = no sync at all, array = specific @@ -790,7 +790,7 @@ var Gaslight = Gaslight || (() => { } mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; } - // Always pass explicit components never let Anchor default to ALL_COMPONENTS + // Always pass explicit components — never let Anchor default to ALL_COMPONENTS if (!anchorComponents) { anchorComponents = {}; allAnchorProps.forEach(function(p) { anchorComponents[p] = true; }); @@ -1154,7 +1154,7 @@ var Gaslight = Gaslight || (() => { var tgtName = l.target.get('name') || l.target.get('id'); out += '? ' + srcName + ' ? ' + tgtName + ' (step ' + l.step + ')
      '; } else { - out += '?? ' + srcName + ' no match found
      '; + out += '?? ' + srcName + ' — no match found
      '; } }); if (links.length === 0) out += '(no linkable tokens)
      '; @@ -1395,12 +1395,12 @@ var Gaslight = Gaslight || (() => { var label = useDefault ? 'character default' : 'token'; - // No args show current config + // No args — show current config if (args.length === 0) { var results = tokens.map(function(t) { var raw = getVal(t); var name = t.get('name') || t.get('id'); - return '' + name + ': ' + (raw === null ? '(default sync all)' : raw || '(empty no sync)'); + return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); }); reply(msg, 'Sync', results.join('
      ')); return; @@ -1408,7 +1408,7 @@ var Gaslight = Gaslight || (() => { var subCmd = args[0].toLowerCase(); - // Reset re-copy from character attribute (only makes sense for token mode) + // Reset — re-copy from character attribute (only makes sense for token mode) if (subCmd === 'reset') { if (useDefault) { reply(msg, 'Error', 'Reset only applies to token sync, not --default.'); return; } tokens.forEach(function(t) { @@ -1426,7 +1426,7 @@ var Gaslight = Gaslight || (() => { return; } - // "all" explicitly sync everything + // "all" — explicitly sync everything if (subCmd === 'all') { tokens.forEach(function(t) { setVal(t, 'all'); }); reply(msg, 'Sync', 'Set to sync all properties on ' + tokens.length + ' ' + label + '(s).'); @@ -1472,7 +1472,7 @@ var Gaslight = Gaslight || (() => { if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync [--default] '); return; } - // "all" disable all syncing (set empty string = no sync) + // "all" — disable all syncing (set empty string = no sync) if (args[0].toLowerCase() === 'all') { tokens.forEach(function(t) { setVal(t, ''); }); reply(msg, 'Desync', 'Disabled all syncing on ' + tokens.length + ' ' + label + '(s).' + (useDefault ? '' : ' Use !gaslight sync reset to restore.')); @@ -1541,7 +1541,7 @@ var Gaslight = Gaslight || (() => { } } - // Normalize names ensure gl_ prefix + // Normalize names — ensure gl_ prefix actions.forEach(function(a) { if (!a.name.startsWith('gl_')) a.name = 'gl_' + a.name; }); @@ -1623,9 +1623,9 @@ var Gaslight = Gaslight || (() => { if (target.get('_pageid') === e[1].pageId) pageName = e[1].name || e[0]; }); }); - return pageName + ': ' + (val || ''); + return pageName + ': ' + (val || 'Ø'); }); - output.push('' + tokenName + ' ' + a.name + ' ' + vals.join(', ')); + output.push('' + tokenName + ' ' + a.name + ' — ' + vals.join(', ')); } else { var target = getTargets[0]; var tNotes = target.get('gmnotes') || ''; @@ -2258,7 +2258,7 @@ var Gaslight = Gaslight || (() => { + 'Aliases: init/turn/turns = initiative, relay = view
      '; // ========================================================================= - // Scripting Engine Fetch Integration + // Scripting Engine — Fetch Integration // ========================================================================= // Module-level evaluation context for Fetch compProp resolution @@ -2334,7 +2334,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Scripting Engine Trigger Map + // Scripting Engine — Trigger Map // ========================================================================= // triggerMap: attributeName ? [{ pinId, pageId }] @@ -2350,7 +2350,7 @@ var Gaslight = Gaslight || (() => { // 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) + // Find content inside {& if ...} blocks (simple regex — catches most cases) var ifRx = /\{&\s*if\s+(.+?)\}/gi; var match; while ((match = ifRx.exec(text)) !== null) { @@ -2450,7 +2450,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle attribute changes check trigger map and re-evaluate affected pins. + * Handle attribute changes — check trigger map and re-evaluate affected pins. */ const onAttributeChanged = (obj) => { var attrName = obj.get('name'); @@ -2466,7 +2466,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle token property changes check trigger map for graphic properties. + * 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'; }); @@ -2936,7 +2936,7 @@ var Gaslight = Gaslight || (() => { evaluationContext.viewerPlayerId = viewerPlayerId; // no linked copy on this viewer's page var content = scriptContent; - // Replace remaining @(target.*) with token ID Fetch resolves native props + // Replace remaining @(target.*) with token ID — Fetch resolves native props content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); // Replace @(gm_target.*) with master page token ID content = content.replace(/@\(gm_target\./g, '@(' + targetToken.get('id') + '.'); @@ -3306,7 +3306,7 @@ var Gaslight = Gaslight || (() => { }); } - // Token assignment only count tokens representing this character + // 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; }); @@ -3382,33 +3382,33 @@ var Gaslight = Gaslight || (() => { '
        ', '
      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. ', + '
      13. Select party tokens on the master page, run: !gaslight setup mygroup — this auto-detects duplicates, assigns pages to players, and configures the group.
      14. ', + '
      15. Run !gaslight test mygroup — dry-run that shows how tokens will link without activating anything. Fix any warnings before proceeding.
      16. ', + '
      17. Run !gaslight split mygroup — activates the group: links tokens across pages, moves players to their individual pages, and begins syncing.
      18. ', + '
      19. When done: !gaslight merge — tears down all links, returns players to the banner page.
      20. ', '
      ', '

      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

      ', + '

      !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).

      ', + '

      !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:

      ', '
        ', @@ -3419,15 +3419,15 @@ var Gaslight = Gaslight || (() => { '

        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.
        • ', + '
        • 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.

        ', + '

        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.

        ', @@ -3437,16 +3437,16 @@ var Gaslight = Gaslight || (() => { '
        // Comments start with //\n!token-mod --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)
        • ', + '
        • @(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
        • ', + '
        • 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.

        ', @@ -3467,14 +3467,14 @@ var Gaslight = Gaslight || (() => { aliases: { help: ['help', '--help'], man: 'man', examples: 'examples', whatsnew: 'whatsnew', genHelp: 'gen-help', genDev: 'gen-dev-docs' }, newSince: '2.1.0', help: { - description: 'Per-player map perception. Split players onto individual page copies with synchronized tokens each player can see different things while movement stays consistent.', + description: 'Per-player map perception. Split players onto individual page copies with synchronized tokens — each player can see different things while movement stays consistent.', quickStart: [ 'Create your master page with all tokens placed.', 'Duplicate it once per player (Roll20 built-in Duplicate Page).', - 'Select party tokens on the master page, run `!gaslight setup mygroup` auto-detects duplicates, assigns pages, configures the group.', - '`!gaslight test mygroup` dry-run showing how tokens will link. Fix any warnings.', - '`!gaslight split mygroup` activates the group: links tokens, moves players, begins syncing.', - 'When done: `!gaslight merge` tears down all links, returns players.', + 'Select party tokens on the master page, run `!gaslight setup mygroup` — auto-detects duplicates, assigns pages, configures the group.', + '`!gaslight test mygroup` — dry-run showing how tokens will link. Fix any warnings.', + '`!gaslight split mygroup` — activates the group: links tokens, moves players, begins syncing.', + 'When done: `!gaslight merge` — tears down all links, returns players.', ], changelog: [ { version: '2.1.0', changes: [ @@ -3485,7 +3485,7 @@ var Gaslight = Gaslight || (() => { 'Scripting engine fixes: anchor component fix, triggerMap auto-rebuild', ]}, { version: '2.0.0', changes: [ - 'Scripting engine reactive per-player automation via map pins', + 'Scripting engine — reactive per-player automation via map pins', 'RollCapture integration for automatic trigger evaluation', 'Auto-relay system for cross-page command forwarding', 'Aggregation functions: any(), all(), max(), min(), join()', @@ -3498,7 +3498,7 @@ var Gaslight = Gaslight || (() => { details: 'Auto-detects page copies by name, assigns master + player pages. Players resolved from selected tokens or named explicitly.', items: [ { name: '', description: 'Name for the group configuration', version: '2.0.0' }, - { name: '[players...]', description: 'Player names to include (optional auto-detected from selected tokens or party-tagged characters)', version: '2.0.0' }, + { name: '[players...]', description: 'Player names to include (optional — auto-detected from selected tokens or party-tagged characters)', version: '2.0.0' }, ]}, { syntax: 'split [--force]', description: 'Activate a prepared group', version: '1.0.0', details: 'Links tokens across pages, moves players to individual pages, begins syncing. Runs test-first unless --force.', @@ -3578,7 +3578,7 @@ var Gaslight = Gaslight || (() => { { name: 'Step 1: gaslight_link', description: 'Explicit match via gaslight_link ID in token GM notes', version: '1.0.0' }, { name: 'Step 2: represents + name', description: 'Same character + token name (unique pair per page)', version: '1.0.0' }, { name: 'Step 3: represents + fingerprint', description: 'Same character + position/property fingerprint', version: '1.0.0' }, - { name: 'Step 4: Unlinked', description: 'Has represents but no match found not synced', version: '1.0.0' }, + { name: 'Step 4: Unlinked', description: 'Has represents but no match found — not synced', version: '1.0.0' }, ], }, sync: { @@ -3599,12 +3599,12 @@ var Gaslight = Gaslight || (() => { description: 'Reactive per-player automation via map pins', version: '2.0.0', details: 'Scripts are stored in handouts and activated by placing map pins on the page. Each pin evaluates per-viewer per-target with conditional logic to fire different commands for different players.', - body: 'Create a handout with script content. Place a map pin on the page title it with the handout name (prefix [GLS] is stripped). The pin evaluates whenever triggered attributes change.', + body: 'Create a handout with script content. Place a map pin on the page — title it with the handout name (prefix [GLS] is stripped). The pin evaluates whenever triggered attributes change.', items: [ { name: '@(target.field)', description: 'Reference a target token field (bar values, GM notes gl_* vars)', version: '2.0.0' }, { name: '@(viewer.field)', description: 'Reference the viewer\'s token field', version: '2.0.0' }, { name: '@(gm.field)', description: 'Reference the GM master token field', version: '2.0.0' }, - { name: '{& if condition}...{& end}', description: 'Conditional block evaluates per viewer', version: '2.0.0' }, + { name: '{& if condition}...{& end}', description: 'Conditional block — evaluates per viewer', version: '2.0.0' }, { name: '{& select target_id}', description: 'Target selection (SelectManager integration)', version: '2.0.0' }, { name: 'any(), all(), max(), min()', description: 'Aggregate functions across viewer tokens', version: '2.0.0' }, { name: 'join()', description: 'Space-separated IDs of matching viewer tokens', version: '2.0.0' }, @@ -3644,7 +3644,7 @@ var Gaslight = Gaslight || (() => { title: 'Troubleshooting', description: 'Common issues and solutions', version: '1.0.0', - body: '**Tokens not linking** Run `!gaslight test ` to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.\n\n**Movement not syncing** Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with `!gaslight status`.\n\n**Relay not working** Confirm the command is in the relay list: `!gaslight config relay-list`. Commands must start with ! to be intercepted.\n\n**Scripts not evaluating** Pin must be on a page with an active split. Check pin title matches handout name. Run `!gaslight eval --dry-run` to debug.\n\n**Players see the same thing** Make sure the group is actually split (`!gaslight status`). Player-specific changes must be made on the player\'s page, not the master.', + body: '**Tokens not linking** — Run `!gaslight test ` to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.\n\n**Movement not syncing** — Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with `!gaslight status`.\n\n**Relay not working** — Confirm the command is in the relay list: `!gaslight config relay-list`. Commands must start with ! to be intercepted.\n\n**Scripts not evaluating** — Pin must be on a page with an active split. Check pin title matches handout name. Run `!gaslight eval --dry-run` to debug.\n\n**Players see the same thing** — Make sure the group is actually split (`!gaslight status`). Player-specific changes must be made on the player\'s page, not the master.', }, }, }, @@ -3673,16 +3673,16 @@ var Gaslight = Gaslight || (() => { description: 'Set up your first per-player split: pages, setup, split, and merge (guide only)', guide: [ { prompt: 'This guide walks you through creating your first **per-player split**.\n\nYou\'ll need at least 2 players in your game, but those players do not need to be online.' }, - { prompt: 'Create your **master page** with all tokens placed (NPCs, player characters, objects). This page is the GM\'s "ground truth" all changes start here.' }, - { prompt: 'Duplicate the page **once per player** using Roll20\'s built-in **Duplicate Page** button. Leave the page names as they are the *"Copy of"* prefix is how Gaslight auto-detects them.' }, - { prompt: 'Navigate to the master page and run `!gaslight setup mygroup` using one of these methods:\n\n**Option 1:** Select player-character tokens on the master page, then run `!gaslight setup mygroup` uses controlling players of selected tokens.\n\n**Option 2:** Set the master page as the banner page and run `!gaslight setup mygroup "Player1" "Player2" ...` with no selection uses the specified player names.\n\n**Option 3:** Define a Roll20 party, set the master page as the banner page, and run `!gaslight setup mygroup` with no selection uses controlling players of party tokens.\n\nAll options auto-detect duplicated pages and assign one per player.', + { prompt: 'Create your **master page** with all tokens placed (NPCs, player characters, objects). This page is the GM\'s "ground truth" — all changes start here.' }, + { prompt: 'Duplicate the page **once per player** using Roll20\'s built-in **Duplicate Page** button. Leave the page names as they are — the *"Copy of"* prefix is how Gaslight auto-detects them.' }, + { prompt: 'Navigate to the master page and run `!gaslight setup mygroup` using one of these methods:\n\n**Option 1:** Select player-character tokens on the master page, then run `!gaslight setup mygroup` — uses controlling players of selected tokens.\n\n**Option 2:** Set the master page as the banner page and run `!gaslight setup mygroup "Player1" "Player2" ...` with no selection — uses the specified player names.\n\n**Option 3:** Define a Roll20 party, set the master page as the banner page, and run `!gaslight setup mygroup` with no selection — uses controlling players of party tokens.\n\nAll options auto-detect duplicated pages and assign one per player.', ...ScriptKit.waitForCommand('!gaslight setup'), onContinue: () => { var groups = discoverAllGroups(); if (Object.keys(groups).length === 0) return 'No group configured yet. Run `!gaslight setup ` using one of the methods above.'; } }, - { prompt: 'Now run:\n\n`!gaslight split mygroup`\n\nThis activates the group players are moved to their individual pages and token syncing begins.', + { prompt: 'Now run:\n\n`!gaslight split mygroup`\n\nThis activates the group — players are moved to their individual pages and token syncing begins.', ...ScriptKit.waitForCommand('!gaslight split'), onContinue: () => { if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first.'; @@ -3691,8 +3691,8 @@ var Gaslight = Gaslight || (() => { { prompt: 'You may notice a **HUD** appeared on the master page. We\'ll cover that in a later example. For now, disable it with:\n\n`!gaslight hud off`', ...ScriptKit.waitForCommand('!gaslight hud') }, - { prompt: 'Your split is now active. Try moving an **NPC token** on the master page it syncs to all player pages automatically. NPC tokens only sync when updated on the master page (one-directional).\n\nThis means you can change an NPC on a specific player\'s page without affecting anyone else useful for hiding tokens, swapping images, or showing per-player information.' }, - { prompt: 'Now try moving a **player-controlled token** on that player\'s page. It syncs back to the master and out to other player pages players can move their own tokens and everyone sees it (bidirectional).' }, + { prompt: 'Your split is now active. Try moving an **NPC token** on the master page — it syncs to all player pages automatically. NPC tokens only sync when updated on the master page (one-directional).\n\nThis means you can change an NPC on a specific player\'s page without affecting anyone else — useful for hiding tokens, swapping images, or showing per-player information.' }, + { prompt: 'Now try moving a **player-controlled token** on that player\'s page. It syncs back to the master and out to other player pages — players can move their own tokens and everyone sees it (bidirectional).' }, { prompt: 'When you\'re done with the split (e.g. combat ends, scene changes), run:\n\n`!gaslight merge`.\n\nThis tears down all links and returns players to the banner page.' }, { prompt: '**That\'s the basics!** From here, explore the other examples to learn more about how to use gaslight.', offerExamples: ['core-mechanics', 'initiative-hud', 'relay', 'scripting'] }, ], @@ -3703,7 +3703,7 @@ var Gaslight = Gaslight || (() => { description: 'Staging, linking, syncing, and token lifecycle', source: SCRIPT_NAME, guide: [ - { prompt: '**Core Mechanics** This guide walks through the fundamental operations that make Gaslight tick: staging tokens, managing links, controlling sync, and what happens when tokens are deleted.\n\n**Prerequisite:** You need an active split. If you haven\'t set one up yet, run the **getting-started** guide first.', + { prompt: '**Core Mechanics** — This guide walks through the fundamental operations that make Gaslight tick: staging tokens, managing links, controlling sync, and what happens when tokens are deleted.\n\n**Prerequisite:** You need an active split. If you haven\'t set one up yet, run the **getting-started** guide first.', onContinue: () => { if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first, or complete the getting-started guide.'; } @@ -3724,11 +3724,11 @@ var Gaslight = Gaslight || (() => { if (!allStaged) return 'The previously selected token has not been staged yet. Select it and run `!gaslight stage`.'; } }, - { prompt: 'Your token is now staged copies exist on each player page and sync automatically.\n\nYou can also set a **default** so tokens representing a character auto-stage whenever placed on a gaslighted page:\n\n`!gaslight stage --default on`\n\nTo remove the default: `!gaslight stage --default off`' }, + { prompt: 'Your token is now staged — copies exist on each player page and sync automatically.\n\nYou can also set a **default** so tokens representing a character auto-stage whenever placed on a gaslighted page:\n\n`!gaslight stage --default on`\n\nTo remove the default: `!gaslight stage --default off`' }, { prompt: '**Unlinking** disconnects a player\'s copy from the master without deleting it. Useful for making permanent per-player differences.\n\nSelect a staged token on the master page and run:\n\n`!gaslight unlink`', ...ScriptKit.waitForCommand('!gaslight unlink') }, - { prompt: 'The player copy now lives independently changes on the master won\'t reach it.' }, + { prompt: 'The player copy now lives independently — changes on the master won\'t reach it.' }, { prompt: '**Relinking** re-establishes the connection. Select the same master token and run:\n\n`!gaslight link`\n\nThe player copy snaps back to match the master.', ...ScriptKit.waitForCommand('!gaslight link') }, @@ -3738,8 +3738,8 @@ var Gaslight = Gaslight || (() => { { prompt: '**Resyncing** resumes sync for a desynced property. The player copy snaps back to the master\'s current value.\n\nSelect the same token and run:\n\n`!gaslight sync left,top`', ...ScriptKit.waitForCommand('!gaslight sync') }, - { prompt: 'You can also set sync/desync **defaults** on a character so new tokens inherit the config:\n\n`!gaslight sync --default all` sync everything (the default)\n`!gaslight sync --default left,top,bar1_value` sync only these props\n`!gaslight desync --default left,top` exclude position from sync\n`!gaslight desync --default all` disable all syncing by default\n\nUse `!gaslight sync reset` on a token to re-read the character default.' }, - { prompt: '**Token deletion behavior:**\n\n Delete a **parent token** (master page, or controlling player\'s page for PCs) ? all linked copies are removed\n Delete a **non-parent copy** (e.g. an NPC on a player page) ? only that copy is removed, others remain' }, + { prompt: 'You can also set sync/desync **defaults** on a character so new tokens inherit the config:\n\n`!gaslight sync --default all` — sync everything (the default)\n`!gaslight sync --default left,top,bar1_value` — sync only these props\n`!gaslight desync --default left,top` — exclude position from sync\n`!gaslight desync --default all` — disable all syncing by default\n\nUse `!gaslight sync reset` on a token to re-read the character default.' }, + { prompt: '**Token deletion behavior:**\n\n• Delete a **parent token** (master page, or controlling player\'s page for PCs) ? all linked copies are removed\n• Delete a **non-parent copy** (e.g. an NPC on a player page) ? only that copy is removed, others remain' }, { prompt: '**That\'s the core!** You now know how to stage, link/unlink, sync/desync, and manage token lifecycle.\n\nNext, learn how the initiative HUD works or how to relay commands to player pages.', offerExamples: ['initiative-hud', 'relay', 'scripting'] }, @@ -3748,10 +3748,10 @@ var Gaslight = Gaslight || (() => { ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'initiative-hud', - description: 'Interactive initiative tracker HUD gestures, customization, reticle', + description: 'Interactive initiative tracker HUD — gestures, customization, reticle', source: SCRIPT_NAME, guide: [ - { prompt: '**Initiative HUD** This guide walks through the on-screen initiative tracker: how to interact with it, customize its appearance, and use the current turn reticle.\n\n**Prerequisites:** You need an active split with the HUD enabled.', + { prompt: '**Initiative HUD** — This guide walks through the on-screen initiative tracker: how to interact with it, customize its appearance, and use the current turn reticle.\n\n**Prerequisites:** You need an active split with the HUD enabled.', onContinue: () => { var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; @@ -3776,7 +3776,7 @@ var Gaslight = Gaslight || (() => { if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; } }, - { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (? in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative we\'ll cover that later.', + { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (? in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative — we\'ll cover that later.', onEnter: (ctx, advance) => { ctx._turnFired = false; on('change:campaign:turnorder', function() { @@ -3792,8 +3792,8 @@ var Gaslight = Gaslight || (() => { if (entries.length < 6) return 'Only ' + entries.length + ' token(s) in the HUD. Add at least 6.'; } }, - { prompt: '**Why duplicates?**\n\nYou may notice the Roll20 Turn Tracker panel shows what appears to be duplicate entries multiple entries for the same combatant. This is because each linked token (one per player page) has its own turn order entry.\n\nThe HUD **deduplicates** these automatically and shows one pin per combatant. The raw turn order is correct it\'s just how Gaslight tracks initiative across pages.' }, - { prompt: '**HUD Layout The Frame:**\n\nThe large rectangle behind the HUD is the **frame**. It defines the HUD\'s position and how many slots are visible. You\'ll learn how to customize it later.', + { prompt: '**Why duplicates?**\n\nYou may notice the Roll20 Turn Tracker panel shows what appears to be duplicate entries — multiple entries for the same combatant. This is because each linked token (one per player page) has its own turn order entry.\n\nThe HUD **deduplicates** these automatically and shows one pin per combatant. The raw turn order is correct — it\'s just how Gaslight tracks initiative across pages.' }, + { prompt: '**HUD Layout — The Frame:**\n\nThe large rectangle behind the HUD is the **frame**. It defines the HUD\'s position and how many slots are visible. You\'ll learn how to customize it later.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3809,7 +3809,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', x + w / 2, y + h / 2, { fromX: x + w / 2 + 100, fromY: y + h / 2 + 100, color: '#ff0000' }); } }, - { prompt: '**HUD Layout The Diamond:**\n\nThe diamond-shaped highlight marks the **current turn**. The pin inside it is the active combatant.', + { prompt: '**HUD Layout — The Diamond:**\n\nThe diamond-shaped highlight marks the **current turn**. The pin inside it is the active combatant.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3823,7 +3823,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', x - 80, y, { fromX: x - 180, fromY: y, color: '#ff0000' }); } }, - { prompt: '**HUD Layout The Pins:**\n\nEach pin represents one combatant. Pins **above** the diamond have already gone this round; pins **below** are upcoming turns. The HUD scrolls to keep the current turn visible.', + { prompt: '**HUD Layout — The Pins:**\n\nEach pin represents one combatant. Pins **above** the diamond have already gone this round; pins **below** are upcoming turns. The HUD scrolls to keep the current turn visible.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3853,7 +3853,7 @@ var Gaslight = Gaslight || (() => { }); } }, - { prompt: '**Gestures Next turn:**\n\nSwipe the **current turn** pin (the one inside the diamond) to the **right** to advance to the next combatant.\n\nTry it now.', + { prompt: '**Gestures — Next turn:**\n\nSwipe the **current turn** pin (the one inside the diamond) to the **right** to advance to the next combatant.\n\nTry it now.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3870,7 +3870,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); } }, - { prompt: '**Gestures Previous turn:**\n\nSwipe the **current turn** pin to the **left** to go back to the previous combatant.\n\nTry it now.', + { prompt: '**Gestures — Previous turn:**\n\nSwipe the **current turn** pin to the **left** to go back to the previous combatant.\n\nTry it now.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3886,7 +3886,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', center.x - 100, center.y, { fromX: center.x - 20, fromY: center.y, color: '#ff4444' }); } }, - { prompt: '**Gestures Jump to turn:**\n\nSwipe any **non-current** pin to the **right** to jump forward to that combatant\'s turn. Swipe **left** to jump backward to it.\n\nTry swiping a non-current pin.', + { prompt: '**Gestures — Jump to turn:**\n\nSwipe any **non-current** pin to the **right** to jump forward to that combatant\'s turn. Swipe **left** to jump backward to it.\n\nTry swiping a non-current pin.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3909,7 +3909,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); } }, - { prompt: '**Gestures Reordering:**\n\nDrag a HUD pin **up or down** to reorder it in initiative. The pin will snap into its new position.\n\nTry dragging a pin to a different slot.', + { prompt: '**Gestures — Reordering:**\n\nDrag a HUD pin **up or down** to reorder it in initiative. The pin will snap into its new position.\n\nTry dragging a pin to a different slot.', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.initData; @@ -3925,12 +3925,16 @@ var Gaslight = Gaslight || (() => { var pin = visible[visible.length - 1]; var pageId = pin.pin.get('_pageid'); var center = toPinCenter(pin.pin); + var frameX = frame ? frame.get('x') : center.x; + var fwPts = frame ? JSON.parse(frame.get('points') || '[]') : []; + var fw = fwPts.length >= 2 ? Math.abs(fwPts[1][0] - fwPts[0][0]) : 70; + var arrowX = frameX - fw / 2; ScriptKit.ping(pageId, center.x, center.y, { color: 'transparent', moveAll: true, player: ctx.player }); - ScriptKit.annotate(pageId, 'arrow', center.x, center.y - 80, { fromX: center.x, fromY: center.y - 10, color: '#00ccff' }); - ScriptKit.annotate(pageId, 'arrow', center.x, center.y + 80, { fromX: center.x, fromY: center.y + 10, color: '#00ccff' }); + ScriptKit.annotate(pageId, 'arrow', arrowX, center.y - 80, { fromX: arrowX, fromY: center.y - 10, color: '#00ccff' }); + ScriptKit.annotate(pageId, 'arrow', arrowX, center.y + 80, { fromX: arrowX, fromY: center.y + 10, color: '#00ccff' }); } }, - { prompt: '**Current Turn Reticle:**\n\nThe rectangle on the map highlighting the current turn\'s token is the **reticle**. It follows the active combatant wherever they are.\n\n **Drag up/down** on the reticle to offset it from the token\n **Resize** the reticle to change its proportional size\n **Rotate** it to add a rotation offset\n **Delete** the reticle to turn it off (or `!gaslight hud reticle off`)', + { prompt: '**Current Turn Reticle:**\n\nThe rectangle on the map highlighting the current turn\'s token is the **reticle**. It follows the active combatant wherever they are.\n\n• **Drag up/down** on the reticle to offset it from the token\n• **Resize** the reticle to change its proportional size\n• **Rotate** it to add a rotation offset\n• **Delete** the reticle to turn it off (or `!gaslight hud reticle off`)', onEnter: (ctx) => { var s = state[SCRIPT_NAME]; var d = s.hud.reticleData; @@ -3942,14 +3946,14 @@ var Gaslight = Gaslight || (() => { ScriptKit.ping(pageId, x, y, { color: 'transparent', moveAll: true, player: ctx.player }); } }, - { prompt: '**Customization Scaling:**\n\nResize any HUD pin and all pins scale together. The padding between pins stays fixed.\n\nTry resizing one of the pins.' }, - { prompt: '**Customization Text:**\n\nThe initiative value text next to each pin can be customized:\n\n Change **font**, **size**, **color**, or **stroke** directly on any text element in Roll20 all will update to match\n **Drag** a text element to adjust its position relative to the frame (closer, further, up, down)\n **Rotate** it to change the text angle\n\nTry changing the font or position of one of the text elements.' }, - { prompt: '**The Frame Vertical resize:**\n\nNotice that not all 6+ pins fit in the frame some are hidden. **Resize the frame vertically** (drag its bottom edge down) to reveal more slots.\n\nTry making the frame taller to show all your pins.' }, - { prompt: '**The Frame Diamond position:**\n\nYou can drag the **diamond highlight up or down** within the frame to shift where the current turn is displayed. Pins above and below will adjust accordingly.\n\nTry moving the diamond to a different position in the frame.' }, - { prompt: '**The Frame Other customization:**\n\nThe frame supports additional tweaks:\n\n **Drag** the frame to move the entire HUD\n **Resize horizontally** to adjust padding between pins\n Change the frame\'s **stroke color** or **fill** directly in Roll20\'s shape properties\n\nThe **diamond** can also be customized:\n\n Change its **color**, **stroke width**, **fill**, or **rotation** directly in Roll20\'s shape properties\n\nThe HUD remembers all of these choices.' }, - { prompt: '**Reticle Inheritance:**\n\nThe reticle inherits its **color** and **stroke width** from the diamond. It also inherits **rotation**, offset by 45 (so the diamond\'s 45 rotation becomes the reticle\'s 90, etc.).\n\nIf you changed the diamond\'s appearance in the previous steps, the reticle should reflect those changes.\n\nTo override the reticle independently, just change its properties directly. Once overridden, it stops inheriting that property from the diamond.' }, - { prompt: '**Gestures Removing:**\n\nDelete a HUD pin to remove that combatant from initiative. Try deleting one now.' }, - { prompt: '**Clearing initiative:**\n\nNow clear the rest of the initiative delete the remaining HUD pins or clear initiative from Roll20\'s Turn Tracker panel.\n\nClear initiative before continuing.', + { prompt: '**Customization — Scaling:**\n\nResize any HUD pin and all pins scale together. The padding between pins stays fixed.\n\nTry resizing one of the pins.' }, + { prompt: '**Customization — Text:**\n\nThe initiative value text next to each pin can be customized:\n\n• Change **font**, **size**, **color**, or **stroke** directly on any text element in Roll20 — all will update to match\n• **Drag** a text element to adjust its position relative to the frame (closer, further, up, down)\n• **Rotate** it to change the text angle\n\nTry changing the font or position of one of the text elements.' }, + { prompt: '**The Frame — Vertical resize:**\n\nNotice that not all 6+ pins fit in the frame — some are hidden. **Resize the frame vertically** (drag its bottom edge down) to reveal more slots.\n\nTry making the frame taller to show all your pins.' }, + { prompt: '**The Frame — Diamond position:**\n\nYou can drag the **diamond highlight up or down** within the frame to shift where the current turn is displayed. Pins above and below will adjust accordingly.\n\nTry moving the diamond to a different position in the frame.' }, + { prompt: '**The Frame — Other customization:**\n\nThe frame supports additional tweaks:\n\n• **Drag** the frame to move the entire HUD\n• **Resize horizontally** to adjust padding between pins\n• Change the frame\'s **stroke color** or **fill** directly in Roll20\'s shape properties\n\nThe **diamond** can also be customized:\n\n• Change its **color**, **stroke width**, **fill**, or **rotation** directly in Roll20\'s shape properties\n\nThe HUD remembers all of these choices.' }, + { prompt: '**Reticle — Inheritance:**\n\nThe reticle inherits its **color** and **stroke width** from the diamond. It also inherits **rotation**, offset by 45° (so the diamond\'s 45° rotation becomes the reticle\'s 90°, etc.).\n\nIf you changed the diamond\'s appearance in the previous steps, the reticle should reflect those changes.\n\nTo override the reticle independently, just change its properties directly. Once overridden, it stops inheriting that property from the diamond.' }, + { prompt: '**Gestures — Removing:**\n\nDelete a HUD pin to remove that combatant from initiative. Try deleting one now.' }, + { prompt: '**Clearing initiative:**\n\nNow clear the rest of the initiative — delete the remaining HUD pins or clear initiative from Roll20\'s Turn Tracker panel.\n\nClear initiative before continuing.', when: () => { var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); return turnOrder.length > 0; @@ -3967,7 +3971,7 @@ var Gaslight = Gaslight || (() => { if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; } }, - { prompt: '**Plugin-added initiative:**\n\nIf you use a plugin (GroupInitiative, etc.) to roll initiative, go ahead and use it now.\n\nIf you don\'t have one, select token(s) and click **Continue** Gaslight will add them to initiative for you.', + { prompt: '**Plugin-added initiative:**\n\nIf you use a plugin (GroupInitiative, etc.) to roll initiative, go ahead and use it now.\n\nIf you don\'t have one, select token(s) and click **Continue** — Gaslight will add them to initiative for you.', onContinue: (ctx) => { var s = state[SCRIPT_NAME]; var entries = (s.hud.initData && s.hud.initData.entries) || []; @@ -3985,13 +3989,13 @@ var Gaslight = Gaslight || (() => { Campaign().set('turnorder', JSON.stringify(turnOrder)); } }, - { prompt: '**The problem:** When a plugin adds tokens to initiative, it sets `Campaign().set(\'turnorder\')` but Gaslight cannot detect that change automatically.\n\nTo sync the HUD with the current turn order, run:\n\n`!gaslight init`\n\nThis is needed whenever initiative changes via a plugin (not via the HUD gestures).', + { prompt: '**The problem:** When a plugin adds tokens to initiative, it sets `Campaign().set(\'turnorder\')` — but Gaslight cannot detect that change automatically.\n\nTo sync the HUD with the current turn order, run:\n\n`!gaslight init`\n\nThis is needed whenever initiative changes via a plugin (not via the HUD gestures).', ...ScriptKit.waitForCommand('!gaslight init') }, - { prompt: '**Reset:**\n\nIf the HUD ever gets into a weird state (misplaced elements, corrupted data), you can reset it:\n\n`!gaslight hud init reset` destroys and recreates the initiative HUD from defaults\n`!gaslight hud reticle reset` destroys and recreates the reticle from defaults\n\nThis preserves your turn order but resets all visual customization.' }, - { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` shows it again with your saved customization\n`!gaslight hud reticle off` / `on` same for the reticle independently\n`!gaslight hud off` / `on` toggles all HUD elements at once' }, + { prompt: '**Reset:**\n\nIf the HUD ever gets into a weird state (misplaced elements, corrupted data), you can reset it:\n\n`!gaslight hud init reset` — destroys and recreates the initiative HUD from defaults\n`!gaslight hud reticle reset` — destroys and recreates the reticle from defaults\n\nThis preserves your turn order but resets all visual customization.' }, + { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` — hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` — shows it again with your saved customization\n`!gaslight hud reticle off` / `on` — same for the reticle independently\n`!gaslight hud off` / `on` — toggles all HUD elements at once' }, { prompt: '**?? Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ?? icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, - { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` toggle/reset the reticle\n`!gaslight init` sync turn order into HUD\n`!gaslight init sync` add missing linked tokens\n`!gaslight init trim` remove stale entries\n\n**That\'s the initiative HUD!**', + { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` — toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` — toggle/reset the reticle\n`!gaslight init` — sync turn order into HUD\n`!gaslight init sync` — add missing linked tokens\n`!gaslight init trim` — remove stale entries\n\n**That\'s the initiative HUD!**', offerExamples: ['relay', 'scripting'] }, ], @@ -4007,10 +4011,10 @@ var Gaslight = Gaslight || (() => { } }, { prompt: 'Run `!rollcapture dissect` in chat, then roll stealth for any NPC. This shows you the template fields available for capture.' }, - { prompt: 'Create a RollCapture rule for skills. Run `!rollcapture rule skills` this creates a *[RC] skills* handout. Open it in the journal.' }, - { prompt: 'Add the line `template: ` where `` is the roll template shown by dissect (e.g. "simple", "atk", "npc").' }, - { prompt: 'Add the line `name_field: ` where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, - { prompt: 'Add the line `result: ` where `` is the inline roll field holding the total (e.g. "r1", "roll1").' }, + { prompt: 'Create a RollCapture rule for skills. Run `!rollcapture rule skills` — this creates a *[RC] skills* handout. Open it in the journal.' }, + { prompt: 'Add the line `template: ` — where `` is the roll template shown by dissect (e.g. "simple", "atk", "npc").' }, + { prompt: 'Add the line `name_field: ` — where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, + { prompt: 'Add the line `result: ` — where `` is the inline roll field holding the total (e.g. "r1", "roll1").' }, { prompt: 'Save the handout and run `!rollcapture reload` to load your new rule.', ...ScriptKit.waitForCommand('!rollcapture reload') }, @@ -4126,7 +4130,7 @@ var Gaslight = Gaslight || (() => { ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'madness', - description: 'An afflicted player sees all tokens as enemies allies swap to a chosen enemy image', + description: 'An afflicted player sees all tokens as enemies — allies swap to a chosen enemy image', guide: [ { prompt: 'Select a token that has the **status marker** you want to represent madness applied to it (e.g. "screaming"). This marker will trigger the effect when placed on a viewer\'s token.', select: 'token', as: 'markerToken', min: 1, max: 1, @@ -4153,7 +4157,7 @@ var Gaslight = Gaslight || (() => { { prompt: (ctx) => '**Test:** Apply the **' + ctx.params._marker + '** marker to a viewer token, then run `!gaslight eval --all`. All tokens on that viewer\'s page should swap to the enemy image. Remove the marker and re-eval to restore.' }, ], handout: (ctx) => ({ - notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|th?????_{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', + notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|t¨´h??ë??ý?_{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', gmnotes: '---GASLIGHT-SCRIPT---\nfilter: characters\ntrigger: on change statusmarkers', archived: false, }), @@ -4344,7 +4348,7 @@ var Gaslight = Gaslight || (() => { // Universal relay: master-page refs, no player-page refs if (masterTokens.length > 0 && !hasPlayerPageRef) { var viewPlayerId = s.view; - if (!viewPlayerId) return; // view off no relay + if (!viewPlayerId) return; // view off — no relay var targetPlayerIds = viewPlayerId === 'master' ? Object.keys(activeEntry[1].playerPages) : [viewPlayerId]; @@ -4370,7 +4374,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // Initiative Tracking sync turn order across linked tokens + // Initiative Tracking — sync turn order across linked tokens // ========================================================================= /** @@ -4504,7 +4508,7 @@ var Gaslight = Gaslight || (() => { if (!entry.id || entry.id === '-1') return; var oldEntry = oldOrder.find(function(e) { return e.id === entry.id; }); if (!oldEntry || oldEntry.pr === entry.pr) return; - // Value changed propagate to linked tokens + // Value changed — propagate to linked tokens var info = getLinkedInfo(entry.id); if (info.linkedIds.length === 0) return; info.linkedIds.forEach(function(linkedId) { @@ -4562,7 +4566,7 @@ var Gaslight = Gaslight || (() => { } modified = true; } else { - // Not forward/backward manual drag. Reorder to group children after master. + // Not forward/backward — manual drag. Reorder to group children after master. var reordered = reorderInitiative(newOrder); if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { newOrder = reordered; @@ -4619,19 +4623,19 @@ var Gaslight = Gaslight || (() => { if (placed.has(entry.id)) return; if (!entry.id || entry.id === '-1') { result.push(entry); - // Don't add '-1' to placed multiple custom entries share this ID + // Don't add '-1' to placed — multiple custom entries share this ID return; } var info = getLinkedInfo(entry.id); if (info.linkedIds.length === 0) { - // Not a linked token just add it + // Not a linked token — just add it result.push(entry); placed.add(entry.id); return; } - // Skip children they'll be pulled in when we reach their master + // Skip children — they'll be pulled in when we reach their master if (!isMasterToken(entry.id)) return; // Find all entries in this link group @@ -4755,7 +4759,7 @@ var Gaslight = Gaslight || (() => { }; // ========================================================================= - // HUD System on-canvas indicators + // HUD System — on-canvas indicators // ========================================================================= const HUD_PREFIX = 'gaslight_hud_'; @@ -5066,14 +5070,14 @@ var Gaslight = Gaslight || (() => { seenGroups.add(entry.id); return true; } - // It's a child check if its master is in the order + // It's a child — check if its master is in the order var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); if (masterId && seenGroups.has(masterId)) return false; // master already shown if (masterId && order.some(function(e) { return e.id === masterId; })) { seenGroups.add(masterId); return false; // master is in order, it will be shown when we reach it } - // No master in order show this child as representative + // No master in order — show this child as representative if (!seenGroups.has(entry.id)) { seenGroups.add(entry.id); return true; @@ -5321,7 +5325,7 @@ var Gaslight = Gaslight || (() => { }); if (direction === undefined) { - // Initial creation delay reflow to give Roll20 time to register objects + // Initial creation — delay reflow to give Roll20 time to register objects setTimeout(function() { reflowInitiativeHud('none'); updateTurnReticle(); }, 100); } else { reflowInitiativeHud(direction); @@ -5374,12 +5378,12 @@ var Gaslight = Gaslight || (() => { // We can fit one more. Shift diamond toward the side with more slots. var shiftAmount; if (slotsBelow >= slotsAbove) { - // More below (or equal) shift down to make room above + // More below (or equal) — shift down to make room above shiftAmount = Math.min(step - extraAbove, extraBelow); frameCenter += shiftAmount; slotsAbove++; } else { - // More above shift up to make room below + // More above — shift up to make room below shiftAmount = Math.min(step - extraBelow, extraAbove); frameCenter -= shiftAmount; slotsBelow++; @@ -5599,7 +5603,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle change:text persist HUD element position/size if moved by GM. + * Handle change:text — persist HUD element position/size if moved by GM. */ const onHudTextChanged = (obj) => { var s = state[SCRIPT_NAME]; @@ -5620,7 +5624,7 @@ var Gaslight = Gaslight || (() => { var vRotation = obj.get('rotation'); if (vRotation !== undefined) s.hud.viewData.rotation = vRotation; } - // Initiative HUD text moved or styled update stored settings + // Initiative HUD text moved or styled — update stored settings if (s.hud.initiative && s.hud.initData && s.hud.initData.entries) { var match = s.hud.initData.entries.find(function(e) { return e.textId === id; }); if (match) { @@ -5725,7 +5729,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle destroy:text if a HUD element is deleted, treat as turning it off. + * Handle destroy:text — if a HUD element is deleted, treat as turning it off. */ const onHudTextDestroyed = (obj) => { var s = state[SCRIPT_NAME]; @@ -5765,7 +5769,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle destroy:pin if an initiative HUD pin is deleted, remove from turn order. + * Handle destroy:pin — if an initiative HUD pin is deleted, remove from turn order. */ const onHudGraphicDestroyed = (obj) => { var s = state[SCRIPT_NAME]; @@ -5776,7 +5780,7 @@ var Gaslight = Gaslight || (() => { if (matchIdx === -1) return; var match = data.entries[matchIdx]; - // Splice entry first associated text will be orphaned and cleaned up on next reflow + // Splice entry first — associated text will be orphaned and cleaned up on next reflow data.entries.splice(matchIdx, 1); var txt = getObj('text', match.textId); if (txt) txt.remove(); @@ -5784,12 +5788,12 @@ var Gaslight = Gaslight || (() => { // Remove from turn order var order = JSON.parse(Campaign().get('turnorder') || '[]'); if (match.sourceId && !match.sourceId.startsWith('custom:')) { - // Token remove it and linked children from turn order + // Token — remove it and linked children from turn order var info = getLinkedInfo(match.sourceId); var groupIds = new Set([match.sourceId].concat(info.linkedIds)); order = order.filter(function(e) { return !groupIds.has(e.id); }); } else { - // Custom turn remove first matching custom entry + // Custom turn — remove first matching custom entry var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); if (customIdx !== -1) order.splice(customIdx, 1); } @@ -5800,7 +5804,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle destroy:path if the initiative frame is deleted, turn off. + * Handle destroy:path — if the initiative frame is deleted, turn off. */ const onHudPathDestroyed = (obj) => { var s = state[SCRIPT_NAME]; @@ -5811,10 +5815,10 @@ var Gaslight = Gaslight || (() => { removeInitiativeHud(); sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); } else if (obj.get('id') === s.hud.initData.highlightId) { - // Highlight deleted just clear ID, will be recreated on next update + // Highlight deleted — just clear ID, will be recreated on next update s.hud.initData.highlightId = null; } else if (s.hud.reticleData && obj.get('id') === s.hud.reticleData.id) { - // Turn indicator deleted turn off reticle + // Turn indicator deleted — turn off reticle s.hud.reticleData.id = null; s.hud.reticleData.tokenId = null; s.hud.reticle = false; @@ -5823,7 +5827,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle frame pathv2 change resize logic, position/style tracking. + * Handle frame pathv2 change — resize logic, position/style tracking. */ const onFrameChanged = (obj, prev) => { var s = state[SCRIPT_NAME]; @@ -5866,7 +5870,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle highlight pathv2 change style tracking, cascade to reticle, Y offset. + * Handle highlight pathv2 change — style tracking, cascade to reticle, Y offset. */ const onHighlightChanged = (obj, prev) => { var s = state[SCRIPT_NAME]; @@ -5909,7 +5913,7 @@ var Gaslight = Gaslight || (() => { }; /** - * Handle reticle pathv2 change style override, size scale, position offset. + * Handle reticle pathv2 change — style override, size scale, position offset. */ const onReticleChanged = (obj, prev) => { var s = state[SCRIPT_NAME]; @@ -6108,7 +6112,7 @@ var Gaslight = Gaslight || (() => { return; } - // Single arg that's a toggle word apply to all elements + // Single arg that's a toggle word — apply to all elements if (args.length === 1 && toggleWords.has(args[0].toLowerCase())) { var toggle = args[0].toLowerCase(); if (toggle === 'reset') { @@ -6231,7 +6235,7 @@ var Gaslight = Gaslight || (() => { horizontalEscapePin = newX > frameLeftPin + fwPin / 2 || newX < frameLeftPin - fwPin / 2; } - // Pin dragged vertically reorder turn in initiative + // Pin dragged vertically — reorder turn in initiative if (!horizontalEscapePin && newY !== oldY) { var order = JSON.parse(Campaign().get('turnorder') || '[]'); var hudOrder = getHudTurnOrder(); @@ -6293,10 +6297,10 @@ var Gaslight = Gaslight || (() => { var insertBeforeIdx = -1; // hudOrder idx to insert before if (newY <= others[0].y) { - // Above all others insert before the topmost pin + // Above all others — insert before the topmost pin insertBeforeIdx = others[0].idx; } else if (newY >= others[others.length - 1].y) { - // Below all others insert after the bottommost pin + // Below all others — insert after the bottommost pin insertAfterIdx = others[others.length - 1].idx; } else { // Between two pins @@ -6415,7 +6419,7 @@ var Gaslight = Gaslight || (() => { } } - // Pin dragged horizontally make it this turn's turn + // Pin dragged horizontally — make it this turn's turn var oldX = prev.x; var verticalDrift = Math.abs(newY - oldY); var tknSizeP = data.tokenSize || defaultInitHud.tokenSize; @@ -6442,7 +6446,7 @@ var Gaslight = Gaslight || (() => { if (targetFullIdx !== -1) { if (targetFullIdx === 0) { - // This pin is the current turn advance/retreat to next/prev master or custom + // This pin is the current turn — advance/retreat to next/prev master or custom if (swipeDir === 'forward') { fullOrder.push(fullOrder.shift()); var skip = fullOrder.length; @@ -6469,7 +6473,7 @@ var Gaslight = Gaslight || (() => { fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); } } else { - // Non-current pin rotate to it, applying formulas to customs passed along the way + // Non-current pin — rotate to it, applying formulas to customs passed along the way var rotCount = swipeDir === 'forward' ? targetFullIdx : fullOrder.length - targetFullIdx; for (var ri = 0; ri < rotCount; ri++) { if (swipeDir === 'forward') { @@ -6494,7 +6498,7 @@ var Gaslight = Gaslight || (() => { reflowInitiativeHud('none'); updateTurnReticle(); - // Scale change adjust all HUD pins to match and update tokenSize + // Scale change — adjust all HUD pins to match and update tokenSize var newScale = obj.get('scale'); var oldScale = prev.scale; if (newScale !== oldScale && newScale != null) { @@ -6549,7 +6553,7 @@ var Gaslight = Gaslight || (() => { cached.handoutId = obj.get('link') || null; rebuildTriggerMapFromCache(); } else if (isActivePage(obj.get('_pageid')) && obj.get('link') && obj.get('linkType') === 'handout') { - // New script pin add to cache and rebuild + // New script pin — add to cache and rebuild knownScriptPins.push({ pinId: pinId, pageId: obj.get('_pageid'), handoutId: obj.get('link') }); rebuildTriggerMapFromCache(); } From ea6e6d3c1c82091708c3228642797f606017e2d1 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 24 Jul 2026 14:26:41 -0400 Subject: [PATCH 23/37] Gaslight: fix corrupted unicode characters --- Gaslight/Gaslight.js | 78 ++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7c4e7d437..0946ebb37 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,4 +1,4 @@ -// ============================================================================= +// ============================================================================= // Gaslight v2.1.0 // Last Updated: 2026-07-05 // Author: Kenan Millet @@ -164,7 +164,7 @@ var Gaslight = Gaslight || (() => { const discoverGroup = (groupName) => { const pages = findObjs({ _type: 'page' }); - const result = { master: null, players: {} }; // players keyed by playerid ? { pageId, name } + const result = { master: null, players: {} }; // players keyed by playerid → { pageId, name } pages.forEach(page => { const cfg = getGroupConfigOnPage(page.get('_id'), groupName); if (!cfg) return; @@ -610,8 +610,8 @@ var Gaslight = Gaslight || (() => { 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 + 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 = {}; @@ -656,7 +656,7 @@ var Gaslight = Gaslight || (() => { if (warnings.length === 0) return ''; var out = '
        Warnings:
        '; warnings.forEach(function(w) { - var icon = w.severity === 'error' ? '??' : w.severity === 'warning' ? '??' : '??'; + var icon = w.severity === 'error' ? '🔴' : w.severity === 'warning' ? '🟡' : 'ℹ️'; out += icon + ' ' + w.message + '
        '; }); return out; @@ -975,7 +975,7 @@ var Gaslight = Gaslight || (() => { 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')); + assignments.push(playerName + ' → ' + page.get('name')); }); var out = 'Group "' + groupName + '" set up:
        '; @@ -1023,7 +1023,7 @@ var Gaslight = Gaslight || (() => { 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: ' + + 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); @@ -1146,15 +1146,15 @@ var Gaslight = Gaslight || (() => { var out = 'Link Test: ' + groupName + '
        '; Object.entries(groupInfo.players).forEach(function(entry) { var playerId = entry[0], pInfo = entry[1]; - out += '
        Master ? ' + pInfo.name + ':
        '; + 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 + ')
        '; + out += '✓ ' + srcName + ' → ' + tgtName + ' (step ' + l.step + ')
        '; } else { - out += '?? ' + srcName + ' — no match found
        '; + out += '🟡 ' + srcName + ' — no match found
        '; } }); if (links.length === 0) out += '(no linkable tokens)
        '; @@ -1623,7 +1623,7 @@ var Gaslight = Gaslight || (() => { if (target.get('_pageid') === e[1].pageId) pageName = e[1].name || e[0]; }); }); - return pageName + ': ' + (val || 'Ø'); + return pageName + ': ' + (val || ''); }); output.push('' + tokenName + ' ' + a.name + ' — ' + vals.join(', ')); } else { @@ -2007,14 +2007,14 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); if (marketplaceFailures.length > 0) { - var failMsg = '?? ' + marketplaceFailures.length + ' token(s) could not be staged because their images are from the Roll20 Marketplace and not in your library.

        '; + var failMsg = '⚠️ ' + marketplaceFailures.length + ' token(s) could not be staged because their images are from the Roll20 Marketplace and not in your library.

        '; marketplaceFailures.forEach(function(f) { failMsg += '
        ' + '
        ' + '' + f.name + '
        '; }); failMsg += '

        To fix:
          ' - + '
        1. In the Art Library tab, find the asset under Premium Assets ? Marketplace Purchases, right-click it (or the folder), and select Copy to Library.
        2. ' + + '
        3. In the Art Library tab, find the asset under Premium Assets → Marketplace Purchases, right-click it (or the folder), and select Copy to Library.
        4. ' + '
        5. Replace the token(s) on the master page with the library copy (drag from your library).
        6. ' + '
        7. Re-run !gaslight stage with the new token(s) selected.
        8. ' + '
        '; @@ -2218,7 +2218,7 @@ var Gaslight = Gaslight || (() => { if (!entry[1].master) dangling.push(entry[0]); }); if (dangling.length > 0) { - var out = '?? Dangling groups with no master page:
        '; + var out = '⚠️ Dangling groups with no master page:
        '; dangling.forEach(function(gn) { out += '' + gn + ': '; out += '!gaslight ungroup ' + gn + ' --all to remove, or '; @@ -2337,7 +2337,7 @@ var Gaslight = Gaslight || (() => { // Scripting Engine — Trigger Map // ========================================================================= - // triggerMap: attributeName ? [{ pinId, pageId }] + // triggerMap: attributeName → [{ pinId, pageId }] var triggerMap = {}; var knownScriptPins = []; // cached pin IDs + handout links from last buildTriggerMap @@ -2551,7 +2551,7 @@ var Gaslight = Gaslight || (() => { // ========================================================================= /** - * Read a handout's notes content (async ? callback pattern). + * Read a handout's notes content (async → callback pattern). * Returns content via callback since Roll20 requires it for notes/gmnotes. */ const getHandoutContent = (handoutId, callback) => { @@ -2719,7 +2719,7 @@ 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 ---------------------------------------------------- + // ─── Viewer Aggregation ──────────────────────────────────────────────────── const OPS = ['>=', '<=', '!=', '!~', '=', '~', '>', '<']; @@ -2969,11 +2969,11 @@ var Gaslight = Gaslight || (() => { // 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()'); + 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()'); + whisper('⚠️ Script error: @(gm.*) must be inside any(), all(), max(), or min()'); return; } @@ -3739,7 +3739,7 @@ var Gaslight = Gaslight || (() => { ...ScriptKit.waitForCommand('!gaslight sync') }, { prompt: 'You can also set sync/desync **defaults** on a character so new tokens inherit the config:\n\n`!gaslight sync --default all` — sync everything (the default)\n`!gaslight sync --default left,top,bar1_value` — sync only these props\n`!gaslight desync --default left,top` — exclude position from sync\n`!gaslight desync --default all` — disable all syncing by default\n\nUse `!gaslight sync reset` on a token to re-read the character default.' }, - { prompt: '**Token deletion behavior:**\n\n• Delete a **parent token** (master page, or controlling player\'s page for PCs) ? all linked copies are removed\n• Delete a **non-parent copy** (e.g. an NPC on a player page) ? only that copy is removed, others remain' }, + { prompt: '**Token deletion behavior:**\n\n• Delete a **parent token** (master page, or controlling player\'s page for PCs) → all linked copies are removed\n• Delete a **non-parent copy** (e.g. an NPC on a player page) → only that copy is removed, others remain' }, { prompt: '**That\'s the core!** You now know how to stage, link/unlink, sync/desync, and manage token lifecycle.\n\nNext, learn how the initiative HUD works or how to relay commands to player pages.', offerExamples: ['initiative-hud', 'relay', 'scripting'] }, @@ -3758,7 +3758,7 @@ var Gaslight = Gaslight || (() => { if (!s.hud.initiative) return 'Initiative HUD is disabled. Run `!gaslight hud init on` to enable it.'; } }, - { prompt: 'Before we begin, clear any existing entries from the turn order. You can do this from Roll20\'s Turn Tracker panel (?).\n\nClear the turn order and click Continue.', + { prompt: 'Before we begin, clear any existing entries from the turn order. You can do this from Roll20\'s Turn Tracker panel (⚙).\n\nClear the turn order and click Continue.', when: () => { var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); return turnOrder.length > 0; @@ -3776,7 +3776,7 @@ var Gaslight = Gaslight || (() => { if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; } }, - { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (? in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative — we\'ll cover that later.', + { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (⚙ in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative — we\'ll cover that later.', onEnter: (ctx, advance) => { ctx._turnFired = false; on('change:campaign:turnorder', function() { @@ -3994,7 +3994,7 @@ var Gaslight = Gaslight || (() => { }, { prompt: '**Reset:**\n\nIf the HUD ever gets into a weird state (misplaced elements, corrupted data), you can reset it:\n\n`!gaslight hud init reset` — destroys and recreates the initiative HUD from defaults\n`!gaslight hud reticle reset` — destroys and recreates the reticle from defaults\n\nThis preserves your turn order but resets all visual customization.' }, { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` — hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` — shows it again with your saved customization\n`!gaslight hud reticle off` / `on` — same for the reticle independently\n`!gaslight hud off` / `on` — toggles all HUD elements at once' }, - { prompt: '**?? Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ?? icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, + { prompt: '**⚠️ Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ⚠️ icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` — toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` — toggle/reset the reticle\n`!gaslight init` — sync turn order into HUD\n`!gaslight init sync` — add missing linked tokens\n`!gaslight init trim` — remove stale entries\n\n**That\'s the initiative HUD!**', offerExamples: ['relay', 'scripting'] }, @@ -4157,7 +4157,7 @@ var Gaslight = Gaslight || (() => { { prompt: (ctx) => '**Test:** Apply the **' + ctx.params._marker + '** marker to a viewer token, then run `!gaslight eval --all`. All tokens on that viewer\'s page should swap to the enemy image. Remove the marker and re-eval to restore.' }, ], handout: (ctx) => ({ - notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|t¨´h??ë??ý?_{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', + notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|ŧ̈́h̶̛ë̵̡ý̸̲{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', gmnotes: '---GASLIGHT-SCRIPT---\nfilter: characters\ntrigger: on change statusmarkers', archived: false, }), @@ -4274,8 +4274,8 @@ var Gaslight = Gaslight || (() => { /** * 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. + * 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; @@ -4737,7 +4737,7 @@ var Gaslight = Gaslight || (() => { if (doSync) parts.push(added + ' entry(s) added'); var out = parts.join(', ') + '.'; if (warnings.length > 0) { - out += '

        ?? ' + warnings.join('
        ?? '); + out += '

        ⚠️ ' + warnings.join('
        ⚠️ '); } reply(msg, 'Init', out); if (s.hud.initiative) { @@ -4750,7 +4750,7 @@ var Gaslight = Gaslight || (() => { var tagStr = computeHudTag(e.sourceId); var sourceToken = getObj('graphic', e.sourceId); var name = sourceToken ? (sourceToken.get('name') || '') : ''; - pin.set('title', ((tagStr ? '?? ' : '') + name).trim()); + pin.set('title', ((tagStr ? '⚠️ ' : '') + name).trim()); pin.set('notes', tagStr || ''); }); } @@ -4895,9 +4895,9 @@ var Gaslight = Gaslight || (() => { // Determine display text var label; if (s.view === null) { - label = '?? RELAY OFF'; + label = '🔴 RELAY OFF'; } else if (s.view === 'master') { - label = '?? RELAY: ALL'; + label = '🟢 RELAY: ALL'; } else { // Resolve player name var playerName = s.view; @@ -4905,7 +4905,7 @@ var Gaslight = Gaslight || (() => { var entry = g.playerPages[s.view]; if (entry && entry.name) playerName = entry.name; }); - label = '?? VIEW: ' + playerName; + label = '🔵 VIEW: ' + playerName; } // Find existing or create new @@ -4958,7 +4958,7 @@ var Gaslight = Gaslight || (() => { /** * Convert a pin's x/y (the "point" where the teardrop points) to its visual center. - * Pin scale 0.25 = 10px, scale 2.0 = 80px ? size = scale * 40. + * Pin scale 0.25 = 10px, scale 2.0 = 80px → size = scale * 40. * Visual center is offset upward by half the pin's visual height. */ const toPinCenter = (pin) => { @@ -5286,7 +5286,7 @@ var Gaslight = Gaslight || (() => { if (!sourceToken) return; var tagStr = computeHudTag(entry.id); - var pinTitle = ((tagStr ? '?? ' : '') + (sourceToken.get('name') || '')).trim(); + var pinTitle = ((tagStr ? '⚠️ ' : '') + (sourceToken.get('name') || '')).trim(); var hudPin = createHudObj('pin', { _pageid: pageId, @@ -6076,7 +6076,7 @@ var Gaslight = Gaslight || (() => { if (!entry.owns) { entry.owns = function(id) { return entry.ids().indexOf(id) !== -1; }; } - // Generic reset if not overridden: disable ? reset state ? enable + // Generic reset if not overridden: disable → reset state → enable if (!entry.reset) { entry.reset = function() { var s = state[SCRIPT_NAME]; @@ -6261,7 +6261,7 @@ var Gaslight = Gaslight || (() => { } if (currentIdx < 0 || currentIdx >= hudOrder.length) { reflowInitiativeHud('none'); return; } - // Build a map of hudOrder index ? current visual Y position + // Build a map of hudOrder index → current visual Y position var tknSize = data.tokenSize || defaultInitHud.tokenSize; var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; var pinYByIdx = []; @@ -6525,7 +6525,7 @@ var Gaslight = Gaslight || (() => { }); }; - // 1. Handout notes change ? rebuild triggerMap from cache + // 1. Handout notes change → rebuild triggerMap from cache on('change:handout:notes', function(obj) { var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; @@ -6535,7 +6535,7 @@ var Gaslight = Gaslight || (() => { } }); - // 2. Pin gmnotes change ? rebuild from cache if pin is known + // 2. Pin gmnotes change → rebuild from cache if pin is known on('change:pin:gmNotes', function(obj) { if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { @@ -6543,7 +6543,7 @@ var Gaslight = Gaslight || (() => { } }); - // 3. Pin link/linkType change ? rebuild from cache + // 3. Pin link/linkType change → rebuild from cache on('change:pin:link', function(obj) { if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; // Update cache entry with new handout link @@ -6565,7 +6565,7 @@ var Gaslight = Gaslight || (() => { } }); - // 4. New pin added to active page ? add to cache and rebuild + // 4. New pin added to active page → add to cache and rebuild on('add:pin', function(obj) { if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; if (!isActivePage(obj.get('_pageid'))) return; From 45b20694f1f1aabd6e64ce80afbf2ff30dbf2654 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 24 Jul 2026 14:32:31 -0400 Subject: [PATCH 24/37] Gaslight: auto-add linked copies to initiative when staging mid-combat --- Gaslight/Gaslight.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 0946ebb37..440eb9654 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2006,6 +2006,47 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); + // Add linked copies to initiative if the staged token is already in the turn order + if (staged > 0) { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + var turnIds = new Set(turnOrder.map(function(e) { return e.id; })); + var added = 0; + tokens.forEach(function(token) { + var tokenId = token.get('id'); + if (!turnIds.has(tokenId)) return; + var entry = turnOrder.find(function(e) { return e.id === tokenId; }); + if (!entry) return; + var info = getLinkedInfo(tokenId); + info.linkedIds.forEach(function(lid) { + if (turnIds.has(lid)) return; + var linkedObj = getObj('graphic', lid); + if (!linkedObj) return; + turnOrder.push({ id: lid, pr: entry.pr, custom: entry.custom || '', _pageid: linkedObj.get('_pageid') }); + turnIds.add(lid); + added++; + }); + }); + if (added > 0) { + turnOrder = reorderInitiative(turnOrder); + Campaign().set('turnorder', JSON.stringify(turnOrder)); + if (s.hud.initiative) { + if (s.hud.initData && s.hud.initData.entries) { + s.hud.initData.entries.forEach(function(e) { + if (e.sourceId.startsWith('custom:')) return; + var pin = getObj('pin', e.tokenId); + if (!pin) return; + var tagStr = computeHudTag(e.sourceId); + var sourceToken = getObj('graphic', e.sourceId); + var name = sourceToken ? (sourceToken.get('name') || '') : ''; + pin.set('title', ((tagStr ? '⚠️ ' : '') + name).trim()); + pin.set('notes', tagStr || ''); + }); + } + updateInitiativeHud(); + } + } + } + if (marketplaceFailures.length > 0) { var failMsg = '⚠️ ' + marketplaceFailures.length + ' token(s) could not be staged because their images are from the Roll20 Marketplace and not in your library.

        '; marketplaceFailures.forEach(function(f) { From efd24a0f6acbb7eb4b92bf2de0651e91562d49c6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Fri, 24 Jul 2026 14:37:33 -0400 Subject: [PATCH 25/37] Gaslight: update HUD and clean initiative on parent token deletion --- Gaslight/Gaslight.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 440eb9654..3c7e38f8d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4310,6 +4310,17 @@ var Gaslight = Gaslight || (() => { if (target) target.remove(); }); destroying = false; + + // Update initiative HUD if the deleted tokens were in the turn order + if (s.hud.initiative) { + var allRemovedIds = new Set([tokenId].concat(linkedIds)); + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var newOrder = order.filter(function(e) { return !allRemovedIds.has(e.id); }); + if (newOrder.length !== order.length) { + Campaign().set('turnorder', JSON.stringify(newOrder)); + updateInitiativeHud(); + } + } }; /** From 6113219d2b7b2d35233eca3080544586d6743422 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 26 Jul 2026 07:44:11 -0400 Subject: [PATCH 26/37] Gaslight: rewrite relay example (hands-on), remove scripting placeholder --- Gaslight/Gaslight.js | 45 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 3c7e38f8d..d65a7d775 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4037,7 +4037,50 @@ var Gaslight = Gaslight || (() => { { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` — hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` — shows it again with your saved customization\n`!gaslight hud reticle off` / `on` — same for the reticle independently\n`!gaslight hud off` / `on` — toggles all HUD elements at once' }, { prompt: '**⚠️ Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ⚠️ icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` — toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` — toggle/reset the reticle\n`!gaslight init` — sync turn order into HUD\n`!gaslight init sync` — add missing linked tokens\n`!gaslight init trim` — remove stale entries\n\n**That\'s the initiative HUD!**', - offerExamples: ['relay', 'scripting'] + offerExamples: ['core-mechanics', 'relay', 'scripting'] + }, + ], + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'relay', + description: 'Command relay, view targeting, and the view HUD', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Relay** — This guide walks you through how commands propagate across pages.\n\n**Prerequisite:** You need an active split with at least one staged token.', + onContinue: () => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; + } + }, + { prompt: '**Try auto-relay:**\n\nSelect a **staged token on the master page** and run any command against it (e.g. `!token-mod --set bar1_value|10` if you have TokenMod, or any other API command that targets tokens).\n\nThen navigate to a player\'s page — you\'ll see the command affected their copy too. That\'s auto-relay: any command referencing a master-page token ID automatically propagates to all player pages.' }, + { prompt: '**Enable the View HUD:**\n\nThe view HUD shows the current relay status as text on the master page. If it\'s not already visible, enable it:\n\n`!gaslight hud view on`\n\nYou\'ll see a status label appear on the master page (e.g. "🟢 RELAY: ALL").', + ...ScriptKit.waitForCommand('!gaslight hud view') + }, + { prompt: '**Disable relay:**\n\nBy default, the view is set to `all` — commands relay to every player page. Let\'s turn it off first to see the difference:\n\n`!gaslight view off`\n\nNotice the View HUD updates to show relay is disabled.', + ...ScriptKit.waitForCommand('!gaslight view') + }, + { prompt: '**Try a command with relay off:**\n\nSelect a staged token on the master page and run a command against it (e.g. `!token-mod --set bar1_value|+15`).\n\nNavigate to a player\'s page — their copy was **not** affected. With view `off`, commands stay on master only.' }, + { prompt: (ctx) => { + var s = state[SCRIPT_NAME]; + var names = []; + Object.values(s.activeGroups).forEach(function(g) { + Object.values(g.playerPages).forEach(function(p) { if (p.name && names.indexOf(p.name) === -1) names.push(p.name); }); + }); + var examples = names.length > 0 ? names.map(function(n) { return '`!gaslight view "' + n + '"`'; }).join(' or ') : '`!gaslight view ""`'; + return '**Switch to a specific player:**\n\nNow target a single player:\n\n' + examples; + }, + ...ScriptKit.waitForCommand('!gaslight view') + }, + { prompt: '**Try a command with a player view:**\n\nSelect the same staged token on the master page and run a command against it.\n\nNavigate to that player\'s page — only their copy was affected. Other players\' copies remain unchanged.' }, + { prompt: '**Switch to all:**\n\nNow switch to relaying to everyone:\n\n`!gaslight view all`\n\nThe View HUD updates again.', + ...ScriptKit.waitForCommand('!gaslight view') + }, + { prompt: '**Try a command with view all:**\n\nRun the same command again with a staged token selected. Now check multiple player pages — all copies were affected. This is the default behavior.' }, + { prompt: '**Manual relay:**\n\nTo relay a command to specific players (overriding the current view), select a master token and run:\n\n`!gaslight relay all !token-mod --set bar1_value|5`\n\nYou can also target specific players:\n\n`!gaslight relay "" !token-mod --set statusmarkers|dead`' }, + { prompt: '**Player-page relay:**\n\nBy default, commands run on a **player page** only affect that page. To make a command also relay from player pages, add it to the relay list:\n\n`!gaslight config relay-add !token-mod`\n\nRemove with: `!gaslight config relay-remove !token-mod`\nView the list: `!gaslight config relay-list`' }, + { prompt: '**What doesn\'t relay:**\n\n• `!gaslight`, `!mirror`, and `!anchor` commands never auto-relay\n• Commands with no tokens selected don\'t relay unless a master token id is specified in their parameters\n• Commands run while view is `off` don\'t relay\n\n**That\'s relay!** You can now run commands from the master page and have them affect all (or specific) player views automatically.', + offerExamples: ['core-mechanics', 'initiative-hud', 'scripting'] }, ], }); From 8f65ebff4135fabda5eecc0e9df3c6b46a5ef0ce Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 13:05:51 -0400 Subject: [PATCH 27/37] Gaslight: scripting example (winds-of-magic walkthrough), fix registerGlCompProp timing --- Gaslight/Gaslight.js | 75 +++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d65a7d775..1a4571f85 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2477,6 +2477,7 @@ var Gaslight = Gaslight || (() => { } else { getPinScript(pin, function(content) { if (!content) return; + registerCompPropsFromScript(content); 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; }); @@ -3025,25 +3026,23 @@ var Gaslight = Gaslight || (() => { return l && (l.startsWith('!') || l.startsWith('{&')); }); - if (dryRun) { - lines.forEach(function(l) { - sendChat('player|' + msg.playerid, CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' + l); - }); - } else { - var fullCmd = lines.join('\n'); - 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'); - } - var batch = '!{{\n' - + CMD + ' --script-lock\n' - + fullCmd + ' {& select ' + viewerTarget.get('id') + '}\n' - + '(^)!^gaslight --script-unlock\n' - + '}}'; - sendChat(getPlayerName(senderId), batch); + var dryRunPrefix = dryRun ? + CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' : + ''; + var selectSuffix = ' {& select ' + viewerTarget.get('id') + '}\n'; + var fullCmd = lines.join(selectSuffix + dryRunPrefix); + 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'); } + var batch = '!{{\n' + + CMD + ' --script-lock\n' + + dryRunPrefix + fullCmd + selectSuffix + + '(^)!^gaslight --script-unlock\n' + + '}}'; + sendChat(getPlayerName(senderId), batch); } }; @@ -4085,6 +4084,41 @@ var Gaslight = Gaslight || (() => { ], }); + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'scripting', + description: 'Gaslight Scripts — build a "winds of magic" script step by step', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Scripting** — This guide walks you through building a Gaslight Script from scratch. We\'ll create a "Winds of Magic" effect: certain tokens radiate a magical aura (status marker) that only players with "second sight" can see.\n\n**Prerequisite:** You need an active split with staged tokens.', + onContinue: () => { + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; + } + }, + { prompt: '**Variables:**\n\nGaslight uses `gl_` prefixed variables to drive scripts. We need two:\n\n• `gl_magical` — marks a token as radiating magical energy\n• `gl_second_sight` — marks a character as able to perceive magic\n\nSelect a staged NPC token on the master page and run:\n\n`!gaslight var --set magical 1`', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**Character variable:**\n\nNow select a **player character token** and set second sight on the character sheet (so it applies to all their tokens):\n\n`!gaslight var --setch second_sight 1`\n\nThe `--setch` flag sets it on the character, not the individual token.', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**The script handout (1/2):**\n\nCreate a new **handout** in your journal. Give it a prefix of "[GLS]" and a name (e.g. "[GLS] Winds of Magic").\n\nIn the **Description & Notes** of the handout, paste this script:\n\n```!token-mod --ids @(target.id) --set {& if @(target.gl_magical) == 1 && any(@(viewer.gl_second_sight)) == 1}statusmarkers|+blue{& else}statusmarkers|-blue{& end}```\n\nThis adds the `blue` status marker to tokens that are magical — but only on pages where the viewer has second sight. Otherwise it removes it.\n\nClick Continue when done.' }, + { prompt: '**The script handout (2/2):**\n\nIn the **GM Notes**, type:\n\n```filter: all\ntrigger: gl_magical, gl_second_sight```\n\n• `filter: all` — evaluates against every staged token\n• `trigger:` — auto-re-evaluates when either variable changes\n\nClick Continue when the handout is configured.' }, + { prompt: '**The Pin:**\n\nDrag the script handout onto the master page to create a map pin. This pin represents the script and will be used to trigger evaluations.' }, + { prompt: '**Evaluate:**\n\nRun:\n\n`!gaslight eval --all`\n\nThis evaluates the script for every token. The NPC you marked as magical should now have the `blue` status marker on the player page(s) where the viewer has second sight — and NOT on pages where they don\'t.\n\nCheck both player pages to confirm.', + ...ScriptKit.waitForCommand('!gaslight eval') + }, + { prompt: '**Test the trigger (1/2):**\n\nSelect the player character token and remove their second sight:\n\n`!gaslight var --delch second_sight`\n\nBecause `trigger: gl_second_sight` is set, the script **automatically re-evaluates**. The blue marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch second_sight 1`', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**Test the trigger (2/2):**\n\nSelect the NPC token on the master page and remove their magical status:\n\n`!gaslight var --del magical`\n\nBecause `trigger: gl_magical` is set, the script **automatically re-evaluates**. The blue marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch magical 1`', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**Debugging:**\n\nIf something doesn\'t work, use dry-run to see what would execute:\n\n`!gaslight eval --dry-run --all`\n\nThis shows the fully-expanded commands after variable substitution, so you can verify the logic without changing anything.' }, + { prompt: '**Key concepts:**\n\n• `@(target.*)` — the token being evaluated (master copy)\n• `@(viewer.*)` — the viewing player\'s copy (use inside `any()`/`all()`)\n• `@(gm_target.*)` — always resolves to the master token\n• `any(@(viewer.gl_x))` — true if ANY viewer has the value\n• `all(@(viewer.gl_x))` — true if ALL viewers have the value\n• `{& if}` / `{& else}` / `{& end}` — conditional blocks\n• `--set` vs `--setch` — token variable vs character variable\n• `trigger:` — comma-separated list of variables that auto-trigger re-evaluation\n\nSee the other applied examples for more complex scripts.', + offerExamples: ['core-mechanics', 'initiative-hud', 'relay', 'stealth', 'truesight', 'madness'] + }, + ], + }); + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'stealth', description: 'Hide/show NPCs per player based on passive perception vs stealth DC (RollCapture)', @@ -4258,7 +4292,10 @@ var Gaslight = Gaslight || (() => { // createHelpHandout(); // Replaced by ScriptKit gen-help log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); checkDanglingGroups(); - if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) buildTriggerMap(); + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) { + buildTriggerMap(); + registerAllCompProps(); + } // HUD recovery: if enabled but object is missing, recreate; if disabled but object exists, clean up var s = state[SCRIPT_NAME]; From 0b10bd65c4772d397db4dba710566196f8833e9c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 13:47:35 -0400 Subject: [PATCH 28/37] Gaslight: fix gl_* compProp resolution (fallback to char attr, fix regex for [default] suffix, fix == to = in examples), remove evaluationContext --- Gaslight/Gaslight.js | 36 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 1a4571f85..7e184dc3f 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -2302,9 +2302,6 @@ var Gaslight = Gaslight || (() => { // 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. */ @@ -2319,20 +2316,18 @@ var Gaslight = Gaslight || (() => { /** * Register a gl_ field as a Fetch compProp on the graphic type. - * Resolution depends on evaluationContext.scope. + * Resolves from token gmnotes first, falls back to character attribute. */ 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) || ''; - } + // Token gmnotes first, fallback to character attribute + var tokenVal = readGlField(o.gmnotes, fieldName); + var charId = o.represents; + var charVal = getAttrByName(charId, fieldName) || ''; + return charVal; }; Fetch.CustomPropsByType.graphic.compProps[fieldName] = { nicks: [], val: valFn }; @@ -2348,7 +2343,7 @@ var Gaslight = Gaslight || (() => { */ 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 rx = /@\([^)]*\.(gl_[a-zA-Z0-9_]+)/g; var match; while ((match = rx.exec(text)) !== null) { registerGlCompProp(match[1]); @@ -2654,7 +2649,7 @@ var Gaslight = Gaslight || (() => { callback(parseConfigText(gmnotes)); } else { // No config found, use defaults - callback({ scope: 'token', filter: 'all', triggers: [] }); + callback({ filter: 'all', triggers: [] }); } }); return; @@ -2667,13 +2662,12 @@ var Gaslight = Gaslight || (() => { * Parse config text into structured object. */ const parseConfigText = (text) => { - var config = { scope: 'token', filter: 'all', triggers: [] }; + var config = { 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(); - else if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); + if (line.startsWith('filter:')) config.filter = line.slice(7).trim(); else if (line.startsWith('trigger:')) config.triggers.push(line.slice(8).trim()); }); return config; @@ -2972,10 +2966,6 @@ var Gaslight = Gaslight || (() => { 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 var content = scriptContent; // Replace remaining @(target.*) with token ID — Fetch resolves native props @@ -4100,7 +4090,7 @@ var Gaslight = Gaslight || (() => { { prompt: '**Character variable:**\n\nNow select a **player character token** and set second sight on the character sheet (so it applies to all their tokens):\n\n`!gaslight var --setch second_sight 1`\n\nThe `--setch` flag sets it on the character, not the individual token.', ...ScriptKit.waitForCommand('!gaslight var') }, - { prompt: '**The script handout (1/2):**\n\nCreate a new **handout** in your journal. Give it a prefix of "[GLS]" and a name (e.g. "[GLS] Winds of Magic").\n\nIn the **Description & Notes** of the handout, paste this script:\n\n```!token-mod --ids @(target.id) --set {& if @(target.gl_magical) == 1 && any(@(viewer.gl_second_sight)) == 1}statusmarkers|+blue{& else}statusmarkers|-blue{& end}```\n\nThis adds the `blue` status marker to tokens that are magical — but only on pages where the viewer has second sight. Otherwise it removes it.\n\nClick Continue when done.' }, + { prompt: '**The script handout (1/2):**\n\nCreate a new **handout** in your journal. Give it a prefix of "[GLS]" and a name (e.g. "[GLS] Winds of Magic").\n\nIn the **Description & Notes** of the handout, paste this script:\n\n```!token-mod --set {& if @(target.gl_magical[0]) = 1 && any(@(viewer.gl_second_sight[0])) = 1}statusmarkers|+blue{& else}statusmarkers|-blue{& end}```\n\nThis adds the `blue` status marker to tokens that are magical — but only on pages where the viewer has second sight. Otherwise it removes it.\n\nClick Continue when done.' }, { prompt: '**The script handout (2/2):**\n\nIn the **GM Notes**, type:\n\n```filter: all\ntrigger: gl_magical, gl_second_sight```\n\n• `filter: all` — evaluates against every staged token\n• `trigger:` — auto-re-evaluates when either variable changes\n\nClick Continue when the handout is configured.' }, { prompt: '**The Pin:**\n\nDrag the script handout onto the master page to create a map pin. This pin represents the script and will be used to trigger evaluations.' }, { prompt: '**Evaluate:**\n\nRun:\n\n`!gaslight eval --all`\n\nThis evaluates the script for every token. The NPC you marked as magical should now have the `blue` status marker on the player page(s) where the viewer has second sight — and NOT on pages where they don\'t.\n\nCheck both player pages to confirm.', @@ -4211,7 +4201,7 @@ var Gaslight = Gaslight || (() => { { prompt: '**Test:** Run `!gaslight eval --all`. Viewers with truesight should see *side 2* (true form) on disguised tokens.' }, ], handout: { - notes: '!token-mod --set {& if @(target.gl_can_disguise[0]) == 1 && any(@(viewer.gl_truesight[0])) == 1}currentSide|1{& else}currentSide|0{& end}', + notes: '!token-mod --set {& if @(target.gl_can_disguise[0]) = 1 && any(@(viewer.gl_truesight[0])) = 1}currentSide|1{& else}currentSide|0{& end}', gmnotes: '---GASLIGHT-SCRIPT---\nfilter: has gl_can_disguise', }, }); @@ -4241,7 +4231,7 @@ var Gaslight = Gaslight || (() => { { prompt: (ctx) => '**Test:** Run `!gaslight eval --all`. Viewers with second sight should see the **' + (ctx.params._marker || 'blue') + '** status marker on magical tokens.' }, ], handout: (ctx) => ({ - notes: '!token-mod --set {& if @(target.gl_magical[0]) == 1 && any(@(viewer.gl_second_sight[0])) == 1}statusmarkers|+' + (ctx.params._marker || 'blue') + '{& else}statusmarkers|-' + (ctx.params._marker || 'blue') + '{& end}', + notes: '!token-mod --set {& if @(target.gl_magical[0]) = 1 && any(@(viewer.gl_second_sight[0])) = 1}statusmarkers|+' + (ctx.params._marker || 'blue') + '{& else}statusmarkers|-' + (ctx.params._marker || 'blue') + '{& end}', archived: false, }), }); From 6cdbd7a1f953133ce71005921d5e414d01edfb57 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 13:50:05 -0400 Subject: [PATCH 29/37] Gaslight: remove winds-of-magic example (covered by scripting guide) --- Gaslight/Gaslight.js | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7e184dc3f..1e6939365 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4090,7 +4090,7 @@ var Gaslight = Gaslight || (() => { { prompt: '**Character variable:**\n\nNow select a **player character token** and set second sight on the character sheet (so it applies to all their tokens):\n\n`!gaslight var --setch second_sight 1`\n\nThe `--setch` flag sets it on the character, not the individual token.', ...ScriptKit.waitForCommand('!gaslight var') }, - { prompt: '**The script handout (1/2):**\n\nCreate a new **handout** in your journal. Give it a prefix of "[GLS]" and a name (e.g. "[GLS] Winds of Magic").\n\nIn the **Description & Notes** of the handout, paste this script:\n\n```!token-mod --set {& if @(target.gl_magical[0]) = 1 && any(@(viewer.gl_second_sight[0])) = 1}statusmarkers|+blue{& else}statusmarkers|-blue{& end}```\n\nThis adds the `blue` status marker to tokens that are magical — but only on pages where the viewer has second sight. Otherwise it removes it.\n\nClick Continue when done.' }, + { prompt: '**The script handout (1/2):**\n\nCreate a new **handout** in your journal. Give it a prefix of "[GLS]" and a name (e.g. "[GLS] Winds of Magic").\n\nIn the **Description & Notes** of the handout, paste this script:\n\n```!token-mod --set {& if @(target.gl_magical[0]) = 1 && any(@(viewer.gl_second_sight[0])) = 1}statusmarkers|+half-haze{& else}statusmarkers|-half-haze{& end}```\n\nThis adds the `half-haze` status marker to tokens that are magical — but only on pages where the viewer has second sight. Otherwise it removes it.\n\nClick Continue when done.' }, { prompt: '**The script handout (2/2):**\n\nIn the **GM Notes**, type:\n\n```filter: all\ntrigger: gl_magical, gl_second_sight```\n\n• `filter: all` — evaluates against every staged token\n• `trigger:` — auto-re-evaluates when either variable changes\n\nClick Continue when the handout is configured.' }, { prompt: '**The Pin:**\n\nDrag the script handout onto the master page to create a map pin. This pin represents the script and will be used to trigger evaluations.' }, { prompt: '**Evaluate:**\n\nRun:\n\n`!gaslight eval --all`\n\nThis evaluates the script for every token. The NPC you marked as magical should now have the `blue` status marker on the player page(s) where the viewer has second sight — and NOT on pages where they don\'t.\n\nCheck both player pages to confirm.', @@ -4206,36 +4206,6 @@ var Gaslight = Gaslight || (() => { }, }); - ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { - name: 'winds-of-magic', - description: 'Show magical auras only to viewers with second sight (via StatusFX)', - guide: [ - { prompt: 'Select a token and apply the **status marker** you want to represent the magical aura. Then select that token.', - select: 'token', as: 'markerToken', min: 1, max: 1, - onContinue: (ctx) => { - var token = Array.isArray(ctx.selected) ? ctx.selected[0] : ctx.selected; - var markers = (token.get('statusmarkers') || '').split(',').filter(Boolean); - if (markers.length === 0) return 'That token has no status markers applied. Apply the marker you want to use for the aura, then select the token.'; - // Store the first marker found - ctx.params._marker = markers[0].replace(/@\d+$/, ''); // strip badge number if any - } - }, - { prompt: 'Select aura-radiating tokens and run `!gaslight var --set magical 1`.' }, - { prompt: 'Select tokens that can perceive magic and run `!gaslight var --setch second_sight 1`.' }, - { prompt: '*Optional:* Install **StatusFX** for animated particle effects on the status marker.' }, - ScriptKit.handout(), - { prompt: (ctx) => 'Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', - select: 'pin', as: 'pin', min: 1, max: 1, - onContinue: validatePin - }, - { prompt: (ctx) => '**Test:** Run `!gaslight eval --all`. Viewers with second sight should see the **' + (ctx.params._marker || 'blue') + '** status marker on magical tokens.' }, - ], - handout: (ctx) => ({ - notes: '!token-mod --set {& if @(target.gl_magical[0]) = 1 && any(@(viewer.gl_second_sight[0])) = 1}statusmarkers|+' + (ctx.params._marker || 'blue') + '{& else}statusmarkers|-' + (ctx.params._marker || 'blue') + '{& end}', - archived: false, - }), - }); - ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { name: 'madness', description: 'An afflicted player sees all tokens as enemies — allies swap to a chosen enemy image', From cacc308b4d0a9b1a8d47ba65836f19444e6e7a70 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 13:51:24 -0400 Subject: [PATCH 30/37] Fixed a typo in the scripting guide --- Gaslight/Gaslight.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 1e6939365..eb5af9400 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4093,13 +4093,13 @@ var Gaslight = Gaslight || (() => { { prompt: '**The script handout (1/2):**\n\nCreate a new **handout** in your journal. Give it a prefix of "[GLS]" and a name (e.g. "[GLS] Winds of Magic").\n\nIn the **Description & Notes** of the handout, paste this script:\n\n```!token-mod --set {& if @(target.gl_magical[0]) = 1 && any(@(viewer.gl_second_sight[0])) = 1}statusmarkers|+half-haze{& else}statusmarkers|-half-haze{& end}```\n\nThis adds the `half-haze` status marker to tokens that are magical — but only on pages where the viewer has second sight. Otherwise it removes it.\n\nClick Continue when done.' }, { prompt: '**The script handout (2/2):**\n\nIn the **GM Notes**, type:\n\n```filter: all\ntrigger: gl_magical, gl_second_sight```\n\n• `filter: all` — evaluates against every staged token\n• `trigger:` — auto-re-evaluates when either variable changes\n\nClick Continue when the handout is configured.' }, { prompt: '**The Pin:**\n\nDrag the script handout onto the master page to create a map pin. This pin represents the script and will be used to trigger evaluations.' }, - { prompt: '**Evaluate:**\n\nRun:\n\n`!gaslight eval --all`\n\nThis evaluates the script for every token. The NPC you marked as magical should now have the `blue` status marker on the player page(s) where the viewer has second sight — and NOT on pages where they don\'t.\n\nCheck both player pages to confirm.', + { prompt: '**Evaluate:**\n\nRun:\n\n`!gaslight eval --all`\n\nThis evaluates the script for every token. The NPC you marked as magical should now have the `half-haze` status marker on the player page(s) where the viewer has second sight — and NOT on pages where they don\'t.\n\nCheck both player pages to confirm.', ...ScriptKit.waitForCommand('!gaslight eval') }, - { prompt: '**Test the trigger (1/2):**\n\nSelect the player character token and remove their second sight:\n\n`!gaslight var --delch second_sight`\n\nBecause `trigger: gl_second_sight` is set, the script **automatically re-evaluates**. The blue marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch second_sight 1`', + { prompt: '**Test the trigger (1/2):**\n\nSelect the player character token and remove their second sight:\n\n`!gaslight var --delch second_sight`\n\nBecause `trigger: gl_second_sight` is set, the script **automatically re-evaluates**. The half-haze marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch second_sight 1`', ...ScriptKit.waitForCommand('!gaslight var') }, - { prompt: '**Test the trigger (2/2):**\n\nSelect the NPC token on the master page and remove their magical status:\n\n`!gaslight var --del magical`\n\nBecause `trigger: gl_magical` is set, the script **automatically re-evaluates**. The blue marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch magical 1`', + { prompt: '**Test the trigger (2/2):**\n\nSelect the NPC token on the master page and remove their magical status:\n\n`!gaslight var --del magical`\n\nBecause `trigger: gl_magical` is set, the script **automatically re-evaluates**. The half-haze marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch magical 1`', ...ScriptKit.waitForCommand('!gaslight var') }, { prompt: '**Debugging:**\n\nIf something doesn\'t work, use dry-run to see what would execute:\n\n`!gaslight eval --dry-run --all`\n\nThis shows the fully-expanded commands after variable substitution, so you can verify the logic without changing anything.' }, From 4aaaaa8de2752c0eaf2ea878a5389200dbf61a66 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 15:27:52 -0400 Subject: [PATCH 31/37] Gaslight: revert to separate batches for script eval (ZeroFrame/SelectManager limitation) --- Gaslight/Gaslight.js | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index eb5af9400..4e422e6b6 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3016,24 +3016,23 @@ var Gaslight = Gaslight || (() => { return l && (l.startsWith('!') || l.startsWith('{&')); }); - var dryRunPrefix = dryRun ? + var dryRunPrefix = dryRun ? CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' : ''; var selectSuffix = ' {& select ' + viewerTarget.get('id') + '}\n'; - var fullCmd = lines.join(selectSuffix + dryRunPrefix); - 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'); - } - var batch = '!{{\n' - + CMD + ' --script-lock\n' - + dryRunPrefix + fullCmd + selectSuffix - + '(^)!^gaslight --script-unlock\n' - + '}}'; - sendChat(getPlayerName(senderId), batch); + var fullCmd = dryRunPrefix + lines.join(selectSuffix + dryRunPrefix) + selectSuffix; + + 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'); } + var batch = '!{{\n' + + CMD + ' --script-lock\n' + + fullCmd + + '(^)!^gaslight --script-unlock\n' + + '}}'; + sendChat(getPlayerName(senderId), batch); }; /** @@ -3041,6 +3040,7 @@ var Gaslight = Gaslight || (() => { */ const evaluatePins = (pins, msg, dryRun, targetTokenId, sourcePageId) => { var s = state[SCRIPT_NAME]; + var evalStart = Date.now(); pins.forEach(function(pin) { var pageId = pin.get('_pageid'); @@ -6264,6 +6264,7 @@ var Gaslight = Gaslight || (() => { }; const registerEventHandlers = () => { + on('chat:message', handleInput); on('chat:message', viewInterceptor); on('chat:message', function(msg) { From c65f7dde0cc6ab5ca14d2138eb9b796d9c37863a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 15:29:17 -0400 Subject: [PATCH 32/37] cleanup --- Gaslight/Gaslight.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 4e422e6b6..d8bf5f4f0 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3040,7 +3040,6 @@ var Gaslight = Gaslight || (() => { */ const evaluatePins = (pins, msg, dryRun, targetTokenId, sourcePageId) => { var s = state[SCRIPT_NAME]; - var evalStart = Date.now(); pins.forEach(function(pin) { var pageId = pin.get('_pageid'); @@ -6264,7 +6263,6 @@ var Gaslight = Gaslight || (() => { }; const registerEventHandlers = () => { - on('chat:message', handleInput); on('chat:message', viewInterceptor); on('chat:message', function(msg) { From efc3e5ccb2257d1d52f3f129eca92053e4042ab5 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 15:42:24 -0400 Subject: [PATCH 33/37] Updated madness text --- Gaslight/Gaslight.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d8bf5f4f0..bfc499258 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4234,7 +4234,7 @@ var Gaslight = Gaslight || (() => { { prompt: (ctx) => '**Test:** Apply the **' + ctx.params._marker + '** marker to a viewer token, then run `!gaslight eval --all`. All tokens on that viewer\'s page should swap to the enemy image. Remove the marker and re-eval to restore.' }, ], handout: (ctx) => ({ - notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|ŧ̈́h̶̛ë̵̡ý̸̲{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|@(gm_target.name){& end}', + notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|"i̒̋̃̑͝_͓̘̝̾̈̿s̵͔̤͚̬͔͉̼̞̦̲̑͊́̏̌̀̿͐ͣ̅͂̃͊͐ͦͤ̊ t̶̴̟̺̲̻̼̼̗̜̤̱̬͍̭̍ͭ͊̿ͮͬ̊̄̋̍̉̾͠_͎͔͍̻̩̣̌ͤͩ̿̾ͮḩ̛͈̫͚̺̼̻̦͔͊ͥͥ̄̅_̛͙ͭaṱ̨̩͈̘̻͚͖̺͕͔̹̱̫̠͙̳̖͕̠͙͆̍̋̍ͨͪͭ̓̂̂̀ͬͬ̃̽̈́̐̃ͯ̽̚͢͢͝ m̶̴̷̧͚̖̼͎͙̹̟̠͎̼̺͓̺̭̄̅͌̿̅̉̂͛̀̏͆̒̇̀̇ͬ̿̋ͮ̓̋̈͘͘͟ȩ̶̵̛̘̬̗̹̟̟̰͎̦͓̹͈͍̘̻ͦ̒̉̉̾̿̇̂̋ͬ́̄̆ͧ͌͊́̈́ͭͪ͛̈̚̚͟͝ͅ"{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|"@(gm_target.name)"{& end}', gmnotes: '---GASLIGHT-SCRIPT---\nfilter: characters\ntrigger: on change statusmarkers', archived: false, }), From f5b6341482249482b1da786a183ab3e157a652be Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 15:55:05 -0400 Subject: [PATCH 34/37] Gaslight: polish stealth/truesight/madness examples (intros, waitForCommand) --- Gaslight/Gaslight.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index bfc499258..762fbaccf 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -4112,12 +4112,14 @@ var Gaslight = Gaslight || (() => { name: 'stealth', description: 'Hide/show NPCs per player based on passive perception vs stealth DC (RollCapture)', guide: [ - { prompt: 'This example requires **RollCapture** to capture stealth rolls.', + { prompt: '**Stealth** — This script automatically hides/shows NPCs based on whether each player\'s passive perception beats the NPC\'s stealth roll. Requires **RollCapture** to detect stealth rolls.\n\nClick Continue to verify RollCapture is installed.', onContinue: () => { if (typeof RollCapture === 'undefined') return 'RollCapture is not installed. Install it from the one-click menu first.'; } }, - { prompt: 'Run `!rollcapture dissect` in chat, then roll stealth for any NPC. This shows you the template fields available for capture.' }, + { prompt: '**Configure RollCapture:**\n\nRun `!rollcapture dissect` in chat, then roll stealth for any NPC. This shows you the template fields available for capture.', + ...ScriptKit.waitForCommand('!rollcapture dissect') + }, { prompt: 'Create a RollCapture rule for skills. Run `!rollcapture rule skills` — this creates a *[RC] skills* handout. Open it in the journal.' }, { prompt: 'Add the line `template: ` — where `` is the roll template shown by dissect (e.g. "simple", "atk", "npc").' }, { prompt: 'Add the line `name_field: ` — where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, @@ -4148,7 +4150,7 @@ var Gaslight = Gaslight || (() => { name: 'truesight', description: 'Reveal true forms to viewers with truesight (swap token side)', guide: [ - { prompt: 'Select one or more **multi-sided tokens** that represent disguised creatures. *Side 1* = disguised appearance, *side 2* = true form.', + { prompt: '**Truesight** — This script swaps multi-sided tokens between their disguised appearance (side 1) and true form (side 2) based on whether the viewer has truesight.\n\nSelect one or more **multi-sided tokens** that represent disguised creatures. *Side 1* = disguised, *side 2* = true form.', select: 'token', as: 'disguised', min: 1, onContinue: (ctx) => { var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; @@ -4163,6 +4165,7 @@ var Gaslight = Gaslight || (() => { } }, { prompt: 'Run `!gaslight var --setch can_disguise 1` with those tokens still selected. This marks them as targets for the truesight script.', + ...ScriptKit.waitForCommand('!gaslight var'), onContinue: (ctx) => { var tokens = Array.isArray(ctx.selections.disguised) ? ctx.selections.disguised : [ctx.selections.disguised]; var missing = tokens.filter(t => { @@ -4178,6 +4181,7 @@ var Gaslight = Gaslight || (() => { } }, { prompt: 'Now select viewer tokens that have **truesight** and run `!gaslight var --set truesight 1`.', + ...ScriptKit.waitForCommand('!gaslight var'), select: 'token', as: 'viewers', min: 1, onContinue: (ctx) => { var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; @@ -4209,7 +4213,7 @@ var Gaslight = Gaslight || (() => { name: 'madness', description: 'An afflicted player sees all tokens as enemies — allies swap to a chosen enemy image', guide: [ - { prompt: 'Select a token that has the **status marker** you want to represent madness applied to it (e.g. "screaming"). This marker will trigger the effect when placed on a viewer\'s token.', + { prompt: '**Madness** — When a player is "afflicted" (via a status marker), all other tokens on their page swap to a chosen enemy image with a dark tint and garbled names. Removing the marker restores everything.\n\nSelect a token that has the **status marker** you want to represent madness applied to it (e.g. "screaming"). This marker will trigger the effect when placed on a viewer\'s token.', select: 'token', as: 'markerToken', min: 1, max: 1, onContinue: (ctx) => { var token = Array.isArray(ctx.selected) ? ctx.selected[0] : ctx.selected; From f98df0257b46f17e51e8881df9846c41fe035cfb Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 16:13:46 -0400 Subject: [PATCH 35/37] Gaslight v2.2.0: version bump, script.json, README, changelog, versioned folder --- Gaslight/2.2.0/Gaslight.js | 6692 ++++++++++++++++++++++++++++++++++++ Gaslight/Gaslight.js | 19 +- Gaslight/README.md | 48 +- Gaslight/script.json | 13 +- 4 files changed, 6756 insertions(+), 16 deletions(-) create mode 100644 Gaslight/2.2.0/Gaslight.js diff --git a/Gaslight/2.2.0/Gaslight.js b/Gaslight/2.2.0/Gaslight.js new file mode 100644 index 000000000..210ce91fb --- /dev/null +++ b/Gaslight/2.2.0/Gaslight.js @@ -0,0 +1,6692 @@ +// ============================================================================= +// Gaslight v2.2.0 +// Last Updated: 2026-07-30 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// 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, Mirror, SelectManager, RollCapture (optional) +// +// Commands: +// !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...|--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 sync [props|all|reset] Manage sync per token +// !gaslight desync [props|all] Exclude props from sync +// !gaslight var [--silent] [actions...] Read/set/unset gl_ variables +// !gaslight view [player|master|off] Switch relay view target +// !gaslight relay Manually relay command to views +// !gaslight config [relay-add|remove|list] Configure auto-relay commands +// !gaslight hud [on|off|reset] [element] Toggle HUD elements +// !gaslight eval [--dry-run] [--all|] Evaluate script pins +// !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.2.0'; + 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 + // ========================================================================= + + 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: 'master', + hud: { view: true, initiative: true } + }; + } + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; + if (!state[SCRIPT_NAME].hud) state[SCRIPT_NAME].hud = { view: true, initiative: true, reticle: true }; + if (state[SCRIPT_NAME].hud.initiative === undefined) state[SCRIPT_NAME].hud.initiative = false; + if (state[SCRIPT_NAME].hud.reticle === undefined) state[SCRIPT_NAME].hud.reticle = true; + // Migration: v2.0.0 -> v2.1.0 — view null used to mean "relay to all", now null means "off" + if (!state[SCRIPT_NAME].version || state[SCRIPT_NAME].version < '2.1.0') { + if (state[SCRIPT_NAME].view === null) state[SCRIPT_NAME].view = 'master'; + state[SCRIPT_NAME].version = SCRIPT_VERSION; + } + }; + + // ========================================================================= + // 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 */ } + 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; + }; + + 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; + }; + + /** + * Test whether an imgsrc can be used with createObj. + * Creates a temporary token, checks if it succeeded, deletes it immediately. + * @param {string} imgsrc - URL to test + * @param {string} pageid - page to create the test token on + * @returns {boolean} true if the imgsrc is valid for API token creation + */ + const canCreateWithImgsrc = (imgsrc, pageid) => { + if (!imgsrc) return false; + var test = createObj('graphic', { + _subtype: 'token', + pageid: pageid, + imgsrc: imgsrc, + left: -1000, top: -1000, width: 10, height: 10, + layer: 'gmlayer' + }); + if (test) { + test.remove(); + return true; + } + return false; + }; + + /** + * Stage a single token to target pages using 3-step logic. + * Returns { cloned: number, failed: boolean, name: string, imgsrc: string } + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + var result = { cloned: 0, failed: false, name: token.get('name') || '(unnamed)', imgsrc: '' }; + + 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; + } + + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + + // Create with spatial + identity + gmnotes (minimum for Anchor/linking to work) + 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'), + flipv: token.get('flipv'), + fliph: token.get('fliph'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '', + gmnotes: token.get('gmnotes') || '' + }); + if (newToken) { + setLinkId(newToken, getLinkId(token)); + result.cloned++; + } else { + result.failed = true; + result.imgsrc = imgsrc; + } + }); + return result; + }; + + 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 gaslight_sync from a token's gmnotes. + * Returns the raw string value, or null if not present. + */ + const getSyncConfigRaw = (token) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + notes = notes.replace(/<\/p>/gi, '\n').replace(//gi, '\n').replace(/<[^>]+>/g, ''); + const match = notes.match(/gaslight_sync:\s*(.+)/); + return match ? match[1].trim() : null; + }; + + const setSyncConfig = (token, value) => { + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + if (notes.match(/gaslight_sync:\s*.*/)) { + notes = notes.replace(/gaslight_sync:\s*.*/, 'gaslight_sync: ' + value); + } else { + notes = (notes ? notes + '\n' : '') + 'gaslight_sync: ' + value; + } + token.set('gmnotes', notes); + }; + + const autoPopulateSyncConfig = (token) => { + if (getSyncConfigRaw(token) !== null) return; // already has one + const charId = token.get('represents'); + if (!charId) return; + const attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr && attr.get('current') !== undefined && attr.get('current') !== null) { + setSyncConfig(token, attr.get('current')); + } + }; + + /** + * Read the gaslight_sync config for a token (from gmnotes only). + * Returns: + * null — not configured (default: sync all non-spatial) + * '' — explicitly empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (token) => { + var rawVal = null; + if (token && typeof token === 'object' && typeof token.get === 'function') { + rawVal = getSyncConfigRaw(token); + } else if (typeof token === 'string') { + // Legacy: character ID — read attribute directly (used internally only) + var attr = findObjs({ _type: 'attribute', _characterid: token, name: 'gaslight_sync' })[0]; + if (!attr) return null; + rawVal = (attr.get('current') || '').trim(); + } + if (rawVal === null || rawVal === undefined) return null; + if (rawVal === '') return ''; + // Parse comma-separated props, resolve groups + var parts = rawVal.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 — read from first token's gmnotes (falls back to character attr) + var syncProps = getGaslightSync(tokens[0]); + // 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']; + var needsAnchor = true; + var anchorComponents = null; // null = use allAnchorProps as default + 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; + } + // Always pass explicit components — never let Anchor default to ALL_COMPONENTS + if (!anchorComponents) { + anchorComponents = {}; + allAnchorProps.forEach(function(p) { anchorComponents[p] = true; }); + } + + // 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 anchor and sight (sight stripped from children) + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + // Also exclude sight unless explicitly included in gaslight_sync + if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS && Mirror.PROP_GROUPS.sight) { + mirrorExcludes = mirrorExcludes.concat(Mirror.PROP_GROUPS.sight); + } + Mirror.chainLink(ids, null, mirrorExcludes); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props (user explicitly chose what to sync) + 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> [players...]'); 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(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); + }); + + // 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); + + // Enable relay by default on split + var s = state[SCRIPT_NAME]; + s.view = 'master'; + + // Recreate HUD elements if enabled + if (s.hud.view) updateViewHud(); + if (s.hud.initiative) updateInitiativeHud(); + + 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); + + // 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) { + 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') || {}; + // Remove all players assigned to any page in this group + var groupPageIds = new Set([active.masterPageId]); + Object.values(active.playerPages).forEach(function(p) { groupPageIds.add(p.pageId); }); + Object.keys(psp).forEach(function(playerId) { + if (groupPageIds.has(psp[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.'); + + // Destroy HUD elements (preference preserved, will recreate on next split) + Object.keys(hudRegistry).forEach(function(el) { hudRegistry[el].disable(); }); + }; + + 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) => { + // --default []: set gaslight_link on selected tokens' characters + var defaultIdx = args.indexOf('--default'); + if (defaultIdx !== -1) { + var name = args[defaultIdx + 1] && !args[defaultIdx + 1].startsWith('-') ? args[defaultIdx + 1] : null; + 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) representing characters.'); return; } + var count = 0; + tokens.forEach(function(t) { + var charId = t.get('represents'); + if (!charId) return; + var linkId = name || getLinkId(t) || genId(); + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr) attr.set('current', linkId); + else createObj('attribute', { characterid: charId, name: LINK_KEY, current: linkId }); + count++; + }); + reply(msg, 'Link', 'Set ' + LINK_KEY + ' default on ' + count + ' character(s). New tokens representing these characters will auto-link with the stored name.'); + return; + } + + 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) => { + // --default: remove gaslight_link from selected tokens' characters + var defaultIdx = args.indexOf('--default'); + if (defaultIdx !== -1) { + 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) representing characters.'); return; } + var count = 0; + tokens.forEach(function(t) { + var charId = t.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: LINK_KEY })[0]; + if (attr) { attr.remove(); count++; } + }); + reply(msg, 'Unlink', 'Removed ' + LINK_KEY + ' default from ' + count + ' character(s).'); + return; + } + + 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; } + var s = state[SCRIPT_NAME]; + tokens.forEach(function(t) { + var tokenId = t.get('id'); + var tokenPageId = t.get('_pageid'); + + // Find the active group this token belongs to + var activeGroup = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId] || Object.values(active.linkedTokens).some(function(peers) { return peers.indexOf(tokenId) !== -1; })) { + activeGroup = active; + } + }); + + // Determine if this is a parent token + var isParent = false; + if (activeGroup) { + if (tokenPageId === activeGroup.masterPageId) { + isParent = true; + } else { + var controlledBy = t.get('controlledby') || ''; + if (!controlledBy) { + var charId = t.get('represents'); + var character = charId ? getObj('character', charId) : null; + if (character) controlledBy = character.get('controlledby') || ''; + } + if (controlledBy) { + var controllerIds = controlledBy.split(',').map(function(c) { return c.trim(); }).filter(Boolean); + var playerEntry = Object.entries(activeGroup.playerPages).find(function(e) { + return controllerIds.indexOf(e[0]) !== -1 && e[1].pageId === tokenPageId; + }); + if (playerEntry) isParent = true; + } + } + } + + if (isParent && activeGroup) { + // Parent unlink: dissolve the entire link group + var allIds = [tokenId].concat(activeGroup.linkedTokens[tokenId] || []); + // Also gather any peers that list this token + Object.entries(activeGroup.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1 && allIds.indexOf(entry[0]) === -1) allIds.push(entry[0]); + }); + // Break anchor chain ring (if token is part of one) + if (typeof Anchor !== 'undefined') Anchor.unchainAnchorObjs(tokenId); + // Remove anchor from all child tokens (non-chain members anchored to a chain member) + allIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + }); + // Break mirror chain and links + if (typeof Mirror !== 'undefined') { + Mirror.unchain(allIds); + Mirror.unlink(allIds); + } + // Remove gaslight_link and clean up tracking + allIds.forEach(function(id) { + var tok = getObj('graphic', id); + if (tok) removeLinkId(tok); + delete activeGroup.linkedTokens[id]; + }); + // Clean up references in peers + Object.keys(activeGroup.linkedTokens).forEach(function(key) { + activeGroup.linkedTokens[key] = activeGroup.linkedTokens[key].filter(function(id) { return allIds.indexOf(id) === -1; }); + }); + } else { + // Non-parent unlink: just remove this one token + removeLinkId(t); + if (typeof Mirror !== 'undefined') Mirror.unlink([tokenId]); + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(tokenId); + // Remove from activeGroups tracking + if (activeGroup) { + if (activeGroup.linkedTokens[tokenId]) { + var peers = activeGroup.linkedTokens[tokenId]; + delete activeGroup.linkedTokens[tokenId]; + peers.forEach(function(peerId) { + if (activeGroup.linkedTokens[peerId]) { + activeGroup.linkedTokens[peerId] = activeGroup.linkedTokens[peerId].filter(function(id) { return id !== tokenId; }); + } + }); + } else { + Object.keys(activeGroup.linkedTokens).forEach(function(key) { + var idx = activeGroup.linkedTokens[key].indexOf(tokenId); + if (idx !== -1) activeGroup.linkedTokens[key].splice(idx, 1); + }); + } + } + } + }); + reply(msg, 'Unlink', tokens.length + ' token(s) unlinked.'); + }; + + const doSync = (msg, args) => { + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + + var useDefault = args.indexOf('--default') !== -1; + args = args.filter(function(a) { return a !== '--default'; }); + + var getVal = useDefault + ? function(t) { + var charId = t.get('represents'); if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + return attr ? (attr.get('current') || '') : null; + } + : function(t) { return getSyncConfigRaw(t); }; + + var setVal = useDefault + ? function(t, value) { + var charId = t.get('represents'); if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr) attr.set('current', value); + else createObj('attribute', { characterid: charId, name: 'gaslight_sync', current: value }); + } + : function(t, value) { setSyncConfig(t, value); }; + + var label = useDefault ? 'character default' : 'token'; + + // No args — show current config + if (args.length === 0) { + var results = tokens.map(function(t) { + var raw = getVal(t); + var name = t.get('name') || t.get('id'); + return '' + name + ': ' + (raw === null ? '(default — sync all)' : raw || '(empty — no sync)'); + }); + reply(msg, 'Sync', results.join('
        ')); + return; + } + + var subCmd = args[0].toLowerCase(); + + // Reset — re-copy from character attribute (only makes sense for token mode) + if (subCmd === 'reset') { + if (useDefault) { reply(msg, 'Error', 'Reset only applies to token sync, not --default.'); return; } + tokens.forEach(function(t) { + var charId = t.get('represents'); + var charVal = ''; + if (charId) { + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr && attr.get('current') !== undefined && attr.get('current') !== null) { + charVal = attr.get('current').trim(); + } + } + setSyncConfig(t, charVal); + }); + reply(msg, 'Sync', 'Reset gaslight_sync to character default on ' + tokens.length + ' token(s).'); + return; + } + + // "all" — explicitly sync everything + if (subCmd === 'all') { + tokens.forEach(function(t) { setVal(t, 'all'); }); + reply(msg, 'Sync', 'Set to sync all properties on ' + tokens.length + ' ' + label + '(s).'); + return; + } + + // Otherwise, treat all args as props to add to sync list + var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + tokens.forEach(function(t) { + var raw = getVal(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + props.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setVal(t, existing.join(', ')); + }); + reply(msg, 'Sync', 'Added [' + props.join(', ') + '] to sync on ' + tokens.length + ' ' + label + '(s).'); + }; + + const doDesync = (msg, args) => { + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + + var useDefault = args.indexOf('--default') !== -1; + args = args.filter(function(a) { return a !== '--default'; }); + + var getVal = useDefault + ? function(t) { + var charId = t.get('represents'); if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + return attr ? (attr.get('current') || '') : null; + } + : function(t) { return getSyncConfigRaw(t); }; + + var setVal = useDefault + ? function(t, value) { + var charId = t.get('represents'); if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (attr) attr.set('current', value); + else createObj('attribute', { characterid: charId, name: 'gaslight_sync', current: value }); + } + : function(t, value) { setSyncConfig(t, value); }; + + var label = useDefault ? 'character default' : 'token'; + + if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight desync [--default] '); return; } + + // "all" — disable all syncing (set empty string = no sync) + if (args[0].toLowerCase() === 'all') { + tokens.forEach(function(t) { setVal(t, ''); }); + reply(msg, 'Desync', 'Disabled all syncing on ' + tokens.length + ' ' + label + '(s).' + (useDefault ? '' : ' Use !gaslight sync reset to restore.')); + return; + } + + var props = args.join(',').split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var excludeProps = props.map(function(p) { return p.startsWith('!') ? p : '!' + p; }); + tokens.forEach(function(t) { + var raw = getVal(t) || ''; + var existing = raw.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + excludeProps.forEach(function(p) { if (existing.indexOf(p) === -1) existing.push(p); }); + setVal(t, existing.join(', ')); + }); + reply(msg, 'Desync', 'Excluded [' + props.join(', ') + '] from sync on ' + tokens.length + ' ' + label + '(s).'); + }; + + /** + * Read/set/unset gl_* variables on token gmnotes or character sheet. + * Supports chained actions: --get, --set, --del, --setch, --delch + * After mutations, propagates to linked tokens and triggers eval (unless --silent). + * + * Usage: !gaslight var [--silent] [actions...] + * --get Read gl_ (token gmnotes priority, fallback to char) + * --set Set gl_ on token gmnotes + * --del Remove gl_ from token gmnotes + * --setch Set gl_ on character sheet attribute + * --delch Remove gl_ from character sheet attribute + */ + const doVar = (msg, args) => { + var silent = args.indexOf('--silent') !== -1; + args = args.filter(function(a) { return a !== '--silent'; }); + + var tokens = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) first.'); return; } + if (args.length === 0) { reply(msg, 'Error', 'Usage: !gaslight var [--silent] --get|--set|--del|--setch|--delch <name> [value]'); return; } + + // Parse chained actions + var actions = []; + var i = 0; + while (i < args.length) { + var action = args[i].toLowerCase(); + if (action === '--get') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--get requires a name.'); return; } + actions.push({ type: 'get', name: args[i + 1] }); + i += 2; + } else if (action === '--set') { + if (i + 2 >= args.length) { reply(msg, 'Error', '--set requires a name and value.'); return; } + actions.push({ type: 'set', name: args[i + 1], value: args[i + 2] }); + i += 3; + } else if (action === '--del') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--del requires a name.'); return; } + actions.push({ type: 'del', name: args[i + 1] }); + i += 2; + } else if (action === '--setch') { + if (i + 2 >= args.length) { reply(msg, 'Error', '--setch requires a name and value.'); return; } + actions.push({ type: 'setch', name: args[i + 1], value: args[i + 2] }); + i += 3; + } else if (action === '--delch') { + if (i + 1 >= args.length) { reply(msg, 'Error', '--delch requires a name.'); return; } + actions.push({ type: 'delch', name: args[i + 1] }); + i += 2; + } else { + reply(msg, 'Error', 'Unknown action: ' + args[i] + '. Expected --get, --set, --del, --setch, or --delch.'); + return; + } + } + + // Normalize names — ensure gl_ prefix + actions.forEach(function(a) { + if (!a.name.startsWith('gl_')) a.name = 'gl_' + a.name; + }); + + var s = state[SCRIPT_NAME]; + var output = []; + var mutatedTokenGmnotes = false; + var mutatedCharAttrs = false; + + tokens.forEach(function(t) { + var tokenName = t.get('name') || t.get('id'); + var tokenId = t.get('id'); + var onMaster = Object.values(s.activeGroups).some(function(active) { return t.get('_pageid') === active.masterPageId; }); + + // Determine which tokens to read from / write to based on view + var resolveViewTargets = function() { + if (!onMaster) return [t]; // non-master: operate on itself only + if (s.view === null) return [t]; // off: master only + if (s.view === 'master') { + // All linked tokens including master + var targets = [t]; + Object.values(s.activeGroups).forEach(function(active) { + 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]); + }); + allLinked.filter(function(id, idx, arr) { return arr.indexOf(id) === idx && id !== tokenId; }).forEach(function(id) { + var linked = getObj('graphic', id); + if (linked) targets.push(linked); + }); + }); + return targets; + } + // Specific player: find their linked copy + var playerTargets = []; + Object.values(s.activeGroups).forEach(function(active) { + var targetPageId = active.playerPages[s.view] ? active.playerPages[s.view].pageId : null; + if (!targetPageId) return; + 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]); + }); + allLinked.filter(function(id, idx, arr) { return arr.indexOf(id) === idx && id !== tokenId; }).forEach(function(id) { + var linked = getObj('graphic', id); + if (linked && linked.get('_pageid') === targetPageId) playerTargets.push(linked); + }); + }); + return playerTargets.length > 0 ? playerTargets : [t]; + }; + + // For --get: read from view targets + var getTargets = resolveViewTargets(); + // For --set/--del: write to view targets (master included only if view is master or off) + var writeTargets; + if (!onMaster || s.view === null || s.view === 'master') { + writeTargets = resolveViewTargets(); + } else { + // view is a specific player: only write to that player's copy, not master + writeTargets = resolveViewTargets(); + } + + // Process --get actions against getTargets + actions.forEach(function(a) { + if (a.type !== 'get') return; + if (s.view === 'master' && onMaster && getTargets.length > 1) { + // Compact format: show all values + var vals = getTargets.map(function(target) { + var tNotes = target.get('gmnotes') || ''; + try { tNotes = decodeURIComponent(tNotes); } catch(e) {} + var val = readGlField(tNotes, a.name); + if (!val) { + var charId = target.get('represents'); + if (charId) val = getAttrByName(charId, a.name) || ''; + } + var pageName = 'master'; + Object.values(s.activeGroups).forEach(function(active) { + if (target.get('_pageid') === active.masterPageId) pageName = 'master'; + Object.entries(active.playerPages).forEach(function(e) { + if (target.get('_pageid') === e[1].pageId) pageName = e[1].name || e[0]; + }); + }); + return pageName + ': ' + (val || ''); + }); + output.push('' + tokenName + ' ' + a.name + ' — ' + vals.join(', ')); + } else { + var target = getTargets[0]; + var tNotes = target.get('gmnotes') || ''; + try { tNotes = decodeURIComponent(tNotes); } catch(e) {} + var val = readGlField(tNotes, a.name); + if (!val) { + var charId = target.get('represents'); + if (charId) val = getAttrByName(charId, a.name) || ''; + } + output.push('' + tokenName + ' ' + a.name + ' = ' + (val || '(not set)')); + } + }); + + // Process --set/--del/--setch/--delch against writeTargets + var prevGmnotes_original = t.get('gmnotes') || ''; + writeTargets.forEach(function(target) { + var prevGmnotes = target.get('gmnotes') || ''; + var notes = prevGmnotes; + try { notes = decodeURIComponent(notes); } catch(e) {} + var gmnotesChanged = false; + + actions.forEach(function(a) { + if (a.type === 'set') { + var rx = new RegExp(a.name + '\\s*[=:]\\s*[^\\n]*'); + if (notes.match(rx)) { + notes = notes.replace(rx, a.name + ': ' + a.value); + } else { + notes = (notes ? notes + '\n' : '') + a.name + ': ' + a.value; + } + gmnotesChanged = true; + } else if (a.type === 'del') { + notes = notes.replace(new RegExp('\\n?' + a.name + '\\s*[=:]\\s*[^\\n]*'), ''); + gmnotesChanged = true; + } else if (a.type === 'setch') { + var charId = target.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: a.name })[0]; + if (attr) { attr.set('current', a.value); } + else { createObj('attribute', { characterid: charId, name: a.name, current: a.value }); } + mutatedCharAttrs = true; + } else if (a.type === 'delch') { + var charId = target.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: a.name })[0]; + if (attr) attr.remove(); + mutatedCharAttrs = true; + } + }); + + if (gmnotesChanged) { + target.set('gmnotes', notes); + mutatedTokenGmnotes = true; + } + }); + + // Trigger script evaluation once, scoped to view (unless --silent) + if (mutatedTokenGmnotes && !silent) { + // Pick the right token to trigger from based on view + var triggerTarget; + if (!onMaster || s.view === null || s.view === 'master') { + triggerTarget = t; // master token: evaluates for all viewers + } else { + // Specific player view: trigger from their copy so eval scopes to that viewer + triggerTarget = writeTargets[0] || t; + } + onGmNotesChanged(triggerTarget, { gmnotes: prevGmnotes_original }); + } + + // Trigger eval for char attr mutations (scripts may resolve from char) + if (mutatedCharAttrs && !silent) { + var changedFields = actions.filter(function(a) { return a.type === 'setch' || a.type === 'delch'; }).map(function(a) { return a.name; }); + changedFields.forEach(function(field) { + var entries = triggerMap[field]; + if (!entries || entries.length === 0) return; + var masterTokenId = null; + Object.values(s.activeGroups).forEach(function(active) { + 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]); + }); + allLinked.filter(function(id, idx) { return allLinked.indexOf(id) === idx; }).forEach(function(id) { + var tok = getObj('graphic', id); + if (tok && tok.get('_pageid') === active.masterPageId) masterTokenId = id; + }); + if (t.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, t.get('_pageid')); + }); + }); + } + }); + + // Build summary + var hasMutations = actions.some(function(a) { return a.type !== 'get'; }); + if (output.length > 0) { + reply(msg, 'Var', output.join('
        ')); + } + if (hasMutations) { + var summary = actions.filter(function(a) { return a.type !== 'get'; }).map(function(a) { + if (a.type === 'set') return 'set ' + a.name + ' = ' + a.value; + if (a.type === 'del') return 'del ' + a.name; + if (a.type === 'setch') return 'setch ' + a.name + ' = ' + a.value; + if (a.type === 'delch') return 'delch ' + a.name; + }).join(', '); + reply(msg, 'Var', summary + ' on ' + tokens.length + ' token(s).' + (silent ? ' (silent)' : '')); + } + }; + + 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; + if (s.view === null) current = 'off (relay disabled)'; + else if (s.view === 'master') current = 'master (relay to all)'; + else { + current = 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; + } + reply(msg, 'View', 'Current view: ' + current + ''); + return; + } + var arg = args.join(' ').replace(/^["']|["']$/g, ''); + if (arg.toLowerCase() === 'master' || arg.toLowerCase() === 'gm' || arg.toLowerCase() === 'all') { + s.view = 'master'; + reply(msg, 'View', 'Switched to master view. Commands relay to all player pages.'); + } else if (arg.toLowerCase() === 'none' || arg.toLowerCase() === 'off') { + s.view = null; + reply(msg, 'View', 'Relay disabled. Commands stay on master only.'); + } 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.'); + } + updateViewHud(); + }; + + /** + * 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]; + + // --default on|off: set gaslight_stage on selected tokens' characters + var defaultIdx = args.indexOf('--default'); + if (defaultIdx !== -1) { + var val = args[defaultIdx + 1]; + if (val !== 'on' && val !== 'off') { reply(msg, 'Error', 'Usage: !gaslight stage --default on|off'); return; } + 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) representing characters.'); return; } + var count = 0; + tokens.forEach(function(t) { + var charId = t.get('represents'); + if (!charId) return; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_stage' })[0]; + if (val === 'on') { + if (attr) attr.set('current', '1'); + else createObj('attribute', { characterid: charId, name: 'gaslight_stage', current: '1' }); + } else { + if (attr) attr.remove(); + } + count++; + }); + reply(msg, 'Stage', 'Set gaslight_stage=' + (val === 'on' ? '1' : '(removed)') + ' on ' + count + ' character(s). Tokens representing these characters will ' + (val === 'on' ? 'auto-stage when placed on a gaslighted page.' : 'no longer auto-stage.')); + return; + } + + 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 { + // Default: follow current view target + if (s.view && s.view !== 'master' && groupInfo.players[s.view]) { + targetPlayerIds = [s.view]; + } else { + targetPlayerIds = Object.keys(groupInfo.players); + } + } + + if (targetPlayerIds.length === 0) { reply(msg, 'Error', 'No valid target players.'); return; } + + var staged = 0; + var marketplaceFailures = []; + 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); + var result = stageTokenToPages(token, targetPages); + staged += result.cloned; + if (result.failed) marketplaceFailures.push(result); + }); + + // 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(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); + }); + 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); + + // Align synced properties from source tokens to newly linked children + if (typeof Mirror !== 'undefined' && Mirror.align) { + tokens.forEach(function(token) { + Mirror.align(token.get('id'), { ifLinked: true }); + }); + } + } + + reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); + + // Add linked copies to initiative if the staged token is already in the turn order + if (staged > 0) { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + var turnIds = new Set(turnOrder.map(function(e) { return e.id; })); + var added = 0; + tokens.forEach(function(token) { + var tokenId = token.get('id'); + if (!turnIds.has(tokenId)) return; + var entry = turnOrder.find(function(e) { return e.id === tokenId; }); + if (!entry) return; + var info = getLinkedInfo(tokenId); + info.linkedIds.forEach(function(lid) { + if (turnIds.has(lid)) return; + var linkedObj = getObj('graphic', lid); + if (!linkedObj) return; + turnOrder.push({ id: lid, pr: entry.pr, custom: entry.custom || '', _pageid: linkedObj.get('_pageid') }); + turnIds.add(lid); + added++; + }); + }); + if (added > 0) { + turnOrder = reorderInitiative(turnOrder); + Campaign().set('turnorder', JSON.stringify(turnOrder)); + if (s.hud.initiative) { + if (s.hud.initData && s.hud.initData.entries) { + s.hud.initData.entries.forEach(function(e) { + if (e.sourceId.startsWith('custom:')) return; + var pin = getObj('pin', e.tokenId); + if (!pin) return; + var tagStr = computeHudTag(e.sourceId); + var sourceToken = getObj('graphic', e.sourceId); + var name = sourceToken ? (sourceToken.get('name') || '') : ''; + pin.set('title', ((tagStr ? '⚠️ ' : '') + name).trim()); + pin.set('notes', tagStr || ''); + }); + } + updateInitiativeHud(); + } + } + } + + if (marketplaceFailures.length > 0) { + var failMsg = '⚠️ ' + marketplaceFailures.length + ' token(s) could not be staged because their images are from the Roll20 Marketplace and not in your library.

        '; + marketplaceFailures.forEach(function(f) { + failMsg += '
        ' + + '
        ' + + '' + f.name + '
        '; + }); + failMsg += '

        To fix:
          ' + + '
        1. In the Art Library tab, find the asset under Premium Assets → Marketplace Purchases, right-click it (or the folder), and select Copy to Library.
        2. ' + + '
        3. Replace the token(s) on the master page with the library copy (drag from your library).
        4. ' + + '
        5. Re-run !gaslight stage with the new token(s) selected.
        6. ' + + '
        '; + reply(msg, 'Stage', failMsg); + } + }; + + /** + * 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(function(t) { autoPopulateLinkId(t); autoPopulateSyncConfig(t); }); + }); + 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); + + // Align synced properties from source to newly linked children + if (typeof Mirror !== 'undefined' && Mirror.align) { + Mirror.align(obj.get('id'), { ifLinked: true }); + } + }, 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 + ' setup <group> -- Quick-configure from duplicated pages
        ' + + '' + 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 + ' sync [props|all|reset] -- Manage sync per token
        ' + + '' + CMD + ' desync [props|all] -- Exclude props from sync
        ' + + '' + CMD + ' var [--silent] [actions...] -- Read/set/unset gl_ vars
        ' + + '' + CMD + ' view [master|off|<player>] -- Control relay targeting
        ' + + '' + CMD + ' relay <views> <!cmd> -- Relay command to views
        ' + + '' + CMD + ' group <group> <player|GM> -- Assign page
        ' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
        ' + + '' + CMD + ' stage [players...] -- Propagate tokens to player pages
        ' + + '' + CMD + ' config [relay-add|relay-remove|relay-list] -- Configure relay
        ' + + '' + CMD + ' status -- Show state
        ' + + '' + CMD + ' --help -- This help
        ' + + '
        View modes:
        ' + + 'master -- relay to all (default on split)
        ' + + 'off -- relay disabled (GM-only changes)
        ' + + '<player> -- relay to one player only
        ' + + '
        Initiative: Linked tokens auto-sync in turn order. Non-master children are auto-skipped on turn advance.
        ' + + '
        HUD:
        ' + + '' + CMD + ' hud -- toggle all elements
        ' + + '' + CMD + ' hud [on|off|reset] -- all elements
        ' + + '' + CMD + ' hud [init|view] [on|off|reset] -- specific element
        ' + + 'Aliases: init/turn/turns = initiative, relay = view
        '; + + // ========================================================================= + // Scripting Engine — Fetch Integration + // ========================================================================= + + /** + * 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. + * Resolves from token gmnotes first, falls back to character attribute. + */ + const registerGlCompProp = (fieldName) => { + if (typeof Fetch === 'undefined' || !Fetch.CustomPropsByType) return; + if (Fetch.CustomPropsByType.graphic.compProps[fieldName]) return; + + var valFn = function(o) { + // Token gmnotes first, fallback to character attribute + var tokenVal = readGlField(o.gmnotes, fieldName); + var charId = o.represents; + var charVal = getAttrByName(charId, fieldName) || ''; + return charVal; + }; + + 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 = {}; + var knownScriptPins = []; // cached pin IDs + handout links from last buildTriggerMap + + /** + * 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 = {}; + knownScriptPins = []; + 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) { + knownScriptPins.push({ pinId: pin.get('_id'), pageId: pageId, handoutId: pin.get('link') || null }); + 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 }); + }); + }); + } + }); + }); + }); + }); + }; + + /** + * Rebuild triggerMap using cached pin IDs (works in non-chat contexts where findObjs fails for pins). + * Uses getObj('pin', id) which works in all contexts. + */ + const rebuildTriggerMapFromCache = () => { + if (knownScriptPins.length === 0) return; + triggerMap = {}; + knownScriptPins.forEach(function(cached) { + var pin = getObj('pin', cached.pinId); + if (!pin) return; + 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: cached.pinId, pageId: cached.pageId }); + }); + } else { + getPinScript(pin, function(content) { + if (!content) return; + registerCompPropsFromScript(content); + 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: cached.pinId, pageId: cached.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); + }); + }); + + // Update turn indicator if the tracked token moved or resized + var s = state[SCRIPT_NAME]; + if (s.hud.initData && s.hud.reticleData && s.hud.reticleData.tokenId === obj.get('id')) { + if (changed.indexOf('left') !== -1 || changed.indexOf('top') !== -1 || changed.indexOf('width') !== -1 || changed.indexOf('height') !== -1) { + updateTurnReticle(); + } + } + }; + 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 + // ========================================================================= + + /** + * 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) { + 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. + * 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) { + 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 configuration. Checks pin gmNotes first, falls back to linked handout gmNotes. + */ + 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({ filter: 'all', triggers: [] }); + } + }); + return; + } + } + callback(null); + }; + + /** + * Parse config text into structured object. + */ + const parseConfigText = (text) => { + var config = { 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('filter:')) config.filter = line.slice(7).trim(); + else if (line.startsWith('trigger:')) config.triggers.push(line.slice(8).trim()); + }); + 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. + */ + 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 === 'characters') { + return tokens.filter(function(t) { return !!t.get('represents'); }); + } + 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) { + // Check gmnotes + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + 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. + * 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) === '&&')) { 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; + + + var content = scriptContent; + // Replace remaining @(target.*) with token ID — Fetch resolves native props + content = content.replace(/@\(target\./g, '@(' + viewerTarget.get('id') + '.'); + // Replace @(gm_target.*) with master page token ID + content = content.replace(/@\(gm_target\./g, '@(' + targetToken.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'); }); + + // 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('{&')); + }); + + var dryRunPrefix = dryRun ? + CMD + ' --echo ' + viewerPlayerId + ' ' + viewerTarget.get('id') + ' ' : + ''; + var selectSuffix = ' {& select ' + viewerTarget.get('id') + '}\n'; + var fullCmd = dryRunPrefix + lines.join(selectSuffix + dryRunPrefix) + selectSuffix; + + 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'); + } + var batch = '!{{\n' + + CMD + ' --script-lock\n' + + fullCmd + + '(^)!^gaslight --script-unlock\n' + + '}}'; + sendChat(getPlayerName(senderId), batch); + }; + + /** + * Evaluate all scripts on pins for a given page. + */ + const evaluatePins = (pins, msg, dryRun, targetTokenId, sourcePageId) => { + var s = state[SCRIPT_NAME]; + pins.forEach(function(pin) { + 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]; + + // 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); + }); + }); + }); + }); + }); + }; + + /** + * !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-run') !== -1; + args = args.filter(function(a) { return a !== '--dry-run'; }); + + 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; + + // ScriptKit handles help, man, examples, whatsnew, gen-help, gen-dev-docs + if (typeof ScriptKit !== 'undefined' && ScriptKit.handleInput(msg)) return; + + const rawArgs = msg.content.slice(CMD.length).trim(); + const args = []; + var argRx = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g; + var argMatch; + while ((argMatch = argRx.exec(rawArgs)) !== null) { + args.push(argMatch[1] !== undefined ? argMatch[1] : argMatch[2] !== undefined ? argMatch[2] : argMatch[3] !== undefined ? argMatch[3] : argMatch[4]); + } + 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 'sync': doSync(msg, args); break; + case 'desync': doDesync(msg, args); break; + case 'var': doVar(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 'hud': doHud(msg, args); break; + case 'init': doInit(msg, args); break; + case 'eval': doEval(msg, args); break; + case 'status': doStatus(msg); break; + 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]; + 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-token': { + var sel = (msg.selected || []).map(function(s) { return getObj(s._type, s._id); }).filter(Boolean); + sel.forEach(function(obj) { + log(SCRIPT_NAME + ' [dump-token ' + obj.get('id') + ']: ' + JSON.stringify(obj)); + }); + reply(msg, 'Debug', 'Dumped ' + sel.length + ' token(s) to API console.'); + 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 '--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 --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('')); + }; + + // ========================================================================= + // ScriptKit Registration + // ========================================================================= + + const registerWithScriptKit = () => { + if (typeof ScriptKit === 'undefined') return; + ScriptKit.register(SCRIPT_NAME, { + version: SCRIPT_VERSION, + command: CMD, + tag: 'GLS', + aliases: { help: ['help', '--help'], man: 'man', examples: 'examples', whatsnew: 'whatsnew', genHelp: 'gen-help', genDev: 'gen-dev-docs' }, + newSince: '2.2.0', + help: { + description: 'Per-player map perception. Split players onto individual page copies with synchronized tokens — each player can see different things while movement stays consistent.', + quickStart: [ + 'Create your master page with all tokens placed.', + 'Duplicate it once per player (Roll20 built-in Duplicate Page).', + 'Select party tokens on the master page, run `!gaslight setup mygroup` — auto-detects duplicates, assigns pages, configures the group.', + '`!gaslight test mygroup` — dry-run showing how tokens will link. Fix any warnings.', + '`!gaslight split mygroup` — activates the group: links tokens, moves players, begins syncing.', + 'When done: `!gaslight merge` — tears down all links, returns players.', + ], + changelog: [ + { version: '2.2.0', changes: [ + 'Initiative HUD: pin-based turn tracker with gesture controls (swipe, drag-to-reorder, delete)', + 'Current turn reticle: rectangle highlighting active combatant on the map', + '`!gaslight init` command: sync/trim linked tokens in initiative', + '`--default` flags for stage, sync, desync (character-level config)', + 'Asymmetric unlink/delete: parent cascades, non-parent detaches', + 'Auto-add to initiative on stage (mid-combat staging)', + 'Interactive guide examples: getting-started, core-mechanics, initiative-hud, relay, scripting', + 'ScriptKit integration: help, man, examples, annotations', + 'GLS script fixes: compProp char-attr fallback, [default] regex, = operator', + ]}, + { version: '2.1.0', changes: [ + 'var command for managing gl_* variables on tokens', + 'sync/desync commands for selective token sync control', + 'HUD system for persistent on-screen status display', + 'Stage improvements: view-awareness, auto-link after stage', + 'Scripting engine fixes: anchor component fix, triggerMap auto-rebuild', + ]}, + { version: '2.0.0', changes: [ + 'Scripting engine — reactive per-player automation via map pins', + 'RollCapture integration for automatic trigger evaluation', + 'Auto-relay system for cross-page command forwarding', + 'Aggregation functions: any(), all(), max(), min(), join()', + 'Setup command for quick group configuration', + ]}, + ], + commands: [ + { group: 'Core', commands: [ + { syntax: 'setup [players...]', description: 'Quick-configure group from duplicate pages', version: '2.0.0', + details: 'Auto-detects page copies by name, assigns master + player pages. Players resolved from selected tokens or named explicitly.', + items: [ + { name: '', description: 'Name for the group configuration', version: '2.0.0' }, + { name: '[players...]', description: 'Player names to include (optional — auto-detected from selected tokens or party-tagged characters)', version: '2.0.0' }, + ]}, + { syntax: 'split [--force]', description: 'Activate a prepared group', version: '1.0.0', + details: 'Links tokens across pages, moves players to individual pages, begins syncing. Runs test-first unless --force.', + items: [ + { name: '--force', description: 'Skip the automatic test and split immediately', version: '1.0.0' }, + ]}, + { syntax: 'merge [group]', description: 'Tear down links, return players to shared page', version: '1.0.0' }, + { syntax: 'test ', description: 'Dry-run linking resolution', version: '1.0.0', + details: 'Shows which tokens would link via each resolution step (1-4) without activating anything.' }, + { syntax: 'status', description: 'Show configured groups, active splits, linked token counts', version: '1.0.0' }, + ]}, + { group: 'Token Linking', commands: [ + { syntax: 'link [|new] [ids...]', description: 'Set gaslight_link on selected + explicit tokens', version: '1.0.0', + details: 'Assigns a shared link ID. "new" generates a fresh ID. First non-ID arg is the link name.' }, + { syntax: 'unlink [ids...|--group ]', description: 'Remove gaslight_link from tokens', version: '1.0.0' }, + { syntax: 'sync [all|reset|]', description: 'Show or modify sync config for selected tokens', version: '2.1.0', + details: 'Overrides the token\'s gaslight_sync config without changing the character attribute. Use "reset" to re-read from the character.', + items: [ + { name: '(no args)', description: 'Show the current gaslight_sync config for each selected token', version: '2.1.0' }, + { name: 'all', description: 'Explicitly sync all properties', version: '2.1.0' }, + { name: 'reset', description: 'Re-copy sync config from the character\'s gaslight_sync attribute', version: '2.1.0' }, + { name: '', description: 'Add specific properties to the sync list (comma or space separated)', version: '2.1.0' }, + ]}, + { syntax: 'desync ', description: 'Exclude properties from sync on selected tokens', version: '2.1.0', + items: [ + { name: 'all', description: 'Disable all property syncing (set empty config)', version: '2.1.0' }, + { name: '', description: 'Add !prop exclusions (e.g. desync tint_color bar3_value)', version: '2.1.0' }, + ]}, + ]}, + { group: 'Page Configuration', commands: [ + { syntax: 'group ', description: 'Assign current page to a group', version: '1.0.0' }, + { syntax: 'ungroup ', description: 'Remove page from group', version: '1.0.0' }, + { syntax: 'stage', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', + details: 'Select tokens on a gaslighted page. Clones to all other pages in the group with automatic linking.' }, + { syntax: 'view [player|master|off]', description: 'Show or switch relay view target', version: '2.0.0', + details: 'Controls which page(s) receive relayed commands and where var writes go. Affects auto-relay, manual relay, and var command targeting.', + items: [ + { name: '(no args)', description: 'Show the current view target', version: '2.0.0' }, + { name: 'master / gm / all', description: 'Relay commands to all player pages', version: '2.0.0' }, + { name: 'off / none', description: 'Disable auto-relay entirely', version: '2.0.0' }, + { name: '', description: 'Relay only to a specific player\'s page', version: '2.0.0' }, + ]}, + ]}, + { group: 'Relay & Automation', commands: [ + { syntax: 'relay ', description: 'Relay a command to specific views (select tokens first)', version: '2.0.0', + details: 'Views: player names, "all", "master"/"gm". Token IDs in the command are swapped with linked counterparts per page.' }, + { syntax: 'config [relay-add|relay-remove|relay-list] [commands...]', description: 'Configure auto-relay command list', version: '2.0.0' }, + { syntax: 'eval [--dry-run] [--all|]', description: 'Evaluate script pins', version: '2.0.0', + details: 'Select pins, or use --all for all active pages, or name a handout. --dry-run previews without executing.' }, + { syntax: 'var [--silent] --set|--get|--del [value]', description: 'Get/set/delete gl_* variables on selected tokens', version: '2.1.0', + details: 'Actions can be chained in one call (e.g. --set a 1 --set b 2 --del c). Names are auto-prefixed with gl_ if missing. View-aware: writes target the current view\'s linked tokens.', + items: [ + { name: '--set ', description: 'Set a gl_* field in token GM notes', version: '2.1.0' }, + { name: '--get ', description: 'Read the current value (from GM notes or character attribute)', version: '2.1.0' }, + { name: '--del ', description: 'Remove the field from token GM notes', version: '2.1.0' }, + { name: '--setch ', description: 'Set on the character attribute (shared across all tokens)', version: '2.1.0' }, + { name: '--delch ', description: 'Delete the character attribute', version: '2.1.0' }, + { name: '--silent', description: 'Don\'t trigger script re-evaluation after the change', version: '2.1.0' }, + ]}, + { syntax: 'hud [] [on|off|reset]', description: 'Toggle HUD elements', version: '2.1.0', + items: [ + { name: '(no args)', description: 'Toggle all HUD elements on/off', version: '2.1.0' }, + { name: 'view', description: 'The view/relay status indicator', version: '2.1.0' }, + { name: 'initiative', description: 'The initiative tracker HUD', version: '2.1.0' }, + { name: 'on / off', description: 'Explicitly enable or disable', version: '2.1.0' }, + { name: 'reset', description: 'Clear stored position and recreate from defaults', version: '2.1.0' }, + ]}, + ]}, + ], + topics: { + linking: { + title: 'Token Linking', + description: 'How tokens are matched across pages', + version: '1.0.0', + body: 'When a group is split, Gaslight resolves token links across pages using a 4-step priority system. Higher steps only apply if previous ones don\'t match.', + items: [ + { name: 'Step 1: gaslight_link', description: 'Explicit match via gaslight_link ID in token GM notes', version: '1.0.0' }, + { name: 'Step 2: represents + name', description: 'Same character + token name (unique pair per page)', version: '1.0.0' }, + { name: 'Step 3: represents + fingerprint', description: 'Same character + position/property fingerprint', version: '1.0.0' }, + { name: 'Step 4: Unlinked', description: 'Has represents but no match found — not synced', version: '1.0.0' }, + ], + }, + sync: { + title: 'Sync Control', + description: 'How token properties stay synchronized', + version: '1.0.0', + details: 'Spatial sync (position, size, rotation) uses Anchor. Property sync (bars, status, tint) uses Mirror. Both respect the gaslight_sync attribute.', + body: 'Set `gaslight_sync` on a character to control which properties sync. Supports include/exclude syntax:', + items: [ + { name: 'gaslight_sync = *', description: 'Sync everything (default)', version: '1.0.0' }, + { name: 'gaslight_sync = bar1 bar2 statusmarkers', description: 'Only sync listed properties', version: '1.0.0' }, + { name: 'gaslight_sync = * !tint_color', description: 'Sync everything except listed', version: '1.0.0' }, + { name: 'gaslight_sync = none', description: 'Disable all property sync', version: '1.0.0' }, + ], + }, + scripting: { + title: 'Scripting Engine', + description: 'Reactive per-player automation via map pins', + version: '2.0.0', + details: 'Scripts are stored in handouts and activated by placing map pins on the page. Each pin evaluates per-viewer per-target with conditional logic to fire different commands for different players.', + body: 'Create a handout with script content. Place a map pin on the page — title it with the handout name (prefix [GLS] is stripped). The pin evaluates whenever triggered attributes change.', + items: [ + { name: '@(target.field)', description: 'Reference a target token field (bar values, GM notes gl_* vars)', version: '2.0.0' }, + { name: '@(viewer.field)', description: 'Reference the viewer\'s token field', version: '2.0.0' }, + { name: '@(gm.field)', description: 'Reference the GM master token field', version: '2.0.0' }, + { name: '{& if condition}...{& end}', description: 'Conditional block — evaluates per viewer', version: '2.0.0' }, + { name: '{& select target_id}', description: 'Target selection (SelectManager integration)', version: '2.0.0' }, + { name: 'any(), all(), max(), min()', description: 'Aggregate functions across viewer tokens', version: '2.0.0' }, + { name: 'join()', description: 'Space-separated IDs of matching viewer tokens', version: '2.0.0' }, + ], + }, + triggers: { + title: 'Triggers', + description: 'How scripts auto-evaluate on changes', + version: '2.0.0', + body: 'Scripts auto-detect triggers from `@(target.gl_*)` references in `{& if}` blocks. Override in pin GM notes:', + items: [ + { name: 'trigger: on change gl_stealth_result', description: 'Explicit trigger on specific field change', version: '2.0.0' }, + { name: 'trigger: manual only', description: 'Only evaluate when manually run via eval command', version: '2.0.0' }, + ], + }, + relay: { + title: 'Auto-Relay', + description: 'Cross-page command forwarding', + version: '2.0.0', + details: 'Commands targeting master-page tokens can auto-relay to all player pages with token IDs swapped for linked counterparts. Configure which commands relay with the config command.', + body: 'Add commands to the relay list with `!gaslight config relay-add !token-mod !aura`. Commands fired on the master page automatically relay to player pages with correct token IDs.', + }, + staging: { + title: 'Staging', + description: 'Adding tokens to an active split', + version: '1.1.0', + details: 'Stage propagates selected tokens from master to all player pages with automatic linking. Tokens with gaslight_stage=1 on their character auto-stage when added to the page.', + body: 'Select tokens on the master page and run `!gaslight stage`. Tokens are cloned to each player page, linked, and synced.\n\nSet `gaslight_stage = 1` on a character attribute for auto-staging whenever that character\'s token is added to a gaslighted page.', + }, + hud: { + title: 'HUD', + description: 'Persistent on-screen status display', + version: '2.1.0', + body: 'The HUD shows the current Gaslight state (active group, view target, linked token count) as a persistent text element on the GM layer.\n\n`!gaslight hud on` / `!gaslight hud off` to toggle. `!gaslight hud reset` to recreate from defaults.', + }, + troubleshooting: { + title: 'Troubleshooting', + description: 'Common issues and solutions', + version: '1.0.0', + body: '**Tokens not linking** — Run `!gaslight test ` to see resolution steps. Ensure tokens share a character (represents) or have matching gaslight_link IDs.\n\n**Movement not syncing** — Check that Anchor is installed and the token isn\'t excluded by gaslight_sync. Verify the group is active with `!gaslight status`.\n\n**Relay not working** — Confirm the command is in the relay list: `!gaslight config relay-list`. Commands must start with ! to be intercepted.\n\n**Scripts not evaluating** — Pin must be on a page with an active split. Check pin title matches handout name. Run `!gaslight eval --dry-run` to debug.\n\n**Players see the same thing** — Make sure the group is actually split (`!gaslight status`). Player-specific changes must be made on the player\'s page, not the master.', + }, + }, + }, + }); + + // Pin validation helper for guide steps + const validatePin = (ctx) => { + var pin = ctx.selected[0]; + if (!pin || pin.get('_type') !== 'pin') return 'Select the map pin you just placed.'; + var pinPage = pin.get('_pageid'); + var onMaster = Object.values(state[SCRIPT_NAME].activeGroups || {}).some(g => g.masterPageId === pinPage); + if (!onMaster) return 'That pin is not on a master page. Place it on the master page of your active gaslight group.'; + if (ctx.handoutName) { + var linkedHandout = pin.get('link'); + if (linkedHandout) { + var ho = getObj('handout', linkedHandout); + if (!ho || ho.get('name') !== ctx.handoutName) return 'That pin is not linked to the generated handout. Drag the "' + ctx.handoutName + '" handout onto the page to create the correct pin.'; + } + } + }; + + // Register examples + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'getting-started', + description: 'Set up your first per-player split: pages, setup, split, and merge (guide only)', + guide: [ + { prompt: 'This guide walks you through creating your first **per-player split**.\n\nYou\'ll need at least 2 players in your game, but those players do not need to be online.' }, + { prompt: 'Create your **master page** with all tokens placed (NPCs, player characters, objects). This page is the GM\'s "ground truth" — all changes start here.' }, + { prompt: 'Duplicate the page **once per player** using Roll20\'s built-in **Duplicate Page** button. Leave the page names as they are — the *"Copy of"* prefix is how Gaslight auto-detects them.' }, + { prompt: 'Navigate to the master page and run `!gaslight setup mygroup` using one of these methods:\n\n**Option 1:** Select player-character tokens on the master page, then run `!gaslight setup mygroup` — uses controlling players of selected tokens.\n\n**Option 2:** Set the master page as the banner page and run `!gaslight setup mygroup "Player1" "Player2" ...` with no selection — uses the specified player names.\n\n**Option 3:** Define a Roll20 party, set the master page as the banner page, and run `!gaslight setup mygroup` with no selection — uses controlling players of party tokens.\n\nAll options auto-detect duplicated pages and assign one per player.', + ...ScriptKit.waitForCommand('!gaslight setup'), + onContinue: () => { + var groups = discoverAllGroups(); + if (Object.keys(groups).length === 0) return 'No group configured yet. Run `!gaslight setup ` using one of the methods above.'; + } + }, + { prompt: 'Now run:\n\n`!gaslight split mygroup`\n\nThis activates the group — players are moved to their individual pages and token syncing begins.', + ...ScriptKit.waitForCommand('!gaslight split'), + onContinue: () => { + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first.'; + } + }, + { prompt: 'You may notice a **HUD** appeared on the master page. We\'ll cover that in a later example. For now, disable it with:\n\n`!gaslight hud off`', + ...ScriptKit.waitForCommand('!gaslight hud') + }, + { prompt: 'Your split is now active. Try moving an **NPC token** on the master page — it syncs to all player pages automatically. NPC tokens only sync when updated on the master page (one-directional).\n\nThis means you can change an NPC on a specific player\'s page without affecting anyone else — useful for hiding tokens, swapping images, or showing per-player information.' }, + { prompt: 'Now try moving a **player-controlled token** on that player\'s page. It syncs back to the master and out to other player pages — players can move their own tokens and everyone sees it (bidirectional).' }, + { prompt: 'When you\'re done with the split (e.g. combat ends, scene changes), run:\n\n`!gaslight merge`.\n\nThis tears down all links and returns players to the banner page.' }, + { prompt: '**That\'s the basics!** From here, explore the other examples to learn more about how to use gaslight.', offerExamples: ['core-mechanics', 'initiative-hud', 'relay', 'scripting'] }, + ], + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'core-mechanics', + description: 'Staging, linking, syncing, and token lifecycle', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Core Mechanics** — This guide walks through the fundamental operations that make Gaslight tick: staging tokens, managing links, controlling sync, and what happens when tokens are deleted.\n\n**Prerequisite:** You need an active split. If you haven\'t set one up yet, run the **getting-started** guide first.', + onContinue: () => { + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Run `!gaslight split ` first, or complete the getting-started guide.'; + } + }, + { prompt: 'During an active split, anything new placed on a page stays only on that page until it\'s been **staged**. This ensures tokens aren\'t visible to players before you\'re ready.\n\nAdd a new token to the master page, select it, and click Continue.', + select: 'token', as: 'unlinked', min: 1, + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; + var unstaged = tokens.some(token => isOnMasterPage(token.get('id')) && !isMasterToken(token.get('id'))); + if (!unstaged) return tokens.length == 1 ? 'The selected token is already linked. Select a new (unstaged) token on the master page.' : 'All selected tokens are already linked. Select a new (unstaged) token on the master page.'; + } + }, + { prompt: 'Now with the token selected, run:\n\n`!gaslight stage`\n\nThis creates copies on each player page and establishes links for automatic syncing.\n\n**Note:** If staging fails, it\'s likely a marketplace image. Gaslight will warn you which tokens couldn\'t be staged. Re-upload the image to your library and swap it to fix this.', + ...ScriptKit.waitForCommand('!gaslight stage'), + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selections.unlinked) ? ctx.selections.unlinked : [ctx.selections.unlinked]; + var allStaged = tokens.every(token => isMasterToken(token.get('id'))); + if (!allStaged) return 'The previously selected token has not been staged yet. Select it and run `!gaslight stage`.'; + } + }, + { prompt: 'Your token is now staged — copies exist on each player page and sync automatically.\n\nYou can also set a **default** so tokens representing a character auto-stage whenever placed on a gaslighted page:\n\n`!gaslight stage --default on`\n\nTo remove the default: `!gaslight stage --default off`' }, + { prompt: '**Unlinking** disconnects a player\'s copy from the master without deleting it. Useful for making permanent per-player differences.\n\nSelect a staged token on the master page and run:\n\n`!gaslight unlink`', + ...ScriptKit.waitForCommand('!gaslight unlink') + }, + { prompt: 'The player copy now lives independently — changes on the master won\'t reach it.' }, + { prompt: '**Relinking** re-establishes the connection. Select the same master token and run:\n\n`!gaslight link`\n\nThe player copy snaps back to match the master.', + ...ScriptKit.waitForCommand('!gaslight link') + }, + { prompt: '**Desyncing** keeps tokens linked but pauses sync for specific properties. For example, you can move a token independently on one player\'s page while bars and status markers still sync.\n\nSelect a staged token and run:\n\n`!gaslight desync left,top`\n\nThis excludes position from syncing.', + ...ScriptKit.waitForCommand('!gaslight desync') + }, + { prompt: '**Resyncing** resumes sync for a desynced property. The player copy snaps back to the master\'s current value.\n\nSelect the same token and run:\n\n`!gaslight sync left,top`', + ...ScriptKit.waitForCommand('!gaslight sync') + }, + { prompt: 'You can also set sync/desync **defaults** on a character so new tokens inherit the config:\n\n`!gaslight sync --default all` — sync everything (the default)\n`!gaslight sync --default left,top,bar1_value` — sync only these props\n`!gaslight desync --default left,top` — exclude position from sync\n`!gaslight desync --default all` — disable all syncing by default\n\nUse `!gaslight sync reset` on a token to re-read the character default.' }, + { prompt: '**Token deletion behavior:**\n\n• Delete a **parent token** (master page, or controlling player\'s page for PCs) → all linked copies are removed\n• Delete a **non-parent copy** (e.g. an NPC on a player page) → only that copy is removed, others remain' }, + { prompt: '**That\'s the core!** You now know how to stage, link/unlink, sync/desync, and manage token lifecycle.\n\nNext, learn how the initiative HUD works or how to relay commands to player pages.', + offerExamples: ['initiative-hud', 'relay', 'scripting'] + }, + ], + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'initiative-hud', + description: 'Interactive initiative tracker HUD — gestures, customization, reticle', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Initiative HUD** — This guide walks through the on-screen initiative tracker: how to interact with it, customize its appearance, and use the current turn reticle.\n\n**Prerequisites:** You need an active split with the HUD enabled.', + onContinue: () => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; + if (!s.hud.initiative) return 'Initiative HUD is disabled. Run `!gaslight hud init on` to enable it.'; + } + }, + { prompt: 'Before we begin, clear any existing entries from the turn order. You can do this from Roll20\'s Turn Tracker panel (⚙).\n\nClear the turn order and click Continue.', + when: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + return turnOrder.length > 0; + }, + onEnter: (ctx, advance) => { + ctx._clearFired = false; + on('change:campaign:turnorder', function() { + if (ctx._clearFired) return; + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length === 0) { ctx._clearFired = true; advance(); } + }); + }, + onContinue: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; + } + }, + { prompt: 'The HUD needs tokens in the turn order. Add **6 or more tokens** to Roll20\'s initiative tracker using the **built-in Turn Tracker** (⚙ in the toolbar). Drag tokens in or use the tracker\'s initiative button.\n\n**Do NOT use a plugin command** (like GroupInitiative, etc.) to roll initiative — we\'ll cover that later.', + onEnter: (ctx, advance) => { + ctx._turnFired = false; + on('change:campaign:turnorder', function() { + if (ctx._turnFired) return; + var s = state[SCRIPT_NAME]; + var entries = (s.hud.initData && s.hud.initData.entries) || []; + if (entries.length >= 6) { ctx._turnFired = true; advance(); } + }); + }, + onContinue: () => { + var s = state[SCRIPT_NAME]; + var entries = (s.hud.initData && s.hud.initData.entries) || []; + if (entries.length < 6) return 'Only ' + entries.length + ' token(s) in the HUD. Add at least 6.'; + } + }, + { prompt: '**Why duplicates?**\n\nYou may notice the Roll20 Turn Tracker panel shows what appears to be duplicate entries — multiple entries for the same combatant. This is because each linked token (one per player page) has its own turn order entry.\n\nThe HUD **deduplicates** these automatically and shows one pin per combatant. The raw turn order is correct — it\'s just how Gaslight tracks initiative across pages.' }, + { prompt: '**HUD Layout — The Frame:**\n\nThe large rectangle behind the HUD is the **frame**. It defines the HUD\'s position and how many slots are visible. You\'ll learn how to customize it later.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.frameId) return; + var frame = getObj('pathv2', d.frameId); + if (!frame) return; + var pageId = frame.get('_pageid'); + var x = frame.get('x'), y = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var w = points.length >= 2 ? Math.abs(points[1][0] - points[0][0]) : 100; + var h = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 500; + ScriptKit.ping(pageId, x, y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', x + w / 2, y + h / 2, { fromX: x + w / 2 + 100, fromY: y + h / 2 + 100, color: '#ff0000' }); + } + }, + { prompt: '**HUD Layout — The Diamond:**\n\nThe diamond-shaped highlight marks the **current turn**. The pin inside it is the active combatant.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + var x = hl.get('x'), y = hl.get('y'); + var color = (d.highlightStroke || defaultInitHud.highlightStroke); + ScriptKit.ping(pageId, x, y, { color: color, moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', x - 80, y, { fromX: x - 180, fromY: y, color: '#ff0000' }); + } + }, + { prompt: '**HUD Layout — The Pins:**\n\nEach pin represents one combatant. Pins **above** the diamond have already gone this round; pins **below** are upcoming turns. The HUD scrolls to keep the current turn visible.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.entries || d.entries.length === 0) return; + // Get frame bounds to filter visible pins + var frame = d.frameId ? getObj('pathv2', d.frameId) : null; + var frameY = frame ? frame.get('y') : 0; + var points = frame ? JSON.parse(frame.get('points') || '[]') : []; + var frameH = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 5000; + var minY = frameY - frameH / 2; + var maxY = frameY + frameH / 2; + // Gather pins within frame bounds, sorted top to bottom + var pins = d.entries.map(function(e) { + var p = getObj('pin', e.tokenId); + if (!p) return null; + var center = toPinCenter(p); + return { x: center.x, y: center.y, rawY: p.get('y'), pageId: p.get('_pageid') }; + }).filter(function(p) { return p && p.rawY >= minY && p.rawY <= maxY; }); + pins.sort(function(a, b) { return a.y - b.y; }); + // Pan to the frame center first + var frameX = frame ? frame.get('x') : (pins[0] ? pins[0].x : 0); + ScriptKit.ping(pins[0].pageId, frameX, frameY, { color: 'transparent', moveAll: true, player: ctx.player }); + pins.forEach(function(p, i) { + setTimeout(function() { + ScriptKit.ping(p.pageId, p.x, p.y, { color: '#ff0000', moveAll: false, player: ctx.player }); + }, (i + 1) * 500); + }); + } + }, + { prompt: '**Gestures — Next turn:**\n\nSwipe the **current turn** pin (the one inside the diamond) to the **right** to advance to the next combatant.\n\nTry it now.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId || !d.entries || d.entries.length === 0) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + // Find the current turn pin (closest to highlight Y) + var hlY = hl.get('y'); + var current = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(Boolean).sort(function(a, b) { return Math.abs(a.y - hlY) - Math.abs(b.y - hlY); })[0]; + if (!current) return; + var center = toPinCenter(current.pin); + ScriptKit.ping(pageId, center.x, center.y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); + } + }, + { prompt: '**Gestures — Previous turn:**\n\nSwipe the **current turn** pin to the **left** to go back to the previous combatant.\n\nTry it now.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId || !d.entries || d.entries.length === 0) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + var hlY = hl.get('y'); + var current = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(Boolean).sort(function(a, b) { return Math.abs(a.y - hlY) - Math.abs(b.y - hlY); })[0]; + if (!current) return; + var center = toPinCenter(current.pin); + ScriptKit.ping(pageId, center.x, center.y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', center.x - 100, center.y, { fromX: center.x - 20, fromY: center.y, color: '#ff4444' }); + } + }, + { prompt: '**Gestures — Jump to turn:**\n\nSwipe any **non-current** pin to the **right** to jump forward to that combatant\'s turn. Swipe **left** to jump backward to it.\n\nTry swiping a non-current pin.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.highlightId || !d.entries || d.entries.length < 2) return; + var hl = getObj('pathv2', d.highlightId); + if (!hl) return; + var pageId = hl.get('_pageid'); + var hlY = hl.get('y'); + var frame = d.frameId ? getObj('pathv2', d.frameId) : null; + var frameY = frame ? frame.get('y') : 0; + var points = frame ? JSON.parse(frame.get('points') || '[]') : []; + var frameH = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 5000; + var minY = frameY - frameH / 2; + var maxY = frameY + frameH / 2; + // Find a non-current pin that's in the frame (smallest Y = topmost) + var nonCurrent = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(function(e) { return e && Math.abs(e.y - hlY) > 30 && e.y >= minY && e.y <= maxY; }).sort(function(a, b) { return a.y - b.y; })[0]; + if (!nonCurrent) return; + var center = toPinCenter(nonCurrent.pin); + ScriptKit.ping(pageId, center.x, center.y, { color: '#ff0000', moveAll: false, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', center.x + 100, center.y, { fromX: center.x + 20, fromY: center.y, color: '#00ff00' }); + } + }, + { prompt: '**Gestures — Reordering:**\n\nDrag a HUD pin **up or down** to reorder it in initiative. The pin will snap into its new position.\n\nTry dragging a pin to a different slot.', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.initData; + if (!d || !d.entries || d.entries.length < 2) return; + var frame = d.frameId ? getObj('pathv2', d.frameId) : null; + var frameY = frame ? frame.get('y') : 0; + var points = frame ? JSON.parse(frame.get('points') || '[]') : []; + var frameH = points.length >= 2 ? Math.abs(points[1][1] - points[0][1]) : 5000; + var minY = frameY - frameH / 2; + var maxY = frameY + frameH / 2; + var visible = d.entries.map(function(e) { var p = getObj('pin', e.tokenId); return p ? { pin: p, y: p.get('y') } : null; }).filter(function(e) { return e && e.y >= minY && e.y <= maxY; }); + if (visible.length < 2) return; + var pin = visible[visible.length - 1]; + var pageId = pin.pin.get('_pageid'); + var center = toPinCenter(pin.pin); + var frameX = frame ? frame.get('x') : center.x; + var fwPts = frame ? JSON.parse(frame.get('points') || '[]') : []; + var fw = fwPts.length >= 2 ? Math.abs(fwPts[1][0] - fwPts[0][0]) : 70; + var arrowX = frameX - fw / 2; + ScriptKit.ping(pageId, center.x, center.y, { color: 'transparent', moveAll: true, player: ctx.player }); + ScriptKit.annotate(pageId, 'arrow', arrowX, center.y - 80, { fromX: arrowX, fromY: center.y - 10, color: '#00ccff' }); + ScriptKit.annotate(pageId, 'arrow', arrowX, center.y + 80, { fromX: arrowX, fromY: center.y + 10, color: '#00ccff' }); + } + }, + { prompt: '**Current Turn Reticle:**\n\nThe rectangle on the map highlighting the current turn\'s token is the **reticle**. It follows the active combatant wherever they are.\n\n• **Drag up/down** on the reticle to offset it from the token\n• **Resize** the reticle to change its proportional size\n• **Rotate** it to add a rotation offset\n• **Delete** the reticle to turn it off (or `!gaslight hud reticle off`)', + onEnter: (ctx) => { + var s = state[SCRIPT_NAME]; + var d = s.hud.reticleData; + if (!d || !d.id) return; + var reticle = getObj('pathv2', d.id); + if (!reticle) return; + var pageId = reticle.get('_pageid'); + var x = reticle.get('x'), y = reticle.get('y'); + ScriptKit.ping(pageId, x, y, { color: 'transparent', moveAll: true, player: ctx.player }); + } + }, + { prompt: '**Customization — Scaling:**\n\nResize any HUD pin and all pins scale together. The padding between pins stays fixed.\n\nTry resizing one of the pins.' }, + { prompt: '**Customization — Text:**\n\nThe initiative value text next to each pin can be customized:\n\n• Change **font**, **size**, **color**, or **stroke** directly on any text element in Roll20 — all will update to match\n• **Drag** a text element to adjust its position relative to the frame (closer, further, up, down)\n• **Rotate** it to change the text angle\n\nTry changing the font or position of one of the text elements.' }, + { prompt: '**The Frame — Vertical resize:**\n\nNotice that not all 6+ pins fit in the frame — some are hidden. **Resize the frame vertically** (drag its bottom edge down) to reveal more slots.\n\nTry making the frame taller to show all your pins.' }, + { prompt: '**The Frame — Diamond position:**\n\nYou can drag the **diamond highlight up or down** within the frame to shift where the current turn is displayed. Pins above and below will adjust accordingly.\n\nTry moving the diamond to a different position in the frame.' }, + { prompt: '**The Frame — Other customization:**\n\nThe frame supports additional tweaks:\n\n• **Drag** the frame to move the entire HUD\n• **Resize horizontally** to adjust padding between pins\n• Change the frame\'s **stroke color** or **fill** directly in Roll20\'s shape properties\n\nThe **diamond** can also be customized:\n\n• Change its **color**, **stroke width**, **fill**, or **rotation** directly in Roll20\'s shape properties\n\nThe HUD remembers all of these choices.' }, + { prompt: '**Reticle — Inheritance:**\n\nThe reticle inherits its **color** and **stroke width** from the diamond. It also inherits **rotation**, offset by 45° (so the diamond\'s 45° rotation becomes the reticle\'s 90°, etc.).\n\nIf you changed the diamond\'s appearance in the previous steps, the reticle should reflect those changes.\n\nTo override the reticle independently, just change its properties directly. Once overridden, it stops inheriting that property from the diamond.' }, + { prompt: '**Gestures — Removing:**\n\nDelete a HUD pin to remove that combatant from initiative. Try deleting one now.' }, + { prompt: '**Clearing initiative:**\n\nNow clear the rest of the initiative — delete the remaining HUD pins or clear initiative from Roll20\'s Turn Tracker panel.\n\nClear initiative before continuing.', + when: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + return turnOrder.length > 0; + }, + onEnter: (ctx, advance) => { + ctx._clearFired = false; + on('change:campaign:turnorder', function() { + if (ctx._clearFired) return; + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length === 0) { ctx._clearFired = true; advance(); } + }); + }, + onContinue: () => { + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length > 0) return 'Turn order still has ' + turnOrder.length + ' entry/entries. Clear them all first.'; + } + }, + { prompt: '**Plugin-added initiative:**\n\nIf you use a plugin (GroupInitiative, etc.) to roll initiative, go ahead and use it now.\n\nIf you don\'t have one, select token(s) and click **Continue** — Gaslight will add them to initiative for you.', + onContinue: (ctx) => { + var s = state[SCRIPT_NAME]; + var entries = (s.hud.initData && s.hud.initData.entries) || []; + if (entries.length > 0) return 'HUD still has pins from before. Clear initiative first (previous step).'; + var turnOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + if (turnOrder.length >= 1) return; // Plugin already added them + var tokens = (ctx.msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) return 'No tokens in initiative and none selected. Either use a plugin or select token(s).'; + // Add selected tokens to initiative + tokens.forEach(function(t) { + var roll = Math.ceil(Math.random() * 20); + turnOrder.push({ id: t.get('id'), pr: roll, custom: '', _pageid: t.get('_pageid') }); + }); + turnOrder.sort(function(a, b) { return (b.pr || 0) - (a.pr || 0); }); + Campaign().set('turnorder', JSON.stringify(turnOrder)); + } + }, + { prompt: '**The problem:** When a plugin adds tokens to initiative, it sets `Campaign().set(\'turnorder\')` — but Gaslight cannot detect that change automatically.\n\nTo sync the HUD with the current turn order, run:\n\n`!gaslight init`\n\nThis is needed whenever initiative changes via a plugin (not via the HUD gestures).', + ...ScriptKit.waitForCommand('!gaslight init') + }, + { prompt: '**Reset:**\n\nIf the HUD ever gets into a weird state (misplaced elements, corrupted data), you can reset it:\n\n`!gaslight hud init reset` — destroys and recreates the initiative HUD from defaults\n`!gaslight hud reticle reset` — destroys and recreates the reticle from defaults\n\nThis preserves your turn order but resets all visual customization.' }, + { prompt: '**Toggling on/off:**\n\nYou can disable the HUD without losing your settings:\n\n`!gaslight hud init off` — hides the initiative HUD (turn order still works normally)\n`!gaslight hud init on` — shows it again with your saved customization\n`!gaslight hud reticle off` / `on` — same for the reticle independently\n`!gaslight hud off` / `on` — toggles all HUD elements at once' }, + { prompt: '**⚠️ Tags:**\n\nIf a token in initiative doesn\'t exist on all player pages, its HUD pin shows a ⚠️ icon in the title. Click the pin to see which players can\'t see it.\n\nThis usually means the token hasn\'t been staged to all pages. Run `!gaslight stage` with it selected to fix it.' }, + { prompt: '**Commands reference:**\n\n`!gaslight hud init on|off|reset` — toggle/reset the initiative HUD\n`!gaslight hud reticle on|off|reset` — toggle/reset the reticle\n`!gaslight init` — sync turn order into HUD\n`!gaslight init sync` — add missing linked tokens\n`!gaslight init trim` — remove stale entries\n\n**That\'s the initiative HUD!**', + offerExamples: ['core-mechanics', 'relay', 'scripting'] + }, + ], + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'relay', + description: 'Command relay, view targeting, and the view HUD', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Relay** — This guide walks you through how commands propagate across pages.\n\n**Prerequisite:** You need an active split with at least one staged token.', + onContinue: () => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; + } + }, + { prompt: '**Try auto-relay:**\n\nSelect a **staged token on the master page** and run any command against it (e.g. `!token-mod --set bar1_value|10` if you have TokenMod, or any other API command that targets tokens).\n\nThen navigate to a player\'s page — you\'ll see the command affected their copy too. That\'s auto-relay: any command referencing a master-page token ID automatically propagates to all player pages.' }, + { prompt: '**Enable the View HUD:**\n\nThe view HUD shows the current relay status as text on the master page. If it\'s not already visible, enable it:\n\n`!gaslight hud view on`\n\nYou\'ll see a status label appear on the master page (e.g. "🟢 RELAY: ALL").', + ...ScriptKit.waitForCommand('!gaslight hud view') + }, + { prompt: '**Disable relay:**\n\nBy default, the view is set to `all` — commands relay to every player page. Let\'s turn it off first to see the difference:\n\n`!gaslight view off`\n\nNotice the View HUD updates to show relay is disabled.', + ...ScriptKit.waitForCommand('!gaslight view') + }, + { prompt: '**Try a command with relay off:**\n\nSelect a staged token on the master page and run a command against it (e.g. `!token-mod --set bar1_value|+15`).\n\nNavigate to a player\'s page — their copy was **not** affected. With view `off`, commands stay on master only.' }, + { prompt: (ctx) => { + var s = state[SCRIPT_NAME]; + var names = []; + Object.values(s.activeGroups).forEach(function(g) { + Object.values(g.playerPages).forEach(function(p) { if (p.name && names.indexOf(p.name) === -1) names.push(p.name); }); + }); + var examples = names.length > 0 ? names.map(function(n) { return '`!gaslight view "' + n + '"`'; }).join(' or ') : '`!gaslight view ""`'; + return '**Switch to a specific player:**\n\nNow target a single player:\n\n' + examples; + }, + ...ScriptKit.waitForCommand('!gaslight view') + }, + { prompt: '**Try a command with a player view:**\n\nSelect the same staged token on the master page and run a command against it.\n\nNavigate to that player\'s page — only their copy was affected. Other players\' copies remain unchanged.' }, + { prompt: '**Switch to all:**\n\nNow switch to relaying to everyone:\n\n`!gaslight view all`\n\nThe View HUD updates again.', + ...ScriptKit.waitForCommand('!gaslight view') + }, + { prompt: '**Try a command with view all:**\n\nRun the same command again with a staged token selected. Now check multiple player pages — all copies were affected. This is the default behavior.' }, + { prompt: '**Manual relay:**\n\nTo relay a command to specific players (overriding the current view), select a master token and run:\n\n`!gaslight relay all !token-mod --set bar1_value|5`\n\nYou can also target specific players:\n\n`!gaslight relay "" !token-mod --set statusmarkers|dead`' }, + { prompt: '**Player-page relay:**\n\nBy default, commands run on a **player page** only affect that page. To make a command also relay from player pages, add it to the relay list:\n\n`!gaslight config relay-add !token-mod`\n\nRemove with: `!gaslight config relay-remove !token-mod`\nView the list: `!gaslight config relay-list`' }, + { prompt: '**What doesn\'t relay:**\n\n• `!gaslight`, `!mirror`, and `!anchor` commands never auto-relay\n• Commands with no tokens selected don\'t relay unless a master token id is specified in their parameters\n• Commands run while view is `off` don\'t relay\n\n**That\'s relay!** You can now run commands from the master page and have them affect all (or specific) player views automatically.', + offerExamples: ['core-mechanics', 'initiative-hud', 'scripting'] + }, + ], + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'scripting', + description: 'Gaslight Scripts — build a "winds of magic" script step by step', + source: SCRIPT_NAME, + guide: [ + { prompt: '**Scripting** — This guide walks you through building a Gaslight Script from scratch. We\'ll create a "Winds of Magic" effect: certain tokens radiate a magical aura (status marker) that only players with "second sight" can see.\n\n**Prerequisite:** You need an active split with staged tokens.', + onContinue: () => { + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length === 0) return 'No active split detected. Complete the getting-started guide first.'; + } + }, + { prompt: '**Variables:**\n\nGaslight uses `gl_` prefixed variables to drive scripts. We need two:\n\n• `gl_magical` — marks a token as radiating magical energy\n• `gl_second_sight` — marks a character as able to perceive magic\n\nSelect a staged NPC token on the master page and run:\n\n`!gaslight var --set magical 1`', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**Character variable:**\n\nNow select a **player character token** and set second sight on the character sheet (so it applies to all their tokens):\n\n`!gaslight var --setch second_sight 1`\n\nThe `--setch` flag sets it on the character, not the individual token.', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**The script handout (1/2):**\n\nCreate a new **handout** in your journal. Give it a prefix of "[GLS]" and a name (e.g. "[GLS] Winds of Magic").\n\nIn the **Description & Notes** of the handout, paste this script:\n\n```!token-mod --set {& if @(target.gl_magical[0]) = 1 && any(@(viewer.gl_second_sight[0])) = 1}statusmarkers|+half-haze{& else}statusmarkers|-half-haze{& end}```\n\nThis adds the `half-haze` status marker to tokens that are magical — but only on pages where the viewer has second sight. Otherwise it removes it.\n\nClick Continue when done.' }, + { prompt: '**The script handout (2/2):**\n\nIn the **GM Notes**, type:\n\n```filter: all\ntrigger: gl_magical, gl_second_sight```\n\n• `filter: all` — evaluates against every staged token\n• `trigger:` — auto-re-evaluates when either variable changes\n\nClick Continue when the handout is configured.' }, + { prompt: '**The Pin:**\n\nDrag the script handout onto the master page to create a map pin. This pin represents the script and will be used to trigger evaluations.' }, + { prompt: '**Evaluate:**\n\nRun:\n\n`!gaslight eval --all`\n\nThis evaluates the script for every token. The NPC you marked as magical should now have the `half-haze` status marker on the player page(s) where the viewer has second sight — and NOT on pages where they don\'t.\n\nCheck both player pages to confirm.', + ...ScriptKit.waitForCommand('!gaslight eval') + }, + { prompt: '**Test the trigger (1/2):**\n\nSelect the player character token and remove their second sight:\n\n`!gaslight var --delch second_sight`\n\nBecause `trigger: gl_second_sight` is set, the script **automatically re-evaluates**. The half-haze marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch second_sight 1`', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**Test the trigger (2/2):**\n\nSelect the NPC token on the master page and remove their magical status:\n\n`!gaslight var --del magical`\n\nBecause `trigger: gl_magical` is set, the script **automatically re-evaluates**. The half-haze marker should disappear from that player\'s view.\n\nRestore it: `!gaslight var --setch magical 1`', + ...ScriptKit.waitForCommand('!gaslight var') + }, + { prompt: '**Debugging:**\n\nIf something doesn\'t work, use dry-run to see what would execute:\n\n`!gaslight eval --dry-run --all`\n\nThis shows the fully-expanded commands after variable substitution, so you can verify the logic without changing anything.' }, + { prompt: '**Key concepts:**\n\n• `@(target.*)` — the token being evaluated (master copy)\n• `@(viewer.*)` — the viewing player\'s copy (use inside `any()`/`all()`)\n• `@(gm_target.*)` — always resolves to the master token\n• `any(@(viewer.gl_x))` — true if ANY viewer has the value\n• `all(@(viewer.gl_x))` — true if ALL viewers have the value\n• `{& if}` / `{& else}` / `{& end}` — conditional blocks\n• `--set` vs `--setch` — token variable vs character variable\n• `trigger:` — comma-separated list of variables that auto-trigger re-evaluation\n\nSee the other applied examples for more complex scripts.', + offerExamples: ['core-mechanics', 'initiative-hud', 'relay', 'stealth', 'truesight', 'madness'] + }, + ], + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'stealth', + description: 'Hide/show NPCs per player based on passive perception vs stealth DC (RollCapture)', + guide: [ + { prompt: '**Stealth** — This script automatically hides/shows NPCs based on whether each player\'s passive perception beats the NPC\'s stealth roll. Requires **RollCapture** to detect stealth rolls.\n\nClick Continue to verify RollCapture is installed.', + onContinue: () => { + if (typeof RollCapture === 'undefined') return 'RollCapture is not installed. Install it from the one-click menu first.'; + } + }, + { prompt: '**Configure RollCapture:**\n\nRun `!rollcapture dissect` in chat, then roll stealth for any NPC. This shows you the template fields available for capture.', + ...ScriptKit.waitForCommand('!rollcapture dissect') + }, + { prompt: 'Create a RollCapture rule for skills. Run `!rollcapture rule skills` — this creates a *[RC] skills* handout. Open it in the journal.' }, + { prompt: 'Add the line `template: ` — where `` is the roll template shown by dissect (e.g. "simple", "atk", "npc").' }, + { prompt: 'Add the line `name_field: ` — where `` is the template field containing the skill name (e.g. "rname", "name"). This is how RollCapture identifies which skill was rolled.' }, + { prompt: 'Add the line `result: ` — where `` is the inline roll field holding the total (e.g. "r1", "roll1").' }, + { prompt: 'Save the handout and run `!rollcapture reload` to load your new rule.', + ...ScriptKit.waitForCommand('!rollcapture reload') + }, + { prompt: 'What does your character sheet call a stealth roll? This should match the value in the `name_field` when you roll stealth (e.g. "hide", "stealth").', + query: { name: 'rollname', default: 'stealth' } + }, + { prompt: 'What is the **passive perception** attribute on player characters? (e.g. `passive_wisdom`, `passive_perception`)', + query: { name: 'perception', default: 'passive_wisdom' } + }, + ScriptKit.handout(), + { prompt: (ctx) => 'The script handout has been generated. Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', + select: 'pin', as: 'pin', min: 1, max: 1, + onContinue: validatePin + }, + { prompt: '**Test:** Select an NPC token and roll stealth. RollCapture captures the result, which triggers this script. Players whose passive perception is lower than the roll should no longer see the NPC on their page.' }, + ], + handout: (ctx) => ({ + notes: '!token-mod --set {& if any(@(viewer.' + ctx.params.perception + '[-999])) >= @(target.gl_' + ctx.params.rollname + '_result[0])}layer|objects{& else}layer|gmlayer{& end}', + archived: false, + }), + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'truesight', + description: 'Reveal true forms to viewers with truesight (swap token side)', + guide: [ + { prompt: '**Truesight** — This script swaps multi-sided tokens between their disguised appearance (side 1) and true form (side 2) based on whether the viewer has truesight.\n\nSelect one or more **multi-sided tokens** that represent disguised creatures. *Side 1* = disguised, *side 2* = true form.', + select: 'token', as: 'disguised', min: 1, + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; + var notMultiSided = tokens.filter(t => { + var sides = t.get('sides'); + return !sides || sides.split('|').filter(Boolean).length < 2; + }); + if (notMultiSided.length > 0) { + var names = notMultiSided.map(t => t.get('name') || t.get('id')).join(', '); + return 'These tokens are not multi-sided: ' + names + '. Set up multiple sides on them first.'; + } + } + }, + { prompt: 'Run `!gaslight var --setch can_disguise 1` with those tokens still selected. This marks them as targets for the truesight script.', + ...ScriptKit.waitForCommand('!gaslight var'), + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selections.disguised) ? ctx.selections.disguised : [ctx.selections.disguised]; + var missing = tokens.filter(t => { + var charId = t.get('represents'); + if (!charId) return true; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gl_can_disguise' })[0]; + return !attr || attr.get('current') !== '1'; + }); + if (missing.length > 0) { + var names = missing.map(t => t.get('name') || t.get('id')).join(', '); + return 'These tokens do not have gl_can_disguise = 1 on their character: ' + names + '. Run `!gaslight var --setch can_disguise 1` (`--setch`, not `--set`) with them selected.'; + } + } + }, + { prompt: 'Now select viewer tokens that have **truesight** and run `!gaslight var --set truesight 1`.', + ...ScriptKit.waitForCommand('!gaslight var'), + select: 'token', as: 'viewers', min: 1, + onContinue: (ctx) => { + var tokens = Array.isArray(ctx.selected) ? ctx.selected : [ctx.selected]; + var missing = tokens.filter(t => { + var notes = t.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} + return notes.indexOf('gl_truesight') === -1 || notes.indexOf('gl_truesight: 1') === -1; + }); + if (missing.length > 0) { + var names = missing.map(t => t.get('name') || t.get('id')).join(', '); + return 'These tokens do not have gl_truesight set: ' + names + '. Run `!gaslight var --set truesight 1` with them selected.'; + } + } + }, + ScriptKit.handout(), + { prompt: (ctx) => 'Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', + select: 'pin', as: 'pin', min: 1, max: 1, + onContinue: validatePin + }, + { prompt: '**Test:** Run `!gaslight eval --all`. Viewers with truesight should see *side 2* (true form) on disguised tokens.' }, + ], + handout: { + notes: '!token-mod --set {& if @(target.gl_can_disguise[0]) = 1 && any(@(viewer.gl_truesight[0])) = 1}currentSide|1{& else}currentSide|0{& end}', + gmnotes: '---GASLIGHT-SCRIPT---\nfilter: has gl_can_disguise', + }, + }); + + ScriptKit.Gaslight.registerExample(SCRIPT_NAME, { + name: 'madness', + description: 'An afflicted player sees all tokens as enemies — allies swap to a chosen enemy image', + guide: [ + { prompt: '**Madness** — When a player is "afflicted" (via a status marker), all other tokens on their page swap to a chosen enemy image with a dark tint and garbled names. Removing the marker restores everything.\n\nSelect a token that has the **status marker** you want to represent madness applied to it (e.g. "screaming"). This marker will trigger the effect when placed on a viewer\'s token.', + select: 'token', as: 'markerToken', min: 1, max: 1, + onContinue: (ctx) => { + var token = Array.isArray(ctx.selected) ? ctx.selected[0] : ctx.selected; + var markers = (token.get('statusmarkers') || '').split(',').filter(Boolean); + if (markers.length === 0) return 'That token has no status markers applied. Apply the marker you want to use for madness, then select the token.'; + ctx.params._marker = markers[0].replace(/@\d+$/, ''); + } + }, + { prompt: 'Select an **enemy token** whose image will replace all other tokens for the afflicted viewer.', + select: 'token', as: 'enemyToken', min: 1, max: 1, + onContinue: (ctx) => { + var token = Array.isArray(ctx.selected) ? ctx.selected[0] : ctx.selected; + ctx.params._enemyId = token.get('id'); + } + }, + { prompt: (ctx) => 'To afflict a player, apply the **' + ctx.params._marker + '** status marker to their viewer token. Remove it to cure them.\n\nThe script will automatically store and restore original images.' }, + ScriptKit.handout(), + { prompt: (ctx) => 'Find **' + ctx.handoutName + '** from the journal and **drag it onto the master page** to create a map pin. Select the pin.', + select: 'pin', as: 'pin', min: 1, max: 1, + onContinue: validatePin + }, + { prompt: (ctx) => '**Test:** Apply the **' + ctx.params._marker + '** marker to a viewer token, then run `!gaslight eval --all`. All tokens on that viewer\'s page should swap to the enemy image. Remove the marker and re-eval to restore.' }, + ], + handout: (ctx) => ({ + notes: '!token-mod --set {& if ([madness] any(@(viewer.statusmarkers)) ~ "' + ctx.params._marker + '") && !(any(@(viewer.token_id)) = @(target.token_id))}imgsrc|' + ctx.params._enemyId + ' tint_color|#000000 name|"i̒̋̃̑͝_͓̘̝̾̈̿s̵͔̤͚̬͔͉̼̞̦̲̑͊́̏̌̀̿͐ͣ̅͂̃͊͐ͦͤ̊ t̶̴̟̺̲̻̼̼̗̜̤̱̬͍̭̍ͭ͊̿ͮͬ̊̄̋̍̉̾͠_͎͔͍̻̩̣̌ͤͩ̿̾ͮḩ̛͈̫͚̺̼̻̦͔͊ͥͥ̄̅_̛͙ͭaṱ̨̩͈̘̻͚͖̺͕͔̹̱̫̠͙̳̖͕̠͙͆̍̋̍ͨͪͭ̓̂̂̀ͬͬ̃̽̈́̐̃ͯ̽̚͢͢͝ m̶̴̷̧͚̖̼͎͙̹̟̠͎̼̺͓̺̭̄̅͌̿̅̉̂͛̀̏͆̒̇̀̇ͬ̿̋ͮ̓̋̈͘͘͟ȩ̶̵̛̘̬̗̹̟̟̰͎̦͓̹͈͍̘̻ͦ̒̉̉̾̿̇̂̋ͬ́̄̆ͧ͌͊́̈́ͭͪ͛̈̚̚͟͝ͅ"{& else}imgsrc|@(gm_target.token_id) tint_color|transparent name|"@(gm_target.name)"{& end}', + gmnotes: '---GASLIGHT-SCRIPT---\nfilter: characters\ntrigger: on change statusmarkers', + archived: false, + }), + }); + }; + + 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(); // Replaced by ScriptKit gen-help + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + if (Object.keys(state[SCRIPT_NAME].activeGroups || {}).length > 0) { + buildTriggerMap(); + registerAllCompProps(); + } + + // HUD recovery: if enabled but object is missing, recreate; if disabled but object exists, clean up + var s = state[SCRIPT_NAME]; + if (s.hud.view) { + var existing = findHudElement('view'); + if (!existing) updateViewHud(); + } else if (s.hud.viewId) { + var orphan = getObj('text', s.hud.viewId); + if (orphan) orphan.remove(); + s.hud.viewId = null; + } + }; + + /** + * 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'); + var tokenPageId = obj.get('_pageid'); + + // Find if this token is tracked in any active group + var linkedIds = null; + var activeGroup = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + activeGroup = active; + // 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; })); + activeGroup = active; + } + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + if (!activeGroup) return; + + // Determine if this is a "parent" token: + // - On the master page (parent for NPCs and player tokens) + // - On the controlling player's page (parent for player-controlled tokens) + var isParent = false; + if (tokenPageId === activeGroup.masterPageId) { + isParent = true; + } else { + // Check if this token is on its controlling player's page + var controlledBy = obj.get('controlledby') || ''; + if (!controlledBy) { + var charId = obj.get('represents'); + var character = charId ? getObj('character', charId) : null; + if (character) controlledBy = character.get('controlledby') || ''; + } + if (controlledBy) { + var controllerIds = controlledBy.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var playerEntry = Object.entries(activeGroup.playerPages).find(function(e) { + return controllerIds.indexOf(e[0]) !== -1 && e[1].pageId === tokenPageId; + }); + if (playerEntry) isParent = true; + } + } + + if (!isParent) { + // Non-parent deletion: just clean up Mirror/Anchor links, don't cascade + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(tokenId); + if (typeof Mirror !== 'undefined') Mirror.unlink([tokenId]); + return; + } + + // Parent deletion: cascade to all linked copies + 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; + + // Update initiative HUD if the deleted tokens were in the turn order + if (s.hud.initiative) { + var allRemovedIds = new Set([tokenId].concat(linkedIds)); + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var newOrder = order.filter(function(e) { return !allRemovedIds.has(e.id); }); + if (newOrder.length !== order.length) { + Campaign().set('turnorder', JSON.stringify(newOrder)); + updateInitiativeHud(); + } + } + }; + + /** + * 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 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; + if (!viewPlayerId) return; // view off — no relay + var targetPlayerIds = viewPlayerId === 'master' + ? Object.keys(activeEntry[1].playerPages) + : [viewPlayerId]; + 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); + } + }; + + // ========================================================================= + // Initiative Tracking — sync turn order across linked tokens + // ========================================================================= + + /** + * Get all linked token IDs for a given token across all active groups. + * Returns { linkedIds: [...], isMaster: bool, masterPageId: string|null } + */ + const getLinkedInfo = (tokenId) => { + var s = state[SCRIPT_NAME]; + var linkedIds = []; + var isMaster = false; + var masterPageId = null; + Object.values(s.activeGroups).forEach(function(active) { + var ids = active.linkedTokens[tokenId]; + if (ids && ids.length > 0) { + linkedIds = linkedIds.concat(ids); + masterPageId = active.masterPageId; + var obj = getObj('graphic', tokenId); + if (obj && obj.get('_pageid') === active.masterPageId) isMaster = true; + } + }); + return { linkedIds: linkedIds, isMaster: isMaster, masterPageId: masterPageId }; + }; + + /** + * Determine if a token ID is a master token (on master page) in any active group. + */ + const isOnMasterPage = (tokenId) => { + var s = state[SCRIPT_NAME]; + var obj = getObj('graphic', tokenId); + if (!obj) return false; + var pageId = obj.get('_pageid'); + return Object.values(s.activeGroups).some(function(active) { + return active.masterPageId === pageId; + }); + }; + + const isMasterToken = (tokenId) => { + var s = state[SCRIPT_NAME]; + if (!isOnMasterPage(tokenId)) return false; + return Object.values(s.activeGroups).some(function(active) { + return !!active.linkedTokens[tokenId]; + }); + }; + + /** + * Check if two token IDs are linked (in the same link group). + */ + const areLinked = (tokenIdA, tokenIdB) => { + var info = getLinkedInfo(tokenIdA); + return info.linkedIds.indexOf(tokenIdB) !== -1; + }; + + /** + * Handle turnorder changes: sync linked tokens, apply [GM] tags, auto-skip children. + */ + const onTurnOrderChanged = (obj, prev) => { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + + var newOrder = JSON.parse(obj.get('turnorder') || '[]'); + var oldOrder = JSON.parse(prev.turnorder || '[]'); + + // Detect direction early (before skip/reorder modifies newOrder) + var hudDirection = 'none'; + if (oldOrder.length === newOrder.length && newOrder.length > 0) { + var targetEntry = newOrder.find(function(e) { return e.id && e.id !== '-1'; }); + if (targetEntry) { + var newIdx = newOrder.indexOf(targetEntry); + var oldIdx = -1; + for (var oi = 0; oi < oldOrder.length; oi++) { + if (oldOrder[oi].id === targetEntry.id) { oldIdx = oi; break; } + } + if (oldIdx !== -1 && oldIdx !== newIdx) { + var rotAmount = oldIdx - newIdx; + var len = newOrder.length; + // Normalize rotation + rotAmount = ((rotAmount % len) + len) % len; + var rotated = newOrder.slice(len - rotAmount).concat(newOrder.slice(0, len - rotAmount)); + var oldIdStr = oldOrder.map(function(e) { return e.id; }).join(','); + var rotIdStr = rotated.map(function(e) { return e.id; }).join(','); + if (rotIdStr === oldIdStr) { + hudDirection = rotAmount <= len / 2 ? 'forward' : 'backward'; + } + } + } + } + + // Detect additions: entries in newOrder not in oldOrder + var oldIds = new Set(oldOrder.map(function(e) { return e.id; })); + var newIds = new Set(newOrder.map(function(e) { return e.id; })); + var added = newOrder.filter(function(e) { return e.id && e.id !== '-1' && !oldIds.has(e.id); }); + var removed = oldOrder.filter(function(e) { return e.id && e.id !== '-1' && !newIds.has(e.id); }); + + var modified = false; + + var modified = false; + + // Handle additions: add linked counterparts + added.forEach(function(entry) { + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + // Deduplicate linked IDs (can accumulate duplicates from repeated splits) + var uniqueLinkedIds = info.linkedIds.filter(function(id, i) { return info.linkedIds.indexOf(id) === i; }); + // Only process if none of this entry's linked tokens are already in the order + var existingIds = new Set(newOrder.map(function(e) { return e.id; })); + var alreadyHasLinked = uniqueLinkedIds.some(function(lid) { return existingIds.has(lid); }); + if (alreadyHasLinked) return; + uniqueLinkedIds.forEach(function(linkedId) { + if (existingIds.has(linkedId)) return; + var linkedObj = getObj('graphic', linkedId); + var pushEntry = { id: linkedId, pr: entry.pr }; + if (linkedObj) pushEntry._pageid = linkedObj.get('_pageid'); + newOrder.push(pushEntry); + modified = true; + }); + }); + + // Handle removals: remove linked counterparts + removed.forEach(function(entry) { + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + var before = newOrder.length; + newOrder = newOrder.filter(function(e) { + return info.linkedIds.indexOf(e.id) === -1; + }); + if (newOrder.length !== before) modified = true; + }); + + // Handle value changes: sync initiative values across linked tokens + newOrder.forEach(function(entry) { + if (!entry.id || entry.id === '-1') return; + var oldEntry = oldOrder.find(function(e) { return e.id === entry.id; }); + if (!oldEntry || oldEntry.pr === entry.pr) return; + // Value changed — propagate to linked tokens + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return; + info.linkedIds.forEach(function(linkedId) { + var linkedEntry = newOrder.find(function(e) { return e.id === linkedId; }); + if (linkedEntry && linkedEntry.pr !== entry.pr) { + linkedEntry.pr = entry.pr; + modified = true; + } + }); + }); + + // Reorder: group linked tokens together, master first (only on additions) + if (added.length > 0) { + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + + // Auto-skip: if turn advanced and new top is linked to the entry that just left the top + // Auto-skip: if current top is a linked non-master token, skip in the correct direction + if (newOrder.length > 1 && newOrder[0].id && newOrder[0].id !== '-1') { + var topId = newOrder[0].id; + var topInfo = getLinkedInfo(topId); + if (topInfo.linkedIds.length > 0 && !topInfo.isMaster) { + // Detect direction + var isForward = oldOrder.length > 0 && newOrder[newOrder.length - 1].id === oldOrder[0].id; + var isBackward = oldOrder.length > 0 && newOrder[0].id === oldOrder[oldOrder.length - 1].id; + + if (isForward || isBackward) { + var startId = topId; + var safety = newOrder.length; + + if (isForward) { + // Rotate forward: shift to end until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.push(newOrder.shift()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + } else { + // Rotate backward: pop from end to front until master/unlinked is on top + while (safety-- > 0 && newOrder.length > 1) { + newOrder.unshift(newOrder.pop()); + var cId = newOrder[0].id; + if (!cId || cId === '-1') break; + if (cId === startId) break; + var cInfo = getLinkedInfo(cId); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + } + } + modified = true; + } else { + // Not forward/backward — manual drag. Reorder to group children after master. + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + } + } + } + + // Always ensure linked tokens are grouped below their master + var reordered = reorderInitiative(newOrder); + if (JSON.stringify(reordered) !== JSON.stringify(newOrder)) { + newOrder = reordered; + modified = true; + } + + // Apply round calculation for custom turns that reached the top via our rotation + // Only if we modified the order (if not modified, Roll20 handled it naturally and already applied the formula) + if (modified && newOrder.length > 0 && newOrder[0].id === '-1' && newOrder[0].formula) { + var topEntry = newOrder[0]; + // Only apply if this custom turn was NOT already at the top + var wasAlreadyTop = oldOrder.length > 0 && oldOrder[0].id === '-1' && oldOrder[0].custom === topEntry.custom && oldOrder[0].pr === topEntry.pr; + if (!wasAlreadyTop) { + var formula = topEntry.formula.trim(); + var currentPr = parseFloat(topEntry.pr) || 0; + var val = parseFloat(formula.replace(/^[+-]/, '')) || 0; + var isAdd = formula.startsWith('+'); + // Forward = apply normally, backward = apply inverse + if (hudDirection === 'backward') isAdd = !isAdd; + topEntry.pr = isAdd ? currentPr + val : currentPr - val; + } + } + + if (modified) { + var finalJson = JSON.stringify(newOrder); + Campaign().set('turnorder', finalJson); + } + + // Update initiative HUD if enabled + if (state[SCRIPT_NAME].hud.initiative) { + updateInitiativeHud(hudDirection); + } + }; + + /** + * Reorder initiative: group linked tokens together with master first in each group. + */ + const reorderInitiative = (order) => { + var s = state[SCRIPT_NAME]; + var result = []; + var placed = new Set(); + + order.forEach(function(entry) { + if (placed.has(entry.id)) return; + if (!entry.id || entry.id === '-1') { + result.push(entry); + // Don't add '-1' to placed — multiple custom entries share this ID + return; + } + + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) { + // Not a linked token — just add it + result.push(entry); + placed.add(entry.id); + return; + } + + // Skip children — they'll be pulled in when we reach their master + if (!isMasterToken(entry.id)) return; + + // Find all entries in this link group + var groupIds = [entry.id].concat(info.linkedIds); + var groupEntries = order.filter(function(e) { + return groupIds.indexOf(e.id) !== -1 && !placed.has(e.id); + }); + + // Sort: master first, then children + groupEntries.sort(function(a, b) { + var aIsMaster = isMasterToken(a.id) ? 0 : 1; + var bIsMaster = isMasterToken(b.id) ? 0 : 1; + return aIsMaster - bIsMaster; + }); + + groupEntries.forEach(function(e) { + result.push(e); + placed.add(e.id); + }); + }); + + return result; + }; + + /** + * !gaslight init [sync] [trim] + * sync (default): ensure all linked tokens are in initiative, grouped with masters first. + * trim: remove entries for tokens that no longer exist. + * Both can be combined: trim runs first, then sync. + */ + const doInit = (msg, args) => { + var s = state[SCRIPT_NAME]; + var doTrim = args.indexOf('trim') !== -1; + var doSync = args.indexOf('sync') !== -1 || args.length === 0 || (args.length === 1 && doTrim); + + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (order.length === 0) { reply(msg, 'Init', 'Turn order is empty.'); return; } + + var trimmed = 0; + var added = 0; + var warnings = []; + + // Trim: remove entries for tokens that no longer exist + if (doTrim) { + var before = order.length; + order = order.filter(function(e) { + if (!e.id || e.id === '-1') return true; + return !!getObj('graphic', e.id); + }); + trimmed = before - order.length; + } + + // Sync: add missing linked tokens, add missing masters, regroup + if (doSync) { + var existingIds = new Set(order.map(function(e) { return e.id; })); + var toAdd = []; + + order.forEach(function(entry) { + if (!entry.id || entry.id === '-1') return; + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) { + if (isOnMasterPage(entry.id)) { + var token = getObj('graphic', entry.id); + var name = token ? (token.get('name') || entry.id) : entry.id; + warnings.push(name + ' is in initiative but has no linked tokens (not staged?)'); + } + return; + } + + // Add master if missing + if (!isMasterToken(entry.id)) { + var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (masterId && !existingIds.has(masterId)) { + var masterObj = getObj('graphic', masterId); + toAdd.push({ id: masterId, pr: entry.pr, _pageid: masterObj ? masterObj.get('_pageid') : undefined }); + existingIds.add(masterId); + added++; + } + } + + // Add missing linked copies + info.linkedIds.forEach(function(linkedId) { + if (existingIds.has(linkedId)) return; + var linkedObj = getObj('graphic', linkedId); + if (!linkedObj) return; + toAdd.push({ id: linkedId, pr: entry.pr, _pageid: linkedObj.get('_pageid') }); + existingIds.add(linkedId); + added++; + }); + }); + + order = order.concat(toAdd); + order = reorderInitiative(order); + } + Campaign().set('turnorder', JSON.stringify(order)); + + var parts = []; + if (doTrim) parts.push(trimmed + ' stale entry(s) trimmed'); + if (doSync) parts.push(added + ' entry(s) added'); + var out = parts.join(', ') + '.'; + if (warnings.length > 0) { + out += '

        ⚠️ ' + warnings.join('
        ⚠️ '); + } + reply(msg, 'Init', out); + if (s.hud.initiative) { + // Refresh tags on existing entries + if (s.hud.initData && s.hud.initData.entries) { + s.hud.initData.entries.forEach(function(e) { + if (e.sourceId.startsWith('custom:')) return; + var pin = getObj('pin', e.tokenId); + if (!pin) return; + var tagStr = computeHudTag(e.sourceId); + var sourceToken = getObj('graphic', e.sourceId); + var name = sourceToken ? (sourceToken.get('name') || '') : ''; + pin.set('title', ((tagStr ? '⚠️ ' : '') + name).trim()); + pin.set('notes', tagStr || ''); + }); + } + updateInitiativeHud(); + } + }; + + // ========================================================================= + // HUD System — on-canvas indicators + // ========================================================================= + + const HUD_PREFIX = 'gaslight_hud_'; + + var _hudCreatedIds = new Set(); + const createHudObj = (type, props) => { + var obj = createObj(type, props); + if (obj) _hudCreatedIds.add(obj.get('id')); + return obj; + }; + + /** + * Get the master page ID from the first active group. + */ + const getHudPageId = () => { + var s = state[SCRIPT_NAME]; + var group = Object.values(s.activeGroups)[0]; + return group ? group.masterPageId : null; + }; + + /** + * Update or create the current-turn indicator (pathv2 rectangle around the current turn's token). + */ + const updateTurnReticle = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.reticle) return; + if (!s.hud.initData) s.hud.initData = {}; + if (!s.hud.reticleData) s.hud.reticleData = {}; + var data = s.hud.initData; + var rd = s.hud.reticleData; + + var pageId = getHudPageId(); + if (!pageId) { removeTurnReticle(); return; } + + // Find current turn master token + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (order.length === 0) { removeTurnReticle(); return; } + + var topEntry = order[0]; + if (!topEntry.id || topEntry.id === '-1') { removeTurnReticle(); return; } + + // Find the master token for this entry + var tokenId = topEntry.id; + if (!isMasterToken(tokenId)) { + var info = getLinkedInfo(tokenId); + var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (masterId) tokenId = masterId; + } + + var token = getObj('graphic', tokenId); + if (!token || token.get('_pageid') !== pageId) { removeTurnReticle(); return; } + + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var x = token.get('left') + (rd.xOffset || 0) * tokenW; + var y = token.get('top') + (rd.yOffset || 0) * tokenH; + var w = tokenW * (rd.widthScale || 1); + var h = tokenH * (rd.heightScale || 1); + var stroke = rd.stroke || data.highlightStroke || defaultInitHud.highlightStroke; + var strokeWidth = rd.strokeWidth || data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth; + var hlRotation = data.highlightRotation != null ? data.highlightRotation : 45; + var reticleRotation = hlRotation + (rd.rotationOffset != null ? rd.rotationOffset : -hlRotation); + + // Create or update + var reticle = rd.id ? getObj('pathv2', rd.id) : null; + if (reticle) { + reticle.set({ + x: x, + y: y, + points: JSON.stringify([[0, 0], [w, h]]), + stroke: stroke, + stroke_width: strokeWidth, + rotation: reticleRotation, + }); + } else { + reticle = createHudObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: 'rec', + x: x, + y: y, + points: JSON.stringify([[0, 0], [w, h]]), + stroke: stroke, + stroke_width: strokeWidth, + fill: 'transparent', + rotation: reticleRotation, + }); + if (reticle) rd.id = reticle.get('id'); + } + + // Track which token we're following + rd.tokenId = tokenId; + }; + + /** + * Remove the current-turn indicator. + */ + const removeTurnReticle = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.reticleData) return; + var rd = s.hud.reticleData; + if (rd.id) { + var reticle = getObj('pathv2', rd.id); + delete rd.id; + if (reticle) reticle.remove(); + } + delete rd.tokenId; + }; + + /** + * Find an existing HUD text object by stored ID. + */ + const findHudElement = (name) => { + var s = state[SCRIPT_NAME]; + var id = s.hud[name + 'Id']; + if (!id) return null; + return getObj('text', id) || null; + }; + + /** + * Create or update the view HUD element. + */ + const updateViewHud = () => { + var s = state[SCRIPT_NAME]; + if (!s.hud.view) return; + + var pageId = getHudPageId(); + if (!pageId) return; + + var page = getObj('page', pageId); + if (!page) return; + + // Determine display text + var label; + if (s.view === null) { + label = '🔴 RELAY OFF'; + } else if (s.view === 'master') { + label = '🟢 RELAY: ALL'; + } else { + // Resolve player name + var playerName = s.view; + Object.values(s.activeGroups).forEach(function(g) { + var entry = g.playerPages[s.view]; + if (entry && entry.name) playerName = entry.name; + }); + label = '🔵 VIEW: ' + playerName; + } + + // Find existing or create new + var existing = findHudElement('view'); + if (existing) { + existing.set('text', label); + } else { + // Use stored position/size or defaults + var vd = s.hud.viewData || {}; + var pageWidth = page.get('width') * 70; + var obj = createHudObj('text', { + _pageid: pageId, + layer: 'foreground', + text: label, + left: Math.round((vd.leftNorm || defaultViewHud.leftNorm) * pageWidth), + top: vd.top || defaultViewHud.top, + font_size: vd.fontSize || defaultViewHud.fontSize, + color: vd.color || defaultViewHud.color, + stroke: vd.stroke || defaultViewHud.stroke, + font_family: vd.fontFamily || defaultViewHud.fontFamily, + rotation: vd.rotation || 0, + }); + s.hud.viewId = obj.get('id'); + } + }; + + /** + * Remove the view HUD element (programmatic). Clears ID first so destroy handler ignores it. + */ + const removeViewHud = () => { + var s = state[SCRIPT_NAME]; + var id = s.hud.viewId; + s.hud.viewId = null; + if (id) { + var obj = getObj('text', id); + if (obj) obj.remove(); + } + }; + + // ---- Initiative HUD ---- + + const defaultViewHud = { + leftNorm: 0.5, + top: 100, + fontSize: 40, + fontFamily: 'Contrail One', + color: '#ffffff', + stroke: '#000000', + }; + + /** + * Convert a pin's x/y (the "point" where the teardrop points) to its visual center. + * Pin scale 0.25 = 10px, scale 2.0 = 80px → size = scale * 40. + * Visual center is offset upward by half the pin's visual height. + */ + const toPinCenter = (pin) => { + var scale = pin.get('scale') || 1; + var size = scale * 40; + return { x: pin.get('x'), y: pin.get('y') - size / 2 }; + }; + + /** + * Convert a target center coordinate to the pin x/y that would place + * the pin's visual center at that point. + */ + const toPinPosition = (x, y, scale) => { + scale = scale || 1; + var size = scale * 40; + return { x: x, y: y + size / 2 }; + }; + + const defaultInitHud = { + tokenSize: 50, + tokenPadding: 20, + vPadding: 15, + hPadding: 10, + textOffset: 25, + textFontSize: 16, + textFontFamily: 'Contrail One', + textColor: '#ffffff', + textStroke: '#000000', + frameStroke: '#ffffff', + frameFill: 'transparent', + frameStrokeWidth: 3, + highlightStroke: '#ffcc00', + highlightFill: 'transparent', + highlightStrokeWidth: 5, + currentTurnOffset: 0.5, + entries: [], + frameId: null, + highlightId: null, + pos: null, + frameSize: null, + }; + + const hudSlotY = (frameCenter, offset, tokenSize, tokenPadding) => { + return frameCenter + offset * (tokenSize + tokenPadding); + }; + const hudPinY = (frameCenter, offset, tokenSize, tokenPadding) => { + return hudSlotY(frameCenter, offset, tokenSize, tokenPadding) + tokenSize / 2; + }; + + /** + * Compute how many slots should be above vs below the diamond, + * distributing proportionally based on available space on each side. + * Returns { above, below } counts. + */ + const computeHudSlotDistribution = (count, slotsBelow, slotsAbove) => { + // Entry 0 is in the diamond. Remaining entries fill below then above proportionally. + var remaining = Math.min(count - 1, slotsBelow + slotsAbove); + if (remaining <= 0) return { below: 0, above: 0 }; + var totalSlots = slotsBelow + slotsAbove; + if (totalSlots === 0) return { below: 0, above: 0 }; + var below = Math.min(slotsBelow, Math.round(remaining * slotsBelow / totalSlots)); + var above = Math.min(slotsAbove, remaining - below); + // If rounding left us short, add to whichever side has room + while (below + above < remaining) { + if (below < slotsBelow) below++; + else if (above < slotsAbove) above++; + else break; + } + return { below: below, above: above }; + }; + + /** + * Compute token size from frame width or stored override. + */ + const getHudTokenSize = (frame) => { + var s = state[SCRIPT_NAME]; + if (s.hud.initData && s.hud.initData.tokenSize) return s.hud.initData.tokenSize; + var points = JSON.parse(frame.get('points') || '[]'); + var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : defaultInitHud.tokenSize + 2 * defaultInitHud.hPadding; + var hPad = (s.hud.initData && s.hud.initData.hPadding) || defaultInitHud.hPadding; + return Math.max(10, frameWidth - 2 * hPad); + }; + + /** + * Get the deduped turn order (master tokens only, skip children). + */ + const getHudTurnOrder = () => { + var s = state[SCRIPT_NAME]; + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + // Collect all page IDs belonging to active groups + var groupPageIds = new Set(); + Object.values(s.activeGroups).forEach(function(active) { + groupPageIds.add(active.masterPageId); + Object.values(active.playerPages).forEach(function(p) { groupPageIds.add(p.pageId); }); + }); + // Track which linked groups we've already included (by master ID or first seen ID) + var seenGroups = new Set(); + return order.filter(function(entry) { + if (!entry.id || entry.id === '-1') return true; // customs always + var token = getObj('graphic', entry.id); + if (!token) return false; // stale entry + var pageId = token.get('_pageid'); + if (!groupPageIds.has(pageId)) return false; // not on a group page + // Check if this token is part of a linked group + var info = getLinkedInfo(entry.id); + if (info.linkedIds.length === 0) return true; // unlinked token, always show + // For linked tokens: only show if this is the master, or if master isn't in the order + if (isMasterToken(entry.id)) { + seenGroups.add(entry.id); + return true; + } + // It's a child — check if its master is in the order + var masterId = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (masterId && seenGroups.has(masterId)) return false; // master already shown + if (masterId && order.some(function(e) { return e.id === masterId; })) { + seenGroups.add(masterId); + return false; // master is in order, it will be shown when we reach it + } + // No master in order — show this child as representative + if (!seenGroups.has(entry.id)) { + seenGroups.add(entry.id); + return true; + } + return false; + }); + }; + + /** + * Compute the tag string for a HUD entry (which views are missing this token's turn). + * Returns '' if present on all views, or a "Missing from" list. + */ + const computeHudTag = (sourceId) => { + var s = state[SCRIPT_NAME]; + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var activeGroup = Object.values(s.activeGroups)[0]; + if (!activeGroup) return ''; + + // Get all view names: GM + each player + var allViews = ['GM']; + var viewPageMap = { GM: activeGroup.masterPageId }; + Object.entries(activeGroup.playerPages).forEach(function(entry) { + var name = entry[1].name || entry[0].slice(0, 6); + allViews.push(name); + viewPageMap[name] = entry[1].pageId; + }); + + // Find which pages have a turn for this token (or its linked copies) + var info = getLinkedInfo(sourceId); + var groupIds = new Set([sourceId].concat(info.linkedIds)); + var pagesWithTurn = new Set(); + order.forEach(function(e) { + if (groupIds.has(e.id)) { + var token = getObj('graphic', e.id); + if (token) pagesWithTurn.add(token.get('_pageid')); + } + }); + + // Find which views are missing + var missingViews = allViews.filter(function(v) { return !pagesWithTurn.has(viewPageMap[v]); }); + + if (missingViews.length === 0) return ''; // on all views + return 'No Turns For:\n' + missingViews.map(function(v) { return v.length > 70 ? v.slice(0, 67) + '...' : v; }).join('\n'); + }; + + /** + * Draw a rectangle path on the foreground layer. + */ + const createFramePath = (pageId, left, top, width, height, data) => { + return createHudObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: 'rec', + x: left, + y: top, + points: JSON.stringify([[0, 0], [width, height]]), + stroke: (data && data.frameStroke) || defaultInitHud.frameStroke, + stroke_width: (data && data.frameStrokeWidth) || defaultInitHud.frameStrokeWidth, + fill: (data && data.frameFill) || defaultInitHud.frameFill, + }); + }; + + /** + * Build/rebuild the initiative HUD. + */ + const updateInitiativeHud = (direction) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative) return; + + var pageId = getHudPageId(); + if (!pageId) return; + + var page = getObj('page', pageId); + if (!page) return; + + var order = getHudTurnOrder(); + if (!s.hud.initData) s.hud.initData = {}; + if (!s.hud.initData.entries) s.hud.initData.entries = []; + var data = s.hud.initData; + + var frameWidth = defaultInitHud.tokenSize + 2 * defaultInitHud.hPadding; + + // Create frame if missing + if (!data.frameId || !getObj('pathv2', data.frameId)) { + var pos = data.pos || { left: 100, top: Math.round(page.get('height') * 70 / 2) }; + var frameHeight = 5.5 * (defaultInitHud.tokenSize + defaultInitHud.tokenPadding) + 2 * defaultInitHud.vPadding; + var frameSize = data.frameSize || { width: frameWidth, height: frameHeight }; + var frame = createFramePath(pageId, pos.left, pos.top, frameSize.width, frameSize.height, data); + data.frameId = frame.get('id'); + data.pos = pos; + data.frameSize = frameSize; + } + + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + + // Create highlight indicator if missing + if (!data.highlightId || !getObj('pathv2', data.highlightId)) { + var hlTokenSize = data.tokenSize || defaultInitHud.tokenSize; + var hlSize = hlTokenSize + 15; + var hlOffset = data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset; + var frameH = JSON.parse(frame.get('points') || '[]'); + var fH = frameH.length >= 2 ? frameH[1][1] - frameH[0][1] : 510; + var vPadHL = data.vPadding || defaultInitHud.vPadding; + var usableTopHL = frame.get('y') - fH / 2 + hlTokenSize / 2 + vPadHL; + var usableBotHL = frame.get('y') + fH / 2 - hlTokenSize / 2 - vPadHL; + var hlStep = hlTokenSize + (data.tokenPadding || defaultInitHud.tokenPadding); + var hlMid = (usableTopHL + usableBotHL) / 2; + var rawHlCenter = usableTopHL + (usableBotHL - usableTopHL) * hlOffset; + var hlSlotFromCenter = hlStep > 0 ? Math.round((rawHlCenter - hlMid) / hlStep) : 0; + var hlY = hlMid + hlSlotFromCenter * hlStep; + var highlight = createHudObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: 'rec', + x: frame.get('x'), + y: hlY, + points: JSON.stringify([[0, 0], [hlSize, hlSize]]), + stroke: data.highlightStroke || defaultInitHud.highlightStroke, + stroke_width: data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth, + fill: data.highlightFill || defaultInitHud.highlightFill, + rotation: data.highlightRotation != null ? data.highlightRotation : 45, + }); + data.highlightId = highlight.get('id'); + } + + var tokenSize = getHudTokenSize(frame); + var frameLeft = frame.get('x'); + var frameTop = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; + var frameTopEdge = frameTop - frameHeight / 2; + + // Remove old entries no longer in the order + // For tokens: match by ID. For customs: if order has fewer customs than we have, remove extras. + var orderTokenIds = order.filter(function(e) { return e.id && e.id !== '-1'; }).map(function(e) { return e.id; }); + var orderCustomCount = order.filter(function(e) { return !e.id || e.id === '-1'; }).length; + var currentCustomCount = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).length; + + var toRemove = []; + var customsToRemove = currentCustomCount - orderCustomCount; + data.entries.forEach(function(entry, i) { + if (entry.sourceId && entry.sourceId.startsWith('custom:')) { + if (customsToRemove > 0) { toRemove.push(i); customsToRemove--; } + } else if (orderTokenIds.indexOf(entry.sourceId) === -1) { + toRemove.push(i); + } + }); + toRemove.reverse().forEach(function(i) { + var entry = data.entries[i]; + var pin = getObj('pin', entry.tokenId); + var txt = getObj('text', entry.textId); + // Splice first so destroy handler ignores + data.entries.splice(i, 1); + if (pin) pin.remove(); + if (txt) txt.remove(); + }); + + // Add new entries not yet in the HUD + var existingTokenIds = data.entries.filter(function(e) { return !e.sourceId.startsWith('custom:'); }).map(function(e) { return e.sourceId; }); + var customsToAdd = orderCustomCount - data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).length; + + order.forEach(function(entry) { + var isCustom = !entry.id || entry.id === '-1'; + + if (isCustom) { + if (customsToAdd <= 0) return; + customsToAdd--; + + var hudPin = createHudObj('pin', { + _pageid: pageId, + x: frameLeft, + y: -5000, + title: entry.custom || 'Custom', + shape: 'circle', + bgColor: 'transparent', + useTextIcon: true, + textIcon: '', + scale: 1.25, + visibleTo: 'all', + notesDesynced: true, + }); + + var pinText = createHudObj('text', { + _pageid: pageId, + layer: 'foreground', + text: String(entry.pr || ''), + left: -5000, + top: -5000, + font_size: data.textFontSize || defaultInitHud.textFontSize, + color: data.textColor || defaultInitHud.textColor, + stroke: data.textStroke || defaultInitHud.textStroke, + font_family: data.textFontFamily || defaultInitHud.textFontFamily, + rotation: data.textRotation || 0, + }); + + data.entries.push({ + sourceId: 'custom:' + Date.now() + ':' + Math.random().toString(36).slice(2, 6), + tokenId: hudPin ? hudPin.get('id') : null, + textId: pinText.get('id'), + }); + } else { + if (existingTokenIds.indexOf(entry.id) !== -1) return; + + var sourceToken = getObj('graphic', entry.id); + if (!sourceToken) return; + + var tagStr = computeHudTag(entry.id); + var pinTitle = ((tagStr ? '⚠️ ' : '') + (sourceToken.get('name') || '')).trim(); + + var hudPin = createHudObj('pin', { + _pageid: pageId, + x: frameLeft, + y: -5000, + title: pinTitle, + shape: 'circle', + bgColor: 'transparent', + customizationType: 'image', + pinImage: sourceToken.get('imgsrc').replace(/\/(?:med|max|original)\.png/, '/thumb.png'), + scale: 1.25, + visibleTo: 'all', + notesDesynced: true, + notes: tagStr || '', + }); + + var hudText = createHudObj('text', { + _pageid: pageId, + layer: 'foreground', + text: String(entry.pr || ''), + left: 0, + top: 0, + font_size: data.textFontSize || defaultInitHud.textFontSize, + color: data.textColor || defaultInitHud.textColor, + stroke: data.textStroke || defaultInitHud.textStroke, + font_family: data.textFontFamily || defaultInitHud.textFontFamily, + rotation: data.textRotation || 0, + }); + + data.entries.push({ + sourceId: entry.id, + tokenId: hudPin ? hudPin.get('id') : null, + textId: hudText.get('id'), + }); + } + }); + + if (direction === undefined) { + // Initial creation — delay reflow to give Roll20 time to register objects + setTimeout(function() { reflowInitiativeHud('none'); updateTurnReticle(); }, 100); + } else { + reflowInitiativeHud(direction); + updateTurnReticle(); + } + }; + + /** + * Reflow initiative HUD: current turn at top, overflow hidden. + */ + const reflowInitiativeHud = (direction) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + + var frame = getObj('pathv2', data.frameId); + if (!frame) return; + + var order = getHudTurnOrder(); + var frameLeft = frame.get('x'); + var frameTop = frame.get('y'); + var points = JSON.parse(frame.get('points') || '[]'); + var frameHeight = points.length >= 2 ? points[1][1] - points[0][1] : 510; + var tokenSize = getHudTokenSize(frame); + var tokenPadding = data.tokenPadding || defaultInitHud.tokenPadding; + var vPadding = data.vPadding || defaultInitHud.vPadding; + var frameTopEdge = frameTop - frameHeight / 2 + vPadding; + var frameBotEdge = frameTop + frameHeight / 2 - vPadding; + var step = tokenSize + tokenPadding; + var frameMid = (frameTopEdge + frameBotEdge) / 2; + var rawOffset = data.currentTurnOffset != null ? data.currentTurnOffset : defaultInitHud.currentTurnOffset; + var rawCenter = (frameTopEdge + tokenSize / 2) + (frameBotEdge - frameTopEdge - tokenSize) * rawOffset; + // Snap to nearest slot from frame center + var slotFromCenter = step > 0 ? Math.round((rawCenter - frameMid) / step) : 0; + var frameCenter = frameMid + slotFromCenter * step; + // Calculate how many slots fit below and above the indicator + var slotsBelow = 0; + var slotsAbove = 0; + while (frameCenter + (slotsBelow + 1) * step + tokenSize / 2 <= frameBotEdge) slotsBelow++; + while (frameCenter - (slotsAbove + 1) * step - tokenSize / 2 >= frameTopEdge) slotsAbove++; + + // Temporary diamond offset: if there are hidden entries and the combined + // "extra" space (fractional leftover on each side) can fit one more slot, + // shift the diamond to make room. + var totalVisible = 1 + slotsBelow + slotsAbove; + if (order.length > totalVisible) { + var extraBelow = frameBotEdge - (frameCenter + slotsBelow * step + tokenSize / 2); + var extraAbove = (frameCenter - slotsAbove * step - tokenSize / 2) - frameTopEdge; + if (extraBelow + extraAbove >= step) { + // We can fit one more. Shift diamond toward the side with more slots. + var shiftAmount; + if (slotsBelow >= slotsAbove) { + // More below (or equal) — shift down to make room above + shiftAmount = Math.min(step - extraAbove, extraBelow); + frameCenter += shiftAmount; + slotsAbove++; + } else { + // More above — shift up to make room below + shiftAmount = Math.min(step - extraBelow, extraAbove); + frameCenter -= shiftAmount; + slotsBelow++; + } + } + } + + // Update highlight position and size + var highlight = data.highlightId ? getObj('pathv2', data.highlightId) : null; + if (highlight) { + var hlSize = tokenSize + 15; + highlight.set({ + x: frameLeft, + y: frameCenter, + points: JSON.stringify([[0, 0], [hlSize, hlSize]]), + }); + } + var frameWidth = points.length >= 2 ? points[1][0] - points[0][0] : tokenSize + 2 * (data.hPadding || defaultInitHud.hPadding); + + // Match order entries to HUD entries + var tokenMap = {}; + data.entries.forEach(function(e) { + if (e.sourceId && !e.sourceId.startsWith('custom:')) tokenMap[e.sourceId] = e; + }); + var customEntries = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + + if (direction === 'forward' || direction === 'backward') { + // Simple shift: move all custom pins/text by one slot, wrap at edges + var shift = direction === 'forward' + ? -(tokenSize + tokenPadding) + : (tokenSize + tokenPadding); + + // Gather all HUD text Y positions (tokens + customs) + var allTextYs = data.entries.map(function(e) { + var t = getObj('text', e.textId); + return t ? t.get('top') : null; + }).filter(function(y) { return y !== null; }); + var minTextY = Math.min.apply(null, allTextYs); + var maxTextY = Math.max.apply(null, allTextYs); + + customEntries.forEach(function(e) { + var pin = getObj('pin', e.tokenId); + var txt = getObj('text', e.textId); + if (!txt) return; + var currentY = txt.get('top'); + var newSlotY; + + if (direction === 'forward' && currentY === minTextY) { + // Wrapping: place at where the current max will be after shift + newSlotY = maxTextY; + } else if (direction === 'backward' && currentY === maxTextY) { + // Wrapping: place at where the current min will be after shift + newSlotY = minTextY; + } else { + newSlotY = currentY + shift; + } + + var visible = (newSlotY - tokenSize / 2 >= frameTopEdge) && + (newSlotY + tokenSize / 2 <= frameBotEdge); + var newPinY = newSlotY + tokenSize / 2; + if (pin) { + pin.set({ x: visible ? frameLeft : -5000, y: visible ? newPinY : -5000 }); + } + txt.set({ top: newSlotY, color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent' }); + }); + + // Update custom text values from current order based on position + var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + customEntries.forEach(function(e) { + var txt = getObj('text', e.textId); + if (!txt) return; + var txtY = txt.get('top'); + // Find which order slot this text is closest to + var bestOffset = null; + var bestDist = Infinity; + for (var ci = 0; ci < order.length; ci++) { + if (order[ci].id && order[ci].id !== '-1') continue; + var off = ci <= order.length / 2 ? ci : ci - order.length; + var slotY = hudSlotY(frameCenter, off, tokenSize, tokenPadding); + var dist = Math.abs(txtY - slotY); + if (dist < bestDist) { bestDist = dist; bestOffset = ci; } + } + if (bestOffset !== null) { + txt.set('text', String(order[bestOffset].pr || '')); + } + }); + + // Reflow only token entries by ID + var tokenDist = computeHudSlotDistribution(order.length, slotsBelow, slotsAbove); + var tUsedBelow = tokenDist.below; + var tUsedAbove = tokenDist.above; + order.forEach(function(entry, i) { + if (!entry.id || entry.id === '-1') return; + var hudEntry = tokenMap[entry.id]; + if (!hudEntry) return; + + var pin = getObj('pin', hudEntry.tokenId); + var txt = getObj('text', hudEntry.textId); + if (!pin) return; + + var offset; + if (i === 0) offset = 0; + else if (i <= tUsedBelow) offset = i; + else offset = -(tUsedBelow + tUsedAbove - i + 1); + var visible = (i === 0) || (i <= tUsedBelow) || (i <= tUsedBelow + tUsedAbove); + var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); + + pin.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000 }); + if (txt) { + txt.set({ + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), + top: yPos + (data.textVOffset || 0), + color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', + text: String(entry.pr || ''), + }); + } + }); + } else { + // Full reflow (sort/add/remove): match customs by pr value + var visibleCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return obj && obj.get('y') > -1000; + }); + var hiddenCustoms = customEntries.filter(function(e) { + var obj = getObj('pin', e.tokenId); + return !obj || obj.get('y') <= -1000; + }); + var sortedCustoms = []; + var usedVisible = new Set(); + var customOrderEntries = order.filter(function(e) { return !e.id || e.id === '-1'; }); + customOrderEntries.forEach(function(orderEntry) { + var prVal = String(orderEntry.pr || ''); + var match = visibleCustoms.findIndex(function(e, i) { + if (usedVisible.has(i)) return false; + var txt = getObj('text', e.textId); + return txt && txt.get('text') === prVal; + }); + if (match !== -1) { + sortedCustoms.push(visibleCustoms[match]); + usedVisible.add(match); + } else if (hiddenCustoms.length > 0) { + sortedCustoms.push(hiddenCustoms.shift()); + } + }); + visibleCustoms.forEach(function(e, i) { + if (!usedVisible.has(i)) sortedCustoms.push(e); + }); + sortedCustoms = sortedCustoms.concat(hiddenCustoms); + var customIdx = 0; + + var offsets = computeHudSlotDistribution(order.length, slotsBelow, slotsAbove); + var usedBelow = offsets.below; + var usedAbove = offsets.above; + order.forEach(function(entry, i) { + var isCustom = !entry.id || entry.id === '-1'; + var hudEntry = isCustom ? sortedCustoms[customIdx++] : tokenMap[entry.id]; + if (!hudEntry) return; + + var pin = getObj('pin', hudEntry.tokenId); + var txt = getObj('text', hudEntry.textId); + if (!pin) return; + + // Sequential: 0 = diamond, 1..usedBelow = below, above entries in order (last = closest to diamond) + var offset; + if (i === 0) offset = 0; + else if (i <= usedBelow) offset = i; + else offset = -(usedBelow + usedAbove - i + 1); + var visible = (i === 0) || (i <= usedBelow) || (i <= usedBelow + usedAbove); + var yPos = hudSlotY(frameCenter, offset, tokenSize, tokenPadding); + + pin.set({ x: visible ? frameLeft : -5000, y: visible ? hudPinY(frameCenter, offset, tokenSize, tokenPadding) : -5000 }); + if (isCustom) pin.set('title', entry.custom || 'Custom'); + + if (txt) { + txt.set({ + left: frameLeft + frameWidth / 2 + (data.textOffset || 15), + top: yPos + (data.textVOffset || 0), + color: visible ? (data.textColor || defaultInitHud.textColor) : 'transparent', stroke: visible ? (data.textStroke || defaultInitHud.textStroke) : 'transparent', + text: String(entry.pr || ''), + }); + } + }); + } + + }; + + /** + * Remove the initiative HUD (programmatic). + */ + const removeInitiativeHud = () => { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData; + if (!data) return; + + var frameId = data.frameId; + var highlightId = data.highlightId; + var entries = data.entries.slice(); + // Clear IDs first so destroy handler ignores + data.frameId = null; + data.highlightId = null; + data.entries = []; + + if (frameId) { + var frame = getObj('pathv2', frameId); + if (frame) frame.remove(); + } + if (highlightId) { + var hl = getObj('pathv2', highlightId); + if (hl) hl.remove(); + } + entries.forEach(function(entry) { + var pin = getObj('pin', entry.tokenId); + var txt = getObj('text', entry.textId); + if (pin) pin.remove(); + if (txt) txt.remove(); + }); + }; + + /** + * Handle change:text — persist HUD element position/size if moved by GM. + */ + const onHudTextChanged = (obj) => { + var s = state[SCRIPT_NAME]; + var id = obj.get('id'); + if (id === s.hud.viewId) { + if (!s.hud.viewData) s.hud.viewData = {}; + var vPageId = getHudPageId(); + var vPage = vPageId ? getObj('page', vPageId) : null; + var vPageWidth = vPage ? vPage.get('width') * 70 : 1400; + s.hud.viewData.leftNorm = obj.get('left') / vPageWidth; + s.hud.viewData.top = obj.get('top'); + s.hud.viewData.fontSize = obj.get('font_size'); + s.hud.viewData.fontFamily = obj.get('font_family'); + var vColor = obj.get('color'); + if (vColor) s.hud.viewData.color = vColor; + var vStroke = obj.get('stroke'); + if (vStroke) s.hud.viewData.stroke = vStroke; + var vRotation = obj.get('rotation'); + if (vRotation !== undefined) s.hud.viewData.rotation = vRotation; + } + // Initiative HUD text moved or styled — update stored settings + if (s.hud.initiative && s.hud.initData && s.hud.initData.entries) { + var match = s.hud.initData.entries.find(function(e) { return e.textId === id; }); + if (match) { + var data = s.hud.initData; + var frame = getObj('pathv2', data.frameId); + if (frame) { + var frameLeft = frame.get('x'); + var pts = JSON.parse(frame.get('points') || '[]'); + var fw = pts.length >= 2 ? pts[1][0] - pts[0][0] : 70; + var rightEdge = frameLeft + fw / 2; + var newOffset = obj.get('left') - rightEdge; + data.textOffset = newOffset; + // Move all other texts to match offset + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('left', rightEdge + newOffset); + }); + } + // Font size/family changes + var newFontSize = obj.get('font_size'); + var newFontFamily = obj.get('font_family'); + if (newFontSize && newFontSize !== data.textFontSize) { + data.textFontSize = newFontSize; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('font_size', newFontSize); + }); + } + if (newFontFamily && newFontFamily !== data.textFontFamily) { + data.textFontFamily = newFontFamily; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('font_family', newFontFamily); + }); + } + var newColor = obj.get('color'); + if (newColor && newColor !== 'transparent' && newColor !== data.textColor) { + data.textColor = newColor; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt && otherTxt.get('color') !== 'transparent') otherTxt.set('color', newColor); + }); + } + var newStroke = obj.get('stroke'); + if (newStroke && newStroke !== data.textStroke) { + data.textStroke = newStroke; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('stroke', newStroke); + }); + } + // Rotation propagation + var newRotation = obj.get('rotation'); + if (newRotation !== undefined && newRotation !== data.textRotation) { + data.textRotation = newRotation; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('rotation', newRotation); + }); + } + // Vertical offset from slot position + var newTop = obj.get('top'); + var tokenSize = data.tokenSize || defaultInitHud.tokenSize; + var tokenPadding = data.tokenPadding || defaultInitHud.tokenPadding; + var highlight = data.highlightId ? getObj('pathv2', data.highlightId) : null; + var frame = data.frameId ? getObj('pathv2', data.frameId) : null; + if (highlight && frame) { + var hlY = highlight.get('y'); + var frameTop = frame.get('y'); + var fPts = JSON.parse(frame.get('points') || '[]'); + var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; + var vPadding = data.vPadding || defaultInitHud.vPadding; + var step = tokenSize + tokenPadding; + // Find this entry's offset in the circular order + var entryIdx = data.entries.indexOf(match); + var numEntries = data.entries.length; + var slotsBelow = 0; + while (hlY + (slotsBelow + 1) * step + tokenSize / 2 <= frameTop + fHeight / 2 - vPadding) slotsBelow++; + var entryOffset = entryIdx <= slotsBelow ? entryIdx : entryIdx - numEntries; + var slotY = hudSlotY(hlY, entryOffset, tokenSize, tokenPadding); + var vOffset = newTop - slotY; + if (Math.abs(vOffset - (data.textVOffset || 0)) > 1) { + data.textVOffset = vOffset; + data.entries.forEach(function(e) { + if (e.textId === id) return; + var otherIdx = data.entries.indexOf(e); + var otherOffset = otherIdx <= slotsBelow ? otherIdx : otherIdx - numEntries; + var otherSlotY = hudSlotY(hlY, otherOffset, tokenSize, tokenPadding); + var otherTxt = getObj('text', e.textId); + if (otherTxt) otherTxt.set('top', otherSlotY + vOffset); + }); + } + } + } + } + }; + + /** + * Handle destroy:text — if a HUD element is deleted, treat as turning it off. + */ + const onHudTextDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + var id = obj.get('id'); + if (id === s.hud.viewId) { + s.hud.view = false; + s.hud.viewId = null; + sendChat(SCRIPT_NAME, '/w gm HUD: view is now off'); + } + // Initiative HUD text + if (s.hud.initData && s.hud.initData.entries) { + var data = s.hud.initData; + var matchIdx = data.entries.findIndex(function(e) { return e.textId === id; }); + if (matchIdx !== -1) { + var match = data.entries[matchIdx]; + // Splice entry first to prevent cascading destroy handlers from re-matching + data.entries.splice(matchIdx, 1); + var pin = getObj('pin', match.tokenId); + if (pin) pin.remove(); + + // Remove from turn order + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (match.sourceId && !match.sourceId.startsWith('custom:')) { + var info = getLinkedInfo(match.sourceId); + var groupIds = new Set([match.sourceId].concat(info.linkedIds)); + order = order.filter(function(e) { return !groupIds.has(e.id); }); + } else { + var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); + if (customIdx !== -1) order.splice(customIdx, 1); + } + Campaign().set('turnorder', JSON.stringify(order)); + + reflowInitiativeHud('none'); + sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); + } + } + }; + + /** + * Handle destroy:pin — if an initiative HUD pin is deleted, remove from turn order. + */ + const onHudGraphicDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initData || !s.hud.initData.entries) return; + var id = obj.get('id'); + var data = s.hud.initData; + var matchIdx = data.entries.findIndex(function(e) { return e.tokenId === id; }); + if (matchIdx === -1) return; + + var match = data.entries[matchIdx]; + // Splice entry first — associated text will be orphaned and cleaned up on next reflow + data.entries.splice(matchIdx, 1); + var txt = getObj('text', match.textId); + if (txt) txt.remove(); + + // Remove from turn order + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + if (match.sourceId && !match.sourceId.startsWith('custom:')) { + // Token — remove it and linked children from turn order + var info = getLinkedInfo(match.sourceId); + var groupIds = new Set([match.sourceId].concat(info.linkedIds)); + order = order.filter(function(e) { return !groupIds.has(e.id); }); + } else { + // Custom turn — remove first matching custom entry + var customIdx = order.findIndex(function(e) { return !e.id || e.id === '-1'; }); + if (customIdx !== -1) order.splice(customIdx, 1); + } + Campaign().set('turnorder', JSON.stringify(order)); + + reflowInitiativeHud('none'); + sendChat(SCRIPT_NAME, '/w gm HUD: Removed entry from initiative.'); + }; + + /** + * Handle destroy:path — if the initiative frame is deleted, turn off. + */ + const onHudPathDestroyed = (obj) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initData) return; + if (obj.get('id') === s.hud.initData.frameId) { + s.hud.initiative = false; + s.hud.initData.frameId = null; + removeInitiativeHud(); + sendChat(SCRIPT_NAME, '/w gm HUD: initiative is now off'); + } else if (obj.get('id') === s.hud.initData.highlightId) { + // Highlight deleted — just clear ID, will be recreated on next update + s.hud.initData.highlightId = null; + } else if (s.hud.reticleData && obj.get('id') === s.hud.reticleData.id) { + // Turn indicator deleted — turn off reticle + s.hud.reticleData.id = null; + s.hud.reticleData.tokenId = null; + s.hud.reticle = false; + sendChat(SCRIPT_NAME, '/w gm HUD: Turn reticle disabled. Use !gaslight hud reticle on to re-enable.'); + } + }; + + /** + * Handle frame pathv2 change — resize logic, position/style tracking. + */ + const onFrameChanged = (obj, prev) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + var points = JSON.parse(obj.get('points') || '[]'); + var newWidth = points.length >= 2 ? points[1][0] - points[0][0] : 0; + var newHeight = points.length >= 2 ? points[1][1] - points[0][1] : 0; + var oldWidth = data.frameSize ? data.frameSize.width : newWidth; + + if (newWidth !== oldWidth) { + var delta = newWidth - oldWidth; + var currentHPad = data.hPadding || defaultInitHud.hPadding; + var currentTokenSize = data.tokenSize || defaultInitHud.tokenSize; + var minHPad = 5; + + if (delta < 0) { + var padReduction = Math.min(Math.abs(delta) / 2, currentHPad - minHPad); + if (padReduction > 0) { + data.hPadding = currentHPad - padReduction; + delta += padReduction * 2; + } + if (delta < 0) { + data.tokenSize = Math.max(10, currentTokenSize + delta); + } + } else { + data.hPadding = currentHPad + delta / 2; + } + } + + data.pos = { left: obj.get('x'), top: obj.get('y') }; + data.frameSize = { width: newWidth, height: newHeight }; + var stroke = obj.get('stroke'); + var fill = obj.get('fill'); + var strokeWidth = obj.get('stroke_width'); + if (stroke !== undefined) data.frameStroke = stroke; + if (fill !== undefined) data.frameFill = fill; + if (strokeWidth !== undefined) data.frameStrokeWidth = strokeWidth; + reflowInitiativeHud('none'); + }; + + /** + * Handle highlight pathv2 change — style tracking, cascade to reticle, Y offset. + */ + const onHighlightChanged = (obj, prev) => { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + var hlStroke = obj.get('stroke'); + var hlFill = obj.get('fill'); + var hlStrokeWidth = obj.get('stroke_width'); + if (hlStroke !== undefined) data.highlightStroke = hlStroke; + if (hlFill !== undefined) data.highlightFill = hlFill; + if (hlStrokeWidth !== undefined) data.highlightStrokeWidth = hlStrokeWidth; + var hlRotation = obj.get('rotation'); + if (hlRotation !== undefined) data.highlightRotation = hlRotation; + // Cascade to reticle if not overridden + var rd = s.hud.reticleData || {}; + var ti = rd.id ? getObj('pathv2', rd.id) : null; + if (ti) { + if (hlStroke !== undefined && !rd.stroke) ti.set('stroke', hlStroke); + if (hlStrokeWidth !== undefined && !rd.strokeWidth) ti.set('stroke_width', hlStrokeWidth); + if (hlRotation !== undefined) { + var reticleOffset = rd.rotationOffset != null ? rd.rotationOffset : -(data.highlightRotation != null ? data.highlightRotation : 45); + ti.set('rotation', hlRotation + reticleOffset); + } + } + // Compute normalized Y offset + var frame = getObj('pathv2', data.frameId); + if (frame) { + var fTop = frame.get('y'); + var fPts = JSON.parse(frame.get('points') || '[]'); + var fHeight = fPts.length >= 2 ? fPts[1][1] - fPts[0][1] : 510; + var tknSz = data.tokenSize || defaultInitHud.tokenSize; + var vPad = data.vPadding || defaultInitHud.vPadding; + var usableTop = fTop - fHeight / 2 + tknSz / 2 + vPad; + var usableBot = fTop + fHeight / 2 - tknSz / 2 - vPad; + var hlY = obj.get('y'); + var norm = (hlY - usableTop) / (usableBot - usableTop); + data.currentTurnOffset = Math.max(0, Math.min(1, norm)); + } + reflowInitiativeHud('none'); + }; + + /** + * Handle reticle pathv2 change — style override, size scale, position offset. + */ + const onReticleChanged = (obj, prev) => { + var s = state[SCRIPT_NAME]; + var rd = s.hud.reticleData; + if (!rd) return; + var data = s.hud.initData || {}; + var tiStroke = obj.get('stroke'); + var tiStrokeWidth = obj.get('stroke_width'); + var tiRotation = obj.get('rotation'); + if (tiStroke !== undefined) { + if (tiStroke === (data.highlightStroke || defaultInitHud.highlightStroke)) { rd.stroke = null; } + else { rd.stroke = tiStroke; } + } + if (tiStrokeWidth !== undefined) { + if (tiStrokeWidth === (data.highlightStrokeWidth || defaultInitHud.highlightStrokeWidth)) { rd.strokeWidth = null; } + else { rd.strokeWidth = tiStrokeWidth; } + } + if (tiRotation !== undefined) { + var hlRot = data.highlightRotation != null ? data.highlightRotation : 45; + rd.rotationOffset = tiRotation - hlRot; + } + // Track proportional size relative to tracked token + var tiPoints = JSON.parse(obj.get('points') || '[]'); + var prevPoints = prev && prev.points ? JSON.parse(prev.points) : tiPoints; + var wasResized = JSON.stringify(tiPoints) !== JSON.stringify(prevPoints); + if (wasResized && tiPoints.length >= 2 && rd.tokenId) { + var token = getObj('graphic', rd.tokenId); + if (token) { + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var reticleW = tiPoints[1][0] - tiPoints[0][0]; + var reticleH = tiPoints[1][1] - tiPoints[0][1]; + if (tokenW > 0) rd.widthScale = reticleW / tokenW; + if (tokenH > 0) rd.heightScale = reticleH / tokenH; + var targetX = token.get('left') + (rd.xOffset || 0) * tokenW; + var targetY = token.get('top') + (rd.yOffset || 0) * tokenH; + obj.set({ x: targetX, y: targetY }); + } + } + // Track proportional position offset from token center (move without resize) + if (!wasResized && rd.tokenId) { + var tiX = obj.get('x'); + var tiY = obj.get('y'); + var token = getObj('graphic', rd.tokenId); + if (token) { + var tokenW = token.get('width'); + var tokenH = token.get('height'); + var dx = tiX - token.get('left'); + var dy = tiY - token.get('top'); + var deadzone = 3; + var maxThreshold = Math.max(tokenW, tokenH) * 1.5; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist < deadzone) { + rd.xOffset = null; + rd.yOffset = null; + } else if (dist <= maxThreshold) { + rd.xOffset = tokenW > 0 ? dx / tokenW : 0; + rd.yOffset = tokenH > 0 ? dy / tokenH : 0; + } + } + } + }; + + /** + * Handle !gaslight hud command. + */ + const hudRegistry = { + view: { + aliases: ['relay'], + stateKey: 'viewData', + defaults: {}, + enable: function() { updateViewHud(); }, + disable: function() { removeViewHud(); }, + reset: function() { + var s = state[SCRIPT_NAME]; + s.hud.viewData = {}; + var existing = findHudElement('view'); + if (existing) { + var pageId = getHudPageId(); + var page = pageId ? getObj('page', pageId) : null; + var pageWidth = page ? page.get('width') * 70 : 1400; + existing.set({ + left: Math.round(defaultViewHud.leftNorm * pageWidth), + top: defaultViewHud.top, + font_size: defaultViewHud.fontSize, + font_family: defaultViewHud.fontFamily, + color: defaultViewHud.color, + stroke: defaultViewHud.stroke, + }); + } else { + updateViewHud(); + } + }, + ids: function() { + var s = state[SCRIPT_NAME]; + return s.hud.viewId ? [s.hud.viewId] : []; + }, + events: { + text: { change: onHudTextChanged, destroy: onHudTextDestroyed }, + }, + }, + initiative: { + aliases: ['init', 'turn', 'turns'], + stateKey: 'initData', + defaults: function() { return Object.assign({}, defaultInitHud, { entries: [] }); }, + enable: function() { updateInitiativeHud(); }, + disable: function() { removeInitiativeHud(); }, + ids: function() { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData; + if (!data) return []; + var result = []; + if (data.frameId) result.push(data.frameId); + if (data.highlightId) result.push(data.highlightId); + if (data.entries) data.entries.forEach(function(e) { + if (e.tokenId) result.push(e.tokenId); + if (e.textId) result.push(e.textId); + }); + return result; + }, + events: { + pathv2: { + change: function(obj, prev) { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData; + if (!data) return; + if (obj.get('id') === data.frameId) onFrameChanged(obj, prev); + else if (obj.get('id') === data.highlightId) onHighlightChanged(obj, prev); + }, + destroy: onHudPathDestroyed, + }, + pin: { change: null, destroy: onHudGraphicDestroyed }, + text: { change: onHudTextChanged, destroy: onHudTextDestroyed }, + }, + }, + reticle: { + aliases: ['indicator', 'current'], + stateKey: 'reticleData', + defaults: function() { + var s = state[SCRIPT_NAME]; + var data = s.hud.initData || {}; + var hlRot = data.highlightRotation != null ? data.highlightRotation : 45; + return { rotationOffset: -hlRot }; + }, + enable: function() { updateTurnReticle(); }, + disable: function() { removeTurnReticle(); }, + ids: function() { + var s = state[SCRIPT_NAME]; + return s.hud.reticleData && s.hud.reticleData.id ? [s.hud.reticleData.id] : []; + }, + events: { + pathv2: { change: onReticleChanged, destroy: onHudPathDestroyed }, + }, + }, + }; + + // Generic owns check derived from ids() + Object.keys(hudRegistry).forEach(function(key) { + var entry = hudRegistry[key]; + if (!entry.owns) { + entry.owns = function(id) { return entry.ids().indexOf(id) !== -1; }; + } + // Generic reset if not overridden: disable → reset state → enable + if (!entry.reset) { + entry.reset = function() { + var s = state[SCRIPT_NAME]; + entry.disable(); + var defs = typeof entry.defaults === 'function' ? entry.defaults() : Object.assign({}, entry.defaults); + s.hud[entry.stateKey] = defs; + entry.enable(); + }; + } + }); + + const resolveHudElement = (name) => { + if (hudRegistry[name]) return name; + for (var key in hudRegistry) { + if (hudRegistry[key].aliases && hudRegistry[key].aliases.indexOf(name) !== -1) return key; + } + return null; + }; + + const doHud = (msg, args) => { + var s = state[SCRIPT_NAME]; + var elementNames = Object.keys(hudRegistry); + var toggleWords = new Set(['on', 'off', 'reset']); + + if (args.length === 0) { + // Toggle all elements + elementNames.forEach(function(el) { + s.hud[el] = !s.hud[el]; + if (s.hud[el]) hudRegistry[el].enable(); + else hudRegistry[el].disable(); + }); + reply(msg, 'HUD', elementNames.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
        ')); + return; + } + + // Single arg that's a toggle word — apply to all elements + if (args.length === 1 && toggleWords.has(args[0].toLowerCase())) { + var toggle = args[0].toLowerCase(); + if (toggle === 'reset') { + elementNames.forEach(function(el) { + s.hud[el] = true; + hudRegistry[el].reset(); + }); + } else { + elementNames.forEach(function(el) { + s.hud[el] = (toggle === 'on'); + if (s.hud[el]) hudRegistry[el].enable(); + else hudRegistry[el].disable(); + }); + } + reply(msg, 'HUD', elementNames.map(function(el) { return '' + el + ': ' + (s.hud[el] ? 'on' : 'off'); }).join('
        ')); + return; + } + + var element = args[0].toLowerCase(); + var toggle = args[1] ? args[1].toLowerCase() : null; + + // Resolve aliases + element = resolveHudElement(element) || element; + if (toggle) toggle = resolveHudElement(toggle) || toggle; + + // Allow toggle before or after element: "hud reset initiative" or "hud initiative reset" + if (toggleWords.has(element) && toggle && hudRegistry[toggle]) { + var tmp = element; + element = toggle; + toggle = tmp; + } + + if (!hudRegistry[element]) { + reply(msg, 'Error', 'Unknown HUD element: ' + element + '. Available: ' + elementNames.join(', ')); + return; + } + + if (toggle === 'on') { + s.hud[element] = true; + hudRegistry[element].enable(); + } else if (toggle === 'off') { + s.hud[element] = false; + hudRegistry[element].disable(); + } else if (toggle === 'reset') { + s.hud[element] = true; + hudRegistry[element].reset(); + reply(msg, 'HUD', '' + element + ' reset to defaults.'); + return; + } else { + // Toggle + s.hud[element] = !s.hud[element]; + if (s.hud[element]) hudRegistry[element].enable(); + else hudRegistry[element].disable(); + } + + reply(msg, 'HUD', '' + element + ' is now ' + (s.hud[element] ? 'on' : 'off')); + }; + + 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('chat:message', function(msg) { + if (msg.type === 'api' && msg.content === '!scriptkit-ready') registerWithScriptKit(); + }); + registerWithScriptKit(); + on('add:graphic', onTokenAdded); + on('change:attribute', onAttributeChanged); + on('change:graphic', onGraphicPropChanged); + on('change:graphic:gmnotes', onGmNotesChanged); + on('change:campaign:turnorder', onTurnOrderChanged); + on('destroy:graphic', function(obj) { onTokenDestroyed(obj); }); + + // HUD registry event routing + var hudEventHandlers = {}; + Object.keys(hudRegistry).forEach(function(element) { + var events = hudRegistry[element].events; + if (!events) return; + Object.keys(events).forEach(function(objType) { + Object.keys(events[objType]).forEach(function(event) { + if (!events[objType][event]) return; + var key = event + ':' + objType; + if (!hudEventHandlers[key]) hudEventHandlers[key] = []; + hudEventHandlers[key].push({ element: element, handler: events[objType][event] }); + }); + }); + }); + Object.keys(hudEventHandlers).forEach(function(key) { + on(key, function(obj, prev) { + var id = obj.get('id'); + if (key.startsWith('change:') && _hudCreatedIds.delete(id)) return; + hudEventHandlers[key].forEach(function(entry) { + if (hudRegistry[entry.element].owns(id)) { + entry.handler(obj, prev); + } + }); + }); + }); + on('change:pin', function(obj, prev) { + var s = state[SCRIPT_NAME]; + if (!s.hud.initiative || !s.hud.initData) return; + var data = s.hud.initData; + var match = data.entries.find(function(e) { return e.tokenId === obj.get('id'); }); + if (!match) return; + + var newY = obj.get('y'); + var oldY = prev.y; + + // Check if pin escaped horizontally (swipe, not reorder) + var newX = obj.get('x'); + var frame = getObj('pathv2', data.frameId); + var horizontalEscapePin = false; + if (frame) { + var frameLeftPin = frame.get('x'); + var ptsPin = JSON.parse(frame.get('points') || '[]'); + var fwPin = ptsPin.length >= 2 ? ptsPin[1][0] - ptsPin[0][0] : 70; + horizontalEscapePin = newX > frameLeftPin + fwPin / 2 || newX < frameLeftPin - fwPin / 2; + } + + // Pin dragged vertically — reorder turn in initiative + if (!horizontalEscapePin && newY !== oldY) { + var order = JSON.parse(Campaign().get('turnorder') || '[]'); + var hudOrder = getHudTurnOrder(); + var isCustomEntry = match.sourceId && match.sourceId.startsWith('custom:'); + + // Find current index in hudOrder + var currentIdx = -1; + if (isCustomEntry) { + // For customs, find rank among customs and map to hudOrder position + var customsInData = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + var myCustomRank = customsInData.findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (myCustomRank !== -1) { + var cCount = 0; + for (var hi = 0; hi < hudOrder.length; hi++) { + if (!hudOrder[hi].id || hudOrder[hi].id === '-1') { + if (cCount === myCustomRank) { currentIdx = hi; break; } + cCount++; + } + } + } + } else { + currentIdx = hudOrder.findIndex(function(e) { return e.id === match.sourceId; }); + } + if (currentIdx < 0 || currentIdx >= hudOrder.length) { reflowInitiativeHud('none'); return; } + + // Build a map of hudOrder index → current visual Y position + var tknSize = data.tokenSize || defaultInitHud.tokenSize; + var tknPad = data.tokenPadding || defaultInitHud.tokenPadding; + var pinYByIdx = []; + hudOrder.forEach(function(entry, idx) { + var hudEntry; + if (!entry.id || entry.id === '-1') { + var customsInData2 = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }); + var cRank = hudOrder.slice(0, idx + 1).filter(function(e) { return !e.id || e.id === '-1'; }).length - 1; + hudEntry = customsInData2[cRank]; + } else { + hudEntry = data.entries.find(function(e) { return e.sourceId === entry.id; }); + } + if (hudEntry) { + var pin2 = getObj('pin', hudEntry.tokenId); + pinYByIdx.push({ idx: idx, y: pin2 ? pin2.get('y') : -5000 }); + } else { + pinYByIdx.push({ idx: idx, y: -5000 }); + } + }); + + // Sort visible pins by Y to get visual order (top to bottom) + var visiblePins = pinYByIdx.filter(function(p) { return p.y > -1000; }); + visiblePins.sort(function(a, b) { return a.y - b.y; }); + + // Minimum drag threshold + if (Math.abs(newY - oldY) < (tknSize + tknPad) / 3) { reflowInitiativeHud('none'); return; } + + // Find which two pins the dragged pin is between (excluding itself) + var others = visiblePins.filter(function(p) { return p.idx !== currentIdx; }); + if (others.length === 0) { reflowInitiativeHud('none'); return; } + + var insertAfterIdx = -1; // hudOrder idx to insert after + var insertBeforeIdx = -1; // hudOrder idx to insert before + + if (newY <= others[0].y) { + // Above all others — insert before the topmost pin + insertBeforeIdx = others[0].idx; + } else if (newY >= others[others.length - 1].y) { + // Below all others — insert after the bottommost pin + insertAfterIdx = others[others.length - 1].idx; + } else { + // Between two pins + for (var vi = 0; vi < others.length - 1; vi++) { + if (newY >= others[vi].y && newY <= others[vi + 1].y) { + insertAfterIdx = others[vi].idx; + insertBeforeIdx = others[vi + 1].idx; + break; + } + } + } + + // Skip if no valid insertion found or same position + var targetIdx = insertAfterIdx !== -1 ? insertAfterIdx : insertBeforeIdx; + if (targetIdx === -1 || targetIdx === currentIdx) { reflowInitiativeHud('none'); return; } + + if (true) { + + // Collapse turn order to one entry per combatant, preserving group info + var collapsed = []; + var groupMap = {}; // collapsed index ? array of full entries for that group + var seen = new Set(); + order.forEach(function(entry) { + if (!entry.id || entry.id === '-1') { + var ci = collapsed.length; + collapsed.push(entry); + groupMap[ci] = [entry]; + return; + } + // Find the master for this token + var masterId = entry.id; + if (!isMasterToken(entry.id)) { + var info = getLinkedInfo(entry.id); + var mid = info.linkedIds.find(function(lid) { return isMasterToken(lid); }); + if (mid) masterId = mid; + } + if (seen.has(masterId)) { + // Add to existing group + var existingIdx = collapsed.findIndex(function(e) { return e._masterId === masterId; }); + if (existingIdx !== -1) groupMap[existingIdx].push(entry); + return; + } + seen.add(masterId); + var ci = collapsed.length; + var colEntry = Object.assign({}, entry, { _masterId: masterId }); + collapsed.push(colEntry); + groupMap[ci] = [entry]; + }); + + // Find source and target in collapsed order + var colSourceIdx = collapsed.findIndex(function(e) { + if (isCustomEntry) return e.id === '-1' || !e.id; // TODO: custom rank matching + return e._masterId === match.sourceId || e.id === match.sourceId; + }); + if (colSourceIdx === -1) { reflowInitiativeHud('none'); return; } + + // Determine insertion point in collapsed order + var colTargetIdx; + if (insertAfterIdx === -1) { + // Insert before the target + colTargetIdx = collapsed.findIndex(function(e) { + var hEntry = hudOrder[insertBeforeIdx]; + if (!hEntry.id || hEntry.id === '-1') return !e.id || e.id === '-1'; + return e._masterId === hEntry.id || e.id === hEntry.id; + }); + if (colTargetIdx === -1) colTargetIdx = 0; + } else { + // Insert after the target + var afterEntry = hudOrder[insertAfterIdx]; + colTargetIdx = collapsed.findIndex(function(e) { + if (!afterEntry.id || afterEntry.id === '-1') return !e.id || e.id === '-1'; + return e._masterId === afterEntry.id || e.id === afterEntry.id; + }); + if (colTargetIdx === -1) colTargetIdx = collapsed.length - 1; + colTargetIdx++; // insert AFTER + } + + // Remove source from collapsed + var removedGroup = groupMap[colSourceIdx]; + collapsed.splice(colSourceIdx, 1); + // Adjust target index if source was before target + if (colSourceIdx < colTargetIdx) colTargetIdx--; + // Rebuild groupMap indices after removal + var newGroupMap = {}; + var gi = 0; + Object.keys(groupMap).sort(function(a, b) { return Number(a) - Number(b); }).forEach(function(key) { + if (Number(key) === colSourceIdx) return; + newGroupMap[gi] = groupMap[key]; + gi++; + }); + + // Insert at target position + collapsed.splice(colTargetIdx, 0, { _placeholder: true }); + // Rebuild final groupMap + var finalGroupMap = {}; + var fgi = 0; + Object.keys(newGroupMap).sort(function(a, b) { return Number(a) - Number(b); }).forEach(function(key) { + var idx = Number(key); + if (idx >= colTargetIdx) idx++; + finalGroupMap[idx] = newGroupMap[key]; + fgi++; + }); + finalGroupMap[colTargetIdx] = removedGroup; + + // Expand back to full order + var newOrder = []; + for (var ci = 0; ci < collapsed.length; ci++) { + var group = finalGroupMap[ci]; + if (group) group.forEach(function(e) { newOrder.push(e); }); + } + + // Clean up temp properties + newOrder.forEach(function(e) { delete e._masterId; delete e._placeholder; }); + + Campaign().set('turnorder', JSON.stringify(newOrder)); + } + } + + // Pin dragged horizontally — make it this turn's turn + var oldX = prev.x; + var verticalDrift = Math.abs(newY - oldY); + var tknSizeP = data.tokenSize || defaultInitHud.tokenSize; + if (newX !== oldX && verticalDrift < tknSizeP / 2 && horizontalEscapePin) { + var frameLeftH = frame.get('x'); + var ptsH = JSON.parse(frame.get('points') || '[]'); + var fwH = ptsH.length >= 2 ? ptsH[1][0] - ptsH[0][0] : 70; + var frameRightEdge = frameLeftH + fwH / 2; + var swipeDir = newX > frameRightEdge ? 'forward' : 'backward'; + var fullOrder = JSON.parse(Campaign().get('turnorder') || '[]'); + + // Find this entry's position in the full order + var targetFullIdx = -1; + if (match.sourceId && match.sourceId.startsWith('custom:')) { + var customsInOrder = []; + fullOrder.forEach(function(e, i) { if (!e.id || e.id === '-1') customsInOrder.push(i); }); + var customRank = data.entries.filter(function(e) { return e.sourceId && e.sourceId.startsWith('custom:'); }).findIndex(function(e) { return e.tokenId === obj.get('id'); }); + if (customRank !== -1 && customsInOrder[customRank] !== undefined) { + targetFullIdx = customsInOrder[customRank]; + } + } else { + targetFullIdx = fullOrder.findIndex(function(e) { return e.id === match.sourceId; }); + } + + if (targetFullIdx !== -1) { + if (targetFullIdx === 0) { + // This pin is the current turn — advance/retreat to next/prev master or custom + if (swipeDir === 'forward') { + fullOrder.push(fullOrder.shift()); + var skip = fullOrder.length; + while (skip-- > 0 && fullOrder[0] && fullOrder[0].id && fullOrder[0].id !== '-1') { + var cInfo = getLinkedInfo(fullOrder[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + fullOrder.push(fullOrder.shift()); + } + } else { + fullOrder.unshift(fullOrder.pop()); + var skip = fullOrder.length; + while (skip-- > 0 && fullOrder[0] && fullOrder[0].id && fullOrder[0].id !== '-1') { + var cInfo = getLinkedInfo(fullOrder[0].id); + if (cInfo.linkedIds.length === 0 || cInfo.isMaster) break; + fullOrder.unshift(fullOrder.pop()); + } + } + // Apply formula if new top is a custom + if (fullOrder[0] && fullOrder[0].id === '-1' && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } + } else { + // Non-current pin — rotate to it, applying formulas to customs passed along the way + var rotCount = swipeDir === 'forward' ? targetFullIdx : fullOrder.length - targetFullIdx; + for (var ri = 0; ri < rotCount; ri++) { + if (swipeDir === 'forward') { + fullOrder.push(fullOrder.shift()); + } else { + fullOrder.unshift(fullOrder.pop()); + } + // Apply formula if we landed on a custom turn during rotation + if (fullOrder[0] && (!fullOrder[0].id || fullOrder[0].id === '-1') && fullOrder[0].formula) { + var f = fullOrder[0].formula.trim(); + var v = parseFloat(f.replace(/^[+-]/, '')) || 0; + var add = f.startsWith('+'); + if (swipeDir === 'backward') add = !add; + fullOrder[0].pr = (parseFloat(fullOrder[0].pr) || 0) + (add ? v : -v); + } + } + } + } + Campaign().set('turnorder', JSON.stringify(fullOrder)); + } + + reflowInitiativeHud('none'); + updateTurnReticle(); + + // Scale change — adjust all HUD pins to match and update tokenSize + var newScale = obj.get('scale'); + var oldScale = prev.scale; + if (newScale !== oldScale && newScale != null) { + newScale = Math.max(0.25, Math.min(2.0, newScale)); + obj.set('scale', newScale); + data.tokenSize = Math.round(40 * newScale); + // Apply scale to all HUD entry pins + data.entries.forEach(function(e) { + var pin = getObj('pin', e.tokenId); + if (pin) pin.set('scale', newScale); + }); + reflowInitiativeHud('none'); + } + }); + + // --- TriggerMap auto-rebuild --- + + // Helper: check if a page belongs to any active group + var isActivePage = function(pageId) { + var s = state[SCRIPT_NAME]; + return Object.values(s.activeGroups).some(function(g) { + if (g.masterPageId === pageId) return true; + return Object.values(g.playerPages).some(function(p) { return p.pageId === pageId; }); + }); + }; + + // 1. Handout notes change → rebuild triggerMap from cache + on('change:handout:notes', function(obj) { + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var handoutId = obj.get('_id'); + if (knownScriptPins.some(function(p) { return p.handoutId === handoutId; })) { + rebuildTriggerMapFromCache(); + } + }); + + // 2. Pin gmnotes change → rebuild from cache if pin is known + on('change:pin:gmNotes', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { + rebuildTriggerMapFromCache(); + } + }); + + // 3. Pin link/linkType change → rebuild from cache + on('change:pin:link', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + // Update cache entry with new handout link + var pinId = obj.get('_id'); + var cached = knownScriptPins.find(function(p) { return p.pinId === pinId; }); + if (cached) { + cached.handoutId = obj.get('link') || null; + rebuildTriggerMapFromCache(); + } else if (isActivePage(obj.get('_pageid')) && obj.get('link') && obj.get('linkType') === 'handout') { + // New script pin — add to cache and rebuild + knownScriptPins.push({ pinId: pinId, pageId: obj.get('_pageid'), handoutId: obj.get('link') }); + rebuildTriggerMapFromCache(); + } + }); + on('change:pin:linkType', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (knownScriptPins.some(function(p) { return p.pinId === obj.get('_id'); })) { + rebuildTriggerMapFromCache(); + } + }); + + // 4. New pin added to active page → add to cache and rebuild + on('add:pin', function(obj) { + if (Object.keys(state[SCRIPT_NAME].activeGroups).length === 0) return; + if (!isActivePage(obj.get('_pageid'))) return; + if (obj.get('link') && obj.get('linkType') === 'handout') { + knownScriptPins.push({ pinId: obj.get('_id'), pageId: obj.get('_pageid'), handoutId: obj.get('link') }); + rebuildTriggerMapFromCache(); + } + }); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 762fbaccf..210ce91fb 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1,6 +1,6 @@ // ============================================================================= -// Gaslight v2.1.0 -// Last Updated: 2026-07-05 +// Gaslight v2.2.0 +// Last Updated: 2026-07-30 // Author: Kenan Millet // // Description: @@ -39,7 +39,7 @@ var Gaslight = Gaslight || (() => { 'use strict'; const SCRIPT_NAME = 'Gaslight'; - const SCRIPT_VERSION = '2.1.0'; + const SCRIPT_VERSION = '2.2.0'; const CMD = '!gaslight'; const CONFIG_HEADER = '---GASLIGHT---'; const LINK_KEY = 'gaslight_link'; @@ -3494,7 +3494,7 @@ var Gaslight = Gaslight || (() => { command: CMD, tag: 'GLS', aliases: { help: ['help', '--help'], man: 'man', examples: 'examples', whatsnew: 'whatsnew', genHelp: 'gen-help', genDev: 'gen-dev-docs' }, - newSince: '2.1.0', + newSince: '2.2.0', help: { description: 'Per-player map perception. Split players onto individual page copies with synchronized tokens — each player can see different things while movement stays consistent.', quickStart: [ @@ -3506,6 +3506,17 @@ var Gaslight = Gaslight || (() => { 'When done: `!gaslight merge` — tears down all links, returns players.', ], changelog: [ + { version: '2.2.0', changes: [ + 'Initiative HUD: pin-based turn tracker with gesture controls (swipe, drag-to-reorder, delete)', + 'Current turn reticle: rectangle highlighting active combatant on the map', + '`!gaslight init` command: sync/trim linked tokens in initiative', + '`--default` flags for stage, sync, desync (character-level config)', + 'Asymmetric unlink/delete: parent cascades, non-parent detaches', + 'Auto-add to initiative on stage (mid-combat staging)', + 'Interactive guide examples: getting-started, core-mechanics, initiative-hud, relay, scripting', + 'ScriptKit integration: help, man, examples, annotations', + 'GLS script fixes: compProp char-attr fallback, [default] regex, = operator', + ]}, { version: '2.1.0', changes: [ 'var command for managing gl_* variables on tokens', 'sync/desync commands for selective token sync control', diff --git a/Gaslight/README.md b/Gaslight/README.md index 875fd96cf..eb80d16f0 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -7,8 +7,9 @@ Per-player map perception for Roll20. Split players onto individual copies of a - Roll20 Pro subscription (API access required) - [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) +- [ScriptKit](https://github.com/Roll20/roll20-api-scripts/tree/master/ScriptKit) (help system, examples, guides) - [RollCapture](https://github.com/Roll20/roll20-api-scripts/tree/master/RollCapture) (optional, roll value extraction for scripting) -- [ZeroFrame] (https://github.com/Roll20/roll20-api-scripts/tree/master/ZeroFrame) (needed for the following other required scripts) +- [ZeroFrame](https://github.com/Roll20/roll20-api-scripts/tree/master/ZeroFrame) (needed for the following other required scripts) - [SelectManager](https://github.com/Roll20/roll20-api-scripts/tree/master/SelectManager) (command relay) - [APILogic](https://github.com/Roll20/roll20-api-scripts/tree/master/APILogic) (conditional branching in scripts) - [Fetch](https://github.com/Roll20/roll20-api-scripts/tree/master/Fetch) (access attributes in scripts) @@ -38,17 +39,20 @@ Per-player map perception for Roll20. Split players onto individual copies of a | `!gaslight split [--force]` | Activate group (test-first) | | `!gaslight merge [group]` | Tear down links, return players | | `!gaslight test ` | Dry-run linking resolution | -| `!gaslight link [name\|new] [ids...]` | Manually link tokens | -| `!gaslight unlink [ids...\|--group ]` | Remove links | -| `!gaslight sync [props\|all\|reset]` | Manage sync whitelist per token | -| `!gaslight desync [props\|all]` | Exclude props from sync per token | +| `!gaslight stage [--default on\|off] [players...]` | Propagate tokens to player pages | +| `!gaslight link [--default] [name\|new] [ids...]` | Manually link tokens | +| `!gaslight unlink [ids...\|--group ]` | Remove links (asymmetric: parent cascades, non-parent detaches) | +| `!gaslight sync [--default] [props\|all\|reset]` | Manage sync whitelist per token | +| `!gaslight desync [--default] [props\|all]` | Exclude props from sync per token | | `!gaslight var [--silent] [actions...]` | Read/set/unset gl_* variables | +| `!gaslight eval [--all] [--dry-run]` | Evaluate GLS scripts | | `!gaslight view [master\|off\|]` | Control command relay targeting | | `!gaslight relay ` | Relay command to views | +| `!gaslight init [sync\|trim]` | Sync initiative with HUD | +| `!gaslight hud [element] [on\|off\|reset]` | Toggle HUD elements | +| `!gaslight config [relay-add\|relay-remove\|relay-list] [cmds]` | Configure relay | | `!gaslight group ` | Assign page to group | | `!gaslight ungroup ` | Remove page from group | -| `!gaslight stage [players...]` | Propagate tokens to player pages | -| `!gaslight config [relay-add\|relay-remove\|relay-list] [cmds]` | Configure relay | | `!gaslight status` | Show state | | `!gaslight --help` | Command reference | @@ -149,9 +153,20 @@ Gaslight automatically syncs the turn order across linked tokens: - **Value sync**: Initiative value changes propagate to all linked copies - **Auto-skip**: When the turn advances to a non-master linked token, Gaslight skips forward/backward to the next master or unlinked token - **Sort-aware**: After sorting initiative, groups are reordered with master tokens on top +- **Stage sync**: Staging a token mid-combat automatically adds its linked copies to initiative The GM only interacts with master-page tokens in the turn tracker. Players see their own copies on their page. Linked children are skipped automatically when advancing turns. +### `!gaslight init` + +Sync the Roll20 turn order with the HUD. Needed after plugins add tokens to initiative (since `Campaign().set('turnorder')` is not detected automatically). + +| Command | Description | +|---------|-------------| +| `!gaslight init` | Sync + trim (default) | +| `!gaslight init sync` | Add missing linked tokens to turn order | +| `!gaslight init trim` | Remove stale entries for deleted tokens | + ## HUD On-canvas indicators on the master page foreground layer. Toggle with `!gaslight hud [element] [on|off|reset]`. @@ -162,6 +177,7 @@ On-canvas indicators on the master page foreground layer. Toggle with `!gaslight |---------|-------------| | `view` (alias: `relay`) | Shows current relay state: ALL / OFF / player name | | `initiative` (aliases: `init`, `turn`, `turns`) | Visual initiative tracker with frame, tokens, and current turn indicator | +| `reticle` (aliases: `indicator`, `current`) | Rectangle highlighting the current turn token on the map | ### Initiative HUD Features @@ -250,6 +266,24 @@ Scripts auto-detect triggers from `@(target.gl_*)` references. Override in pin G - `!gaslight eval ` — all pins linked to that handout - Add `--dry-run` to preview without executing +## Interactive Guides + +Gaslight includes interactive guide-only examples that walk you through features step by step. Access them via ScriptKit: + +`!gaslight examples` + +Available guides: +- **getting-started** - Setup, split, merge walkthrough +- **core-mechanics** - Staging, linking, syncing, token lifecycle +- **initiative-hud** - HUD gestures, customization, reticle +- **relay** - Command relay, view targeting, view HUD +- **scripting** - Build a "winds of magic" GLS script from scratch + +Applied script examples (with handout generation): +- **stealth** - Hide/show NPCs per player via passive perception +- **truesight** - Reveal true forms to viewers with truesight +- **madness** - Afflicted players see all tokens as enemies + ## License MIT diff --git a/Gaslight/script.json b/Gaslight/script.json index 1161447a4..87f19cf69 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -1,18 +1,21 @@ { "name": "Gaslight", "script": "Gaslight.js", - "version": "2.1.0", - "previousversions": ["1.0.0", "1.1.0", "2.0.0"], - "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\n**v2.1.0 — New features:**\n- Initiative tracking: linked tokens auto-added to turn order, auto-skip children on turn advance, direction-aware (forward/backward), sort-aware\n- Token-level sync control: `!gaslight sync` / `!gaslight desync` commands to manage per-token sync config\n- View off mode: `!gaslight view off` disables command relay (GM-only changes)\n- State migration from v2.0.0\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 sync [props|all|reset]` -- Manage sync whitelist per token\n- `!gaslight desync [props|all]` -- Exclude props from sync per token\n- `!gaslight view [master|off|]` -- Control command relay targeting\n- `!gaslight relay ` -- Relay command to specific views\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", + "version": "2.2.0", + "previousversions": ["1.0.0", "1.1.0", "2.0.0", "2.1.0"], + "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\n**v2.2.0 - New features:**\n- Initiative HUD: pin-based turn tracker with gestures (swipe to advance/retreat, drag to reorder, delete to remove), customizable diamond highlight, current turn reticle, frame resize/move, proportional slot distribution\n- `!gaslight init` command: sync/trim linked tokens in initiative\n- HUD registry: unified enable/disable/reset for view, initiative, reticle elements\n- `--default` flags: `stage --default on|off`, `sync --default`, `desync --default` for character-level config\n- Asymmetric unlink/delete: parent tokens cascade, non-parent copies just detach\n- Auto-add to initiative on stage: staging mid-combat adds linked copies to turn order\n- ScriptKit integration: interactive guide examples, help system, annotations\n- 5 guide-only examples: getting-started, core-mechanics, initiative-hud, relay, scripting\n- Applied script fixes: compProp resolution fallback to character attributes, regex fix for [default] syntax\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight setup [players...]` -- Quick setup from duplicate pages\n- `!gaslight stage [--default on|off]` -- Propagate tokens to player pages\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...|--group ]` -- Remove links (asymmetric)\n- `!gaslight sync [--default] [props|all|reset]` -- Manage sync config\n- `!gaslight desync [--default] [props|all]` -- Exclude props from sync\n- `!gaslight view [master|off|]` -- Control command relay targeting\n- `!gaslight relay ` -- Relay command to specific views\n- `!gaslight init [sync|trim]` -- Sync initiative with HUD\n- `!gaslight hud [element] [on|off|reset]` -- Toggle HUD elements\n- `!gaslight var [--silent] --set|--get|--del [value]` -- Manage gl_* variables\n- `!gaslight eval [--all] [--dry-run]` -- Evaluate GLS scripts\n- `!gaslight config [relay-add|relay-remove|relay-list]` -- Configure relay\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", - "dependencies": ["Anchor", "Mirror", "RollCapture", "ZeroFrame","SelectManager", "APILogic", "Fetch"], + "dependencies": ["Anchor", "Mirror", "ScriptKit", "RollCapture", "ZeroFrame", "SelectManager", "APILogic", "Fetch"], "modifies": { "graphic": "read, write", "text": "read, write", + "pathv2": "read, write", + "pin": "read, write", "character": "read", - "attribute": "read", + "attribute": "read, write", "campaign": "read, write", + "handout": "read", "page": "read" }, "conflicts": [], From 91997bd31f1be2def6a78fc719f6df482681294f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 16:18:01 -0400 Subject: [PATCH 36/37] Gaslight v2.2.0: add ScriptKit command/topic versions (init, reticle, --default flags) --- Gaslight/2.2.0/Gaslight.js | 21 +++++++++++++++++---- Gaslight/Gaslight.js | 21 +++++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Gaslight/2.2.0/Gaslight.js b/Gaslight/2.2.0/Gaslight.js index 210ce91fb..ff2267bc5 100644 --- a/Gaslight/2.2.0/Gaslight.js +++ b/Gaslight/2.2.0/Gaslight.js @@ -3554,25 +3554,30 @@ var Gaslight = Gaslight || (() => { { syntax: 'link [|new] [ids...]', description: 'Set gaslight_link on selected + explicit tokens', version: '1.0.0', details: 'Assigns a shared link ID. "new" generates a fresh ID. First non-ID arg is the link name.' }, { syntax: 'unlink [ids...|--group ]', description: 'Remove gaslight_link from tokens', version: '1.0.0' }, - { syntax: 'sync [all|reset|]', description: 'Show or modify sync config for selected tokens', version: '2.1.0', + { syntax: 'sync [--default] [all|reset|]', description: 'Show or modify sync config for selected tokens', version: '2.1.0', details: 'Overrides the token\'s gaslight_sync config without changing the character attribute. Use "reset" to re-read from the character.', items: [ { name: '(no args)', description: 'Show the current gaslight_sync config for each selected token', version: '2.1.0' }, { name: 'all', description: 'Explicitly sync all properties', version: '2.1.0' }, { name: 'reset', description: 'Re-copy sync config from the character\'s gaslight_sync attribute', version: '2.1.0' }, { name: '', description: 'Add specific properties to the sync list (comma or space separated)', version: '2.1.0' }, + { name: '--default', description: 'Apply to character attribute instead of token (new tokens inherit)', version: '2.2.0' }, ]}, - { syntax: 'desync ', description: 'Exclude properties from sync on selected tokens', version: '2.1.0', + { syntax: 'desync [--default] ', description: 'Exclude properties from sync on selected tokens', version: '2.1.0', items: [ { name: 'all', description: 'Disable all property syncing (set empty config)', version: '2.1.0' }, { name: '', description: 'Add !prop exclusions (e.g. desync tint_color bar3_value)', version: '2.1.0' }, + { name: '--default', description: 'Apply to character attribute instead of token (new tokens inherit)', version: '2.2.0' }, ]}, ]}, { group: 'Page Configuration', commands: [ { syntax: 'group ', description: 'Assign current page to a group', version: '1.0.0' }, { syntax: 'ungroup ', description: 'Remove page from group', version: '1.0.0' }, - { syntax: 'stage', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', - details: 'Select tokens on a gaslighted page. Clones to all other pages in the group with automatic linking.' }, + { syntax: 'stage [--default on|off] [players...]', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', + details: 'Select tokens on a gaslighted page. Clones to all other pages in the group with automatic linking. Use --default to set gaslight_stage on the character for auto-staging.', + items: [ + { name: '--default on|off', description: 'Set/remove gaslight_stage=1 on the character (auto-stage on placement)', version: '2.2.0' }, + ]}, { syntax: 'view [player|master|off]', description: 'Show or switch relay view target', version: '2.0.0', details: 'Controls which page(s) receive relayed commands and where var writes go. Affects auto-relay, manual relay, and var command targeting.', items: [ @@ -3598,11 +3603,19 @@ var Gaslight = Gaslight || (() => { { name: '--delch ', description: 'Delete the character attribute', version: '2.1.0' }, { name: '--silent', description: 'Don\'t trigger script re-evaluation after the change', version: '2.1.0' }, ]}, + { syntax: 'init [sync|trim]', description: 'Sync initiative turn order with the HUD', version: '2.2.0', + details: 'Run after plugins add tokens to initiative. Adds missing linked copies, removes stale entries, and refreshes the HUD.', + items: [ + { name: '(no args)', description: 'Sync + trim (default)', version: '2.2.0' }, + { name: 'sync', description: 'Add missing linked tokens to turn order', version: '2.2.0' }, + { name: 'trim', description: 'Remove entries for deleted tokens', version: '2.2.0' }, + ]}, { syntax: 'hud [] [on|off|reset]', description: 'Toggle HUD elements', version: '2.1.0', items: [ { name: '(no args)', description: 'Toggle all HUD elements on/off', version: '2.1.0' }, { name: 'view', description: 'The view/relay status indicator', version: '2.1.0' }, { name: 'initiative', description: 'The initiative tracker HUD', version: '2.1.0' }, + { name: 'reticle', description: 'Current turn indicator on the map (inherits from diamond)', version: '2.2.0' }, { name: 'on / off', description: 'Explicitly enable or disable', version: '2.1.0' }, { name: 'reset', description: 'Clear stored position and recreate from defaults', version: '2.1.0' }, ]}, diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 210ce91fb..ff2267bc5 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3554,25 +3554,30 @@ var Gaslight = Gaslight || (() => { { syntax: 'link [|new] [ids...]', description: 'Set gaslight_link on selected + explicit tokens', version: '1.0.0', details: 'Assigns a shared link ID. "new" generates a fresh ID. First non-ID arg is the link name.' }, { syntax: 'unlink [ids...|--group ]', description: 'Remove gaslight_link from tokens', version: '1.0.0' }, - { syntax: 'sync [all|reset|]', description: 'Show or modify sync config for selected tokens', version: '2.1.0', + { syntax: 'sync [--default] [all|reset|]', description: 'Show or modify sync config for selected tokens', version: '2.1.0', details: 'Overrides the token\'s gaslight_sync config without changing the character attribute. Use "reset" to re-read from the character.', items: [ { name: '(no args)', description: 'Show the current gaslight_sync config for each selected token', version: '2.1.0' }, { name: 'all', description: 'Explicitly sync all properties', version: '2.1.0' }, { name: 'reset', description: 'Re-copy sync config from the character\'s gaslight_sync attribute', version: '2.1.0' }, { name: '', description: 'Add specific properties to the sync list (comma or space separated)', version: '2.1.0' }, + { name: '--default', description: 'Apply to character attribute instead of token (new tokens inherit)', version: '2.2.0' }, ]}, - { syntax: 'desync ', description: 'Exclude properties from sync on selected tokens', version: '2.1.0', + { syntax: 'desync [--default] ', description: 'Exclude properties from sync on selected tokens', version: '2.1.0', items: [ { name: 'all', description: 'Disable all property syncing (set empty config)', version: '2.1.0' }, { name: '', description: 'Add !prop exclusions (e.g. desync tint_color bar3_value)', version: '2.1.0' }, + { name: '--default', description: 'Apply to character attribute instead of token (new tokens inherit)', version: '2.2.0' }, ]}, ]}, { group: 'Page Configuration', commands: [ { syntax: 'group ', description: 'Assign current page to a group', version: '1.0.0' }, { syntax: 'ungroup ', description: 'Remove page from group', version: '1.0.0' }, - { syntax: 'stage', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', - details: 'Select tokens on a gaslighted page. Clones to all other pages in the group with automatic linking.' }, + { syntax: 'stage [--default on|off] [players...]', description: 'Propagate selected tokens to player pages and link', version: '1.1.0', + details: 'Select tokens on a gaslighted page. Clones to all other pages in the group with automatic linking. Use --default to set gaslight_stage on the character for auto-staging.', + items: [ + { name: '--default on|off', description: 'Set/remove gaslight_stage=1 on the character (auto-stage on placement)', version: '2.2.0' }, + ]}, { syntax: 'view [player|master|off]', description: 'Show or switch relay view target', version: '2.0.0', details: 'Controls which page(s) receive relayed commands and where var writes go. Affects auto-relay, manual relay, and var command targeting.', items: [ @@ -3598,11 +3603,19 @@ var Gaslight = Gaslight || (() => { { name: '--delch ', description: 'Delete the character attribute', version: '2.1.0' }, { name: '--silent', description: 'Don\'t trigger script re-evaluation after the change', version: '2.1.0' }, ]}, + { syntax: 'init [sync|trim]', description: 'Sync initiative turn order with the HUD', version: '2.2.0', + details: 'Run after plugins add tokens to initiative. Adds missing linked copies, removes stale entries, and refreshes the HUD.', + items: [ + { name: '(no args)', description: 'Sync + trim (default)', version: '2.2.0' }, + { name: 'sync', description: 'Add missing linked tokens to turn order', version: '2.2.0' }, + { name: 'trim', description: 'Remove entries for deleted tokens', version: '2.2.0' }, + ]}, { syntax: 'hud [] [on|off|reset]', description: 'Toggle HUD elements', version: '2.1.0', items: [ { name: '(no args)', description: 'Toggle all HUD elements on/off', version: '2.1.0' }, { name: 'view', description: 'The view/relay status indicator', version: '2.1.0' }, { name: 'initiative', description: 'The initiative tracker HUD', version: '2.1.0' }, + { name: 'reticle', description: 'Current turn indicator on the map (inherits from diamond)', version: '2.2.0' }, { name: 'on / off', description: 'Explicitly enable or disable', version: '2.1.0' }, { name: 'reset', description: 'Clear stored position and recreate from defaults', version: '2.1.0' }, ]}, From 9f43016697a9a68673e63ea843b26f986f28960a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Thu, 30 Jul 2026 16:19:27 -0400 Subject: [PATCH 37/37] Gaslight v2.2.0: update HUD topic with initiative/reticle/gestures --- Gaslight/2.2.0/Gaslight.js | 2 +- Gaslight/Gaslight.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gaslight/2.2.0/Gaslight.js b/Gaslight/2.2.0/Gaslight.js index ff2267bc5..2af495024 100644 --- a/Gaslight/2.2.0/Gaslight.js +++ b/Gaslight/2.2.0/Gaslight.js @@ -3691,7 +3691,7 @@ var Gaslight = Gaslight || (() => { title: 'HUD', description: 'Persistent on-screen status display', version: '2.1.0', - body: 'The HUD shows the current Gaslight state (active group, view target, linked token count) as a persistent text element on the GM layer.\n\n`!gaslight hud on` / `!gaslight hud off` to toggle. `!gaslight hud reset` to recreate from defaults.', + body: 'The HUD provides on-screen elements on the master page foreground layer.\n\n**Elements:**\n- `view` - relay status text (ALL / OFF / player name)\n- `initiative` - pin-based turn tracker with gestures (swipe, drag-to-reorder, delete)\n- `reticle` - rectangle highlighting current turn token on the map\n\n**Gestures (initiative):**\n- Swipe pin right = next turn, left = previous turn\n- Drag pin up/down = reorder in initiative\n- Delete pin = remove from initiative\n- Resize pin = scale all pins\n- Resize frame vertically = show more/fewer slots\n- Drag diamond = change current turn position in frame\n\n`!gaslight hud on` / `off` / `reset` to control. Element-specific: `!gaslight hud init reset`.', }, troubleshooting: { title: 'Troubleshooting', diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index ff2267bc5..2af495024 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -3691,7 +3691,7 @@ var Gaslight = Gaslight || (() => { title: 'HUD', description: 'Persistent on-screen status display', version: '2.1.0', - body: 'The HUD shows the current Gaslight state (active group, view target, linked token count) as a persistent text element on the GM layer.\n\n`!gaslight hud on` / `!gaslight hud off` to toggle. `!gaslight hud reset` to recreate from defaults.', + body: 'The HUD provides on-screen elements on the master page foreground layer.\n\n**Elements:**\n- `view` - relay status text (ALL / OFF / player name)\n- `initiative` - pin-based turn tracker with gestures (swipe, drag-to-reorder, delete)\n- `reticle` - rectangle highlighting current turn token on the map\n\n**Gestures (initiative):**\n- Swipe pin right = next turn, left = previous turn\n- Drag pin up/down = reorder in initiative\n- Delete pin = remove from initiative\n- Resize pin = scale all pins\n- Resize frame vertically = show more/fewer slots\n- Drag diamond = change current turn position in frame\n\n`!gaslight hud on` / `off` / `reset` to control. Element-specific: `!gaslight hud init reset`.', }, troubleshooting: { title: 'Troubleshooting',