From 76cf7870ac1a6191a68588d7fb5714a645c923a4 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 14 Jun 2026 12:19:26 -0400 Subject: [PATCH 01/38] Gaslight v1.0.0: per-player map perception with Anchor-based sync --- Gaslight/DESIGN.md | 370 +++++++++++++++ Gaslight/Gaslight.js | 1017 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1387 insertions(+) create mode 100644 Gaslight/DESIGN.md create mode 100644 Gaslight/Gaslight.js diff --git a/Gaslight/DESIGN.md b/Gaslight/DESIGN.md new file mode 100644 index 0000000000..b7228a1c43 --- /dev/null +++ b/Gaslight/DESIGN.md @@ -0,0 +1,370 @@ +# Gaslight - Design Document + +## Concept + +Gaslight makes it easy to give each player their own perception of the same map. One command splits players onto individual copies of a page, and tokens are automatically synchronized across all copies so movement stays consistent -- but each player can see different things (different token art, different names, hidden/revealed tokens, etc.). + +## Use Cases + +- **Illusions**: One player sees a bridge, another sees empty air +- **Shapechangers**: A disguised NPC looks different to a player with truesight +- **Stealth**: A rogue is visible on their own map but absent from others +- **Madness/Hallucinations**: A player sees enemies that aren't there +- **Stealth/Perception**: A stealthing creature is invisible on most player maps, semi-transparent for a player who rolled high perception, and fully visible for a player with truesight — all on the same "map" simultaneously +- **Secrets**: An NPC whispers something -- only one player sees the speech bubble token + +## Core Features + +### 1. Page Split + +Two modes: + +**On-demand mode** (`!gaslight split`): +- Clones the current page (everything: tokens, paths, DL walls, text) once per player +- Original becomes the master page (GM stays here) +- Each player is assigned to their own copy via `playerspecificpages` +- Gaslit tokens are linked via Anchor + +**Pre-setup mode** (`!gaslight split `): +- Pages are pre-configured with gaslight group metadata (stored in page GM notes) +- Group config specifies: shared group ID, player-to-page assignments, designated master page +- One page can belong to multiple gaslight groups (different player assignments per group) +- On activation: moves party tokens to their assigned pages, sets up Anchor links +- Does NOT copy or modify the map -- assumes GM has already prepared per-player differences +- If party tokens already exist on target pages, does not duplicate them +- **Test-first behavior** (default): + - Runs linking resolution before splitting + - If errors (e.g. duplicate link IDs): blocks split, shows results, no proceed option + - If warnings/info only: shows results + a clickable `[Proceed]` button in chat + - If clean (no issues): splits immediately without prompting +- `--force` flag skips the test and splits immediately regardless of warnings/errors + +**Merge** (`!gaslight merge`): +- Returns all players to the master page +- Tears down Anchor links / peer sync +- **On-demand splits**: deletes cloned pages (they were ad-hoc) +- **Pre-setup splits**: preserves pages, only unlinks sync (GM prepared these intentionally) +- A page property (in GM notes metadata) tracks whether it is ad-hoc or pre-setup + +### 2. Token Sync + +Two sync modes, auto-detected per token based on how many players *in the active gaslight group* can control it: + +**Anchor mode** (0 or 1 controlling player in group): +- **NPC tokens (0 controllers)**: Parent on master page. GM moves on master, Anchor propagates to all player pages. +- **Single-player tokens (1 controller)**: Parent on that player's page. Player moves their token, Anchor propagates to master + other player pages. + +**Peer mode** (2+ controlling players in group): +- No Anchor parent/child relationship. Gaslight's own `change:graphic` listener handles sync. +- Movement on any page where a controller lives is authoritative and propagates to all other pages. +- Use case: shared torches, vehicles, objects multiple players can interact with. + +**GM override** (both modes): If the GM moves a token copy on the master page, Gaslight detects this and propagates to the parent (Anchor mode) or all peers (peer mode). This allows GM to move any token from master (teleportation, forced movement, etc.). + +### 3. Master Page + +- Always separate -- no player is ever assigned to the master page +- GM's control surface for the gaslit encounter +- NPC parents live here (GM moves NPCs from master) +- Player token children live here (sync from player pages, GM-movable via override) +- Future possibilities: + - Toggle views: macro buttons to cycle between player perspectives without switching pages + - Diff display: show per-player differences in the GM/foreground layer + - Staging area: set up new tokens and "commit" them to player pages + +### 4. Token Linking Resolution + +All tokens with a `represents` value (character sheet) are candidates for cross-page linking. Tokens without a character sheet are page-local and never linked. + +Linking is resolved per-token from the authoritative page (master for NPCs, player's page for player tokens) to each other page, using the following cascade: + +**Step 1: Token GM notes — `gaslight_link` ID** +If a token's GM notes contain a `gaslight_link: ` entry, it links to any other token on another page with the same `gaslight_link` ID in its GM notes. This is per-token, does not require `represents` or a character sheet at all, and works for any object type. Set manually via `!gaslight link`, or auto-populated from a `gaslight_link` character attribute when the token is placed or split runs. + +**Step 2: `represents` + `name`** +For tokens with a `represents` value: if there is exactly one token with this character+name pair on each page, link them. If a page has multiple tokens with the same `represents` + `name`, those tokens fall through to step 3. + +**Step 3: `represents` + position + bars (fingerprint)** +For tokens not resolved by steps 1-2, attempt exact match by: `represents`, `left`, `top`, `width`, `height`, `rotation`, `bar1_value`, `bar1_max`, `bar2_value`, `bar2_max`, `bar3_value`, `bar3_max`. This disambiguates duplicate creatures (e.g. multiple goblins) that were placed identically across pages. + +**Step 4: No link — warn GM** +If no unique match is found, the token is not linked. Gaslight whispers warnings to the GM with varying urgency: + +1. **Info** — A token with `gaslight_link` is missing from some (but not all) group pages. Likely intentional per-player difference. +2. **Warning** — A token with `gaslight_link` exists on only one page. Likely a setup mistake. +3. **Warning** — A `represents` token on the master page failed to link to at least one player page. Master is source of truth; unlinked master tokens are likely unintentional. +4. **Error** — Duplicate `gaslight_link` ID found on the same page. Link resolution will not work correctly for these tokens. Must be fixed. + +Suggestions for near-matches are a v2 feature. + +### Order of Operations + +Linking runs in passes across ALL tokens, not per-token: + +1. First pass: attempt step 1 (gmnotes link ID) for every token on all pages. +2. Second pass: attempt step 2 (represents + name) for every still-unlinked token. +3. Third pass: attempt step 3 (fingerprint) for every still-unlinked token. +4. Final: report step 4 warnings for anything still unlinked. + +**Critical rule**: A token that has already been linked (matched as a target in a previous step/pass) is excluded from being matched again. This prevents a single token from being claimed by multiple sources and ensures each link is unique. + +### Auto-population from character attribute + +If a character has a `gaslight_link` attribute, its value is automatically written into the `gmnotes` of any token representing that character when: +- The token is first placed on a gaslit page +- `!gaslight split` runs + +This allows GMs to set linking at the character level for simple cases (unique NPCs) while retaining per-token override for duplicates. + +### 5. Manual Linking + +`!gaslight link [|new] [--ignore-selected] [...]` + +Writes a shared `gaslight_link` ID into the GM notes of all specified/selected tokens. + +- `` — Use this as the link ID. Tokens with the same link ID across pages will be linked. +- `new` — Auto-generate a unique link ID. +- No name argument — use the existing link ID from the first token (for adding tokens to an existing link group). +- `--ignore-selected` — Skip selected tokens, only use explicit IDs. + +Examples: +- `!gaslight link goblin-shaman` — selected tokens all get `gaslight_link: goblin-shaman` +- `!gaslight link new` — selected tokens get a generated unique ID +- `!gaslight link new -AbC123 -DeF456` — explicitly link two tokens by ID + +`!gaslight unlink [--ignore-selected] [...]` — Remove the `gaslight_link` entry from tokens' GM notes. + +### 6. Test Command + +`!gaslight test ` — Dry-run the linking resolution. Reports: +- Tokens that would link (and by which step) +- Tokens that are ambiguous (with suggested matches) +- Tokens that have no match + +No state changes are made. Use before `split` to verify setup. + +### 6. Sync Properties + +**Always sync** (hardcoded): left, top, rotation, width, height + +**Configurable sync** (per-character via `gaslight_sync` attribute, comma-separated): +- `side` -- multi-sided token current side index (`currentSide`). Each page's token can have different images in its sides list but stay on the same frame index. +- `light` -- light emission (radius, dimradius, angle, otherplayers) +- `statusmarkers` -- conditions visible to all (all-or-nothing in v1) +- `bar1`, `bar2`, `bar3` -- HP/resource values visible to all +- `layer` -- if GM hides a token, hide everywhere + +**Sight rules** (hardcoded logic): +- Child/peer tokens have sight stripped by default +- Exception: sight is preserved if the parent/source has sight AND the player assigned to that page can control the character +- This ensures each player only sees through their own token's eyes, not from copies + +### 7. Lights and Torches + +Roll20 has no dedicated "light" object type -- lights are just graphic tokens with `light_radius`/`light_dimradius` properties. Gaslight treats them the same as any other token. + +Multi-controller torches (controlled by multiple players in the gaslight group) automatically use peer sync mode, allowing any of those players to move the light on their page and have it propagate. + +### 8. Reactions (Token Triggers) + +When Gaslight propagates movement, reaction tokens on non-authoritative pages would fire duplicate reactions. Gaslight suppresses this: + +**v1 (default)**: Only the authoritative page fires reactions. +- Player moves their token → reactions fire on their page only +- GM moves from master → reactions fire on master only +- On non-authoritative pages, Gaslight watches for `change:graphic:interactionTriggered` and resets it (`interactionTriggered = false`) to suppress duplicate firing + +**v2 (configurable)**: Per-reaction-token control via attribute (e.g. `gaslight_reaction`): `source-only` (default), `master-only`, `all`, `suppress`. + +**API mechanism**: The `interactionTriggered` property on graphic objects fires a `change:graphic:interactionTriggered` event when a reaction activates. Gaslight can intercept and reset this on non-authoritative pages. + +### 9. New Tokens After Split + +- Auto-commit (configurable via script.json useroptions, toggleable at runtime): + - When ON: new gaslit tokens placed on master auto-clone to player pages with Anchor links + - When OFF: GM uses `!gaslight commit` to manually push new tokens to player pages +- Non-gaslit tokens placed on any page stay local (that's the point) + +### 10. Party Detection + +Priority order: +1. Selected tokens (default) +2. Party-tagged characters (fallback -- uses Roll20's `tags` property from Define Party) +3. No further fallback -- if neither is available, error + +## Gaslight Group Config (Text object on GM layer) + +Config is stored as text objects on the GM layer of each page -- one text object per gaslight group. This keeps config physically tied to the page, visible to the GM, and portable when pages are duplicated. + +Master page format: +``` +---GASLIGHT--- +group: haunted-mansion +player: GM +``` + +Player page format: +``` +---GASLIGHT--- +group: haunted-mansion +player: Kenan Millet +playerid: -ABC123 +``` + +Storage rules: +- Text object on `gmlayer`, content starts with `---GASLIGHT---` header +- One text object per group membership (a page in two groups has two text objects) +- `player: GM` designates the master page for that group (set when arg is `GM`, `gm`, or `master`, or if the resolved player is a GM) +- For player pages: `player` stores display name (human-readable), `playerid` stores the player object ID (used for reliable lookups, handles duplicate display names) +- `adhoc` field only on master page -- indicates the group was created by on-demand split (v2) +- Commands create/update these text objects automatically +- On page duplication (manual copy): Gaslight detects duplicated config text, clears player assignment, whispers a warning to the GM +- v2: config to toggle visibility + +### Split/Merge Edge Cases + +**Adhoc merge with multi-group child page:** +- Delete the gaslight text object for the merged group only +- If no other gaslight text objects remain on the page, delete the page +- If other groups still reference the page, leave it alive + +**Adhoc split when child pages already exist for the group:** +- Auto-assign to existing pages where a matching `player:` field exists +- Create new child pages (clone from master) only for players that don't have an existing page +- Existing child pages whose `player:` is not in the current selection/party are left dormant +- GM can add dormant players to the active split by calling split again with them selected +- Currently-assigned players remain on their page (not reassigned or disrupted) + +## Commands (Draft) + +| Command | Description | +|---------|-------------| +| `!gaslight split ` | Activate group (test-first; blocks on errors, prompts on warnings) | +| `!gaslight split --force` | Activate group (skip test, split immediately) | +| `!gaslight merge [group]` | Tear down links, return players | +| `!gaslight test ` | Dry-run linking resolution, report results | +| `!gaslight link [|new] [ids...]` | Manually link tokens across pages | +| `!gaslight unlink [ids...]` | Remove gaslight_link from tokens | +| `!gaslight group ` | Assign page to group (GM/gm/master = master page) | +| `!gaslight ungroup ` | Remove page from group | +| `!gaslight status` | Show current gaslight state | +| `!gaslight --help` | Command reference | + +Player resolution for `group`/`ungroup`: +- `GM`, `gm`, `master` → designates master page +- Player display name → resolves to player object, stores name + ID +- If two players share a display name → whispers disambiguation buttons showing each player's controlled characters, GM clicks the correct one +- Buttons embed the player ID internally (users never need to type IDs) +- If arg starts with `-` (Roll20 ID format) → treated as player ID directly (used by disambiguation button callbacks) + +## Dependencies + +- **Anchor** (cross-page position sync via parent/child) + +## Architecture Notes + +- Uses `Campaign().set('playerspecificpages', {...})` to assign per-player pages +- Page duplication (on-demand): `createObj("page", {...})`, then clone all graphics/paths/text/DL/doors/windows onto it +- Anchor handles position sync for single-controller tokens (unidirectional parent→child) +- Gaslight's own `change:graphic` listener handles peer sync (multi-controller) and GM override +- Recursion guard needed for all sync listeners (flag during propagation) +- Config stored as text object on GM layer per page (searchable by `---GASLIGHT---` prefix) +- On manual page copy: detect duplicated config text, clear player fields, warn GM +- State storage: `state.Gaslight` tracks active splits, runtime config, active sync mappings +- Party detection: selected → `tags` (Define Party) → error + +## Open Questions + +(None remaining — all feasibility confirmed) + +## Confirmed Feasibility + +- ✅ Anchor works cross-page (tested) +- ✅ Pages can be created via `createObj("page", {...})` +- ✅ Text objects on GM layer can store per-page config +- ✅ `currentSide` property exists for multi-sided token sync +- ✅ `interactionTriggered` property exists for reaction suppression + +## Known Limitations + +- `imgsrc` restriction: API can only use images already in the user's Roll20 library. On-demand cloning is fine since source tokens are already uploaded. Only matters if trying to set external URLs (Gaslight won't do this). +- Page creation via API creates a blank page -- all objects must be individually cloned onto it. + +## V1 MVP Scope + +### Included + +1. **Pre-setup split** (`!gaslight split `) — activate a prepared group, assign players to their pre-configured pages, move party tokens, set up Anchor links +2. **Merge** (`!gaslight merge`) — tear down Anchor links, unassign players from pages, preserve all pages +3. **Anchor-mode sync** — NPC tokens: parent on master, children on player pages. Player tokens: parent on their own page, children on master + other player pages. +4. **GM override** — GM moves a child token on master → Gaslight propagates upstream to the parent → Anchor propagates to all other children +5. **Token linking resolution** — 4-step cascade: `gaslight_link` attribute → `represents`+name → `represents`+position+bars fingerprint → warn GM +6. **Manual linking** (`!gaslight link `) — explicit override for ambiguous tokens +7. **Test command** (`!gaslight test `) — dry-run linking, report matches/ambiguities +8. **Sight stripping** — all Anchor children have sight stripped unconditionally +9. **Config storage** — text objects on GM layer, one per group per page, `---GASLIGHT---` header +10. **Page resolution** — selected token's page (default) or `--page "name"` argument +11. **Party detection** — selected tokens (default) → party-tagged characters (fallback) → error +12. **`!gaslight group `** — assign page to group; stores player name + ID for reliable lookup; GM/gm/master designates master page +13. **`!gaslight ungroup `** — remove page from group +14. **`!gaslight status`** — show all configured and active groups +15. **`!gaslight --help`** — command reference +16. **Startup warning** — whispers to GM about dangling groups (no master) +17. **Idempotent split** — re-running split with new players adds them without disrupting existing assignments +18. **Recursion guard** — flag during propagation to prevent echo loops + +### Not Included (v2+) + +- On-demand split (page cloning) +- Peer sync mode (multi-controller tokens) +- Configurable sync properties (`gaslight_sync`) +- Reaction suppression +- Auto-commit / `!gaslight commit` +- `!gaslight link` / `!gaslight unlink` (manual attribute commands) +- `!gaslight config` (runtime settings) + +## V2+ Roadmap + +### On-Demand Split (Page Cloning) +- `!gaslight split` (no group) clones current page N times +- `!gaslight test` (no group) dry-runs an ad-hoc split from the current page, showing what would be cloned and how tokens would link +- Adds `adhoc: true` to master config text +- Merge deletes adhoc child pages (unless they belong to another group) +- Requires: clone logic for all object types (graphics, paths, text, DL walls, doors, windows) +- **Changes to v1 systems**: Merge needs to check `adhoc` flag and conditionally delete pages. Split needs a no-arg path that creates pages instead of just assigning existing ones. + +### Peer Sync Mode +- Auto-detected: if 2+ players in the active group control a token, use peer sync instead of Anchor +- Gaslight's own `change:graphic` listener propagates movement from any controller's page to all others +- No parent/child — all copies are equal peers +- **Changes to v1 systems**: Sight stripping rule becomes conditional — children in Anchor mode still get stripped, but peer tokens preserve sight if the player on that page controls the character. Split logic needs to detect multi-controller tokens and skip Anchor setup for them. + +### Configurable Sync Properties +- `gaslight_sync` character attribute (comma-separated): `side`, `light`, `statusmarkers`, `bar1`, `bar2`, `bar3`, `layer` +- `change:graphic` listener checks which properties changed and only propagates configured ones +- **Changes to v1 systems**: The sync listener (currently only handling GM override) expands to also propagate configurable properties on any authoritative change. + +### Reaction Suppression +- On non-authoritative pages, watch `change:graphic:interactionTriggered` and reset to suppress duplicate reactions +- Default: source-only (only authoritative page fires) +- Per-token override via `gaslight_reaction` attribute: `source-only`, `master-only`, `all`, `suppress` +- **Changes to v1 systems**: Adds a new event listener. No changes to existing sync logic, but needs access to the "which page is authoritative for this move" context that the sync system already tracks. + +### Auto-Commit +- Configurable via `useroptions` and `!gaslight config auto-commit on/off` +- When ON: new gaslit tokens on master auto-clone to player pages with Anchor links +- `!gaslight commit` for manual push when auto-commit is OFF +- **Changes to v1 systems**: Adds `add:graphic` listener on master page. Commit logic reuses the same token-cloning code that split uses for initial setup. + +### Link/Unlink Commands +- `!gaslight link ` — set gaslight attribute on selected tokens' characters +- `!gaslight unlink` — remove gaslight attribute +- Convenience only — GM can always set attributes manually +- **Changes to v1 systems**: None — purely additive commands. + +### Additional V2 Ideas +- Per-status-marker sync granularity +- Master page view toggling (cycle between player perspectives via macro buttons) +- Master page diff display (show per-player differences on GM layer) +- Config visibility toggle (hide gaslight text in HTML comment) +- Choreograph/Sequence integration (lifecycle hooks for gaslight events) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js new file mode 100644 index 0000000000..6bc3d43773 --- /dev/null +++ b/Gaslight/Gaslight.js @@ -0,0 +1,1017 @@ +// ============================================================================= +// Gaslight v1.0.0 +// Last Updated: 2026-06-14 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor. Each player can see different things +// while token movement stays consistent across all copies. +// +// Dependencies: Anchor +// +// Commands: +// !gaslight split Activate a prepared gaslight group +// !gaslight merge [group] Tear down links, return players +// !gaslight test Dry-run linking resolution +// !gaslight link [|new] [ids...] Set gaslight_link on tokens +// !gaslight unlink [ids...] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight master Designate page as group master +// !gaslight status Show current state +// !gaslight --help Command reference +// ============================================================================= + +/* global on, sendChat, getObj, findObjs, createObj, Campaign, playerIsGM, log, state, generateUUID */ + +var Gaslight = Gaslight || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Gaslight'; + const SCRIPT_VERSION = '1.0.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body); + }; + + const genId = () => { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + }; + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + activeGroups: {}, + config: { autoCommit: false } + }; + } + }; + + // ========================================================================= + // 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) => { + const notes = token.get('gmnotes') || ''; + const match = notes.match(/gaslight_link:\s*(.+)/); + return match ? match[1].trim() : null; + }; + + const setLinkId = (token, linkId) => { + let notes = token.get('gmnotes') || ''; + 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) => { + let notes = token.get('gmnotes') || ''; + 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. + */ + 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')); + } + }; + + // ========================================================================= + // 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 || controlledBy === 'all') return 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 parent and anchor all others as children + Object.values(linkGroups).forEach(function(tokenMap) { + var tokens = Object.values(tokenMap); + if (tokens.length < 2) return; + + // Determine parent: player-controlled -> parent on player's page; NPC -> parent on master + var parent = null; + var controllerId = null; + for (var i = 0; i < tokens.length; i++) { + controllerId = getControllingPlayerName(tokens[i], groupInfo); + if (controllerId) break; + } + + if (controllerId) { + var playerPageId = groupInfo.players[controllerId].pageId; + parent = tokens.find(function(t) { return t.get('_pageid') === playerPageId; }); + } + if (!parent) { + 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')); + stripSight(t); + if (!active.linkedTokens[parent.get('id')]) active.linkedTokens[parent.get('id')] = []; + active.linkedTokens[parent.get('id')].push(t.get('id')); + }); + }); + }; + + // ========================================================================= + // GM Override + // ========================================================================= + + var propagatingIds = new Set(); + + const onGraphicChanged = (obj) => { + if (typeof Anchor === 'undefined') return; + const tokenId = obj.get('id'); + if (propagatingIds.has(tokenId)) return; + const s = state[SCRIPT_NAME]; + const pageId = obj.get('_pageid'); + + // Only care about master pages of active groups + const activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); + if (!activeEntry) return; + + const anchor = Anchor.getAnchor(tokenId); + if (!anchor) return; + + // Parent lives on a different page — this is GM override + if (anchor.get('_pageid') === pageId) return; + + // GM moved a child on master — set parent to match child's new position + propagatingIds.add(tokenId); + anchor.set({ + left: obj.get('left'), + top: obj.get('top'), + rotation: obj.get('rotation') + }); + Anchor.updateObj(anchor); + propagatingIds.delete(tokenId); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + const doSplit = (msg, args) => { + var force = args.indexOf('--force') !== -1; + args = args.filter(function(a) { return a !== '--force'; }); + + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight split <group> [--force]'); return; } + + const groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + if (Object.keys(groupInfo.players).length === 0) { reply(msg, 'Error', 'No player pages for group "' + groupName + '".'); return; } + + // Auto-populate gaslight_link from character attributes + var allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + }); + + // Resolve links + var allLinks = []; + var unlinkWarnings = []; + Object.values(groupInfo.players).forEach(function(pInfo) { + var links = resolveLinks(groupInfo.master, pInfo.pageId); + links.forEach(function(l) { + if (l.target) allLinks.push(l); + else unlinkWarnings.push(l); + }); + }); + + // Check warnings + var globalWarnings = checkWarnings(groupInfo); + var hasErrors = globalWarnings.some(function(w) { return w.severity === 'error'; }); + var hasIssues = hasErrors || unlinkWarnings.length > 0 || globalWarnings.length > 0; + + // Test-first behavior (unless --force) + if (!force && hasIssues) { + var out = 'Split Test: ' + groupName + '
'; + out += allLinks.length + ' link(s) would be established.
'; + if (unlinkWarnings.length > 0) { + out += '
🟡 ' + unlinkWarnings.length + ' token(s) could not be linked: ' + + unlinkWarnings.map(function(w) { return w.source.get('name') || w.source.get('id'); }).join(', ') + '
'; + } + out += formatWarnings(globalWarnings); + if (hasErrors) { + out += '
Split blocked due to errors. Fix the issues above and try again.'; + } else { + out += '
[Proceed](' + CMD + ' split ' + groupName + ' --force)'; + } + reply(msg, 'Split', out); + return; + } + + // Assign players to pages + var psp = Campaign().get('playerspecificpages') || {}; + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + var player = getObj('player', playerId); + if (player) psp[playerId] = pInfo.pageId; + else reply(msg, 'Warning', 'Player "' + pInfo.name + '" (' + playerId + ') not found.'); + }); + Campaign().set('playerspecificpages', psp); + + // Establish links + establishLinks(groupName, groupInfo, allLinks); + + var summary = 'Group "' + groupName + '" activated. ' + + Object.keys(groupInfo.players).length + ' player(s), ' + + allLinks.length + ' link(s) established.'; + if (unlinkWarnings.length > 0) { + summary += '
' + unlinkWarnings.length + ' token(s) could not be linked: ' + + unlinkWarnings.map(function(w) { return w.source.get('name') || w.source.get('id'); }).join(', '); + } + summary += formatWarnings(globalWarnings); + reply(msg, 'Split', summary); + }; + + 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') { + Object.values(active.linkedTokens).forEach(function(childIds) { + childIds.forEach(function(cid) { Anchor.removeAnchor(cid); }); + }); + } + + var psp = Campaign().get('playerspecificpages') || {}; + Object.keys(active.playerPages).forEach(function(playerId) { + delete psp[playerId]; + }); + Campaign().set('playerspecificpages', Object.keys(psp).length > 0 ? psp : false); + delete s.activeGroups[gn]; + }); + + reply(msg, 'Merge', 'Merged ' + groupsToMerge.length + ' group(s). Players returned to shared page.'); + }; + + const doTest = (msg, args) => { + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight test <group>'); return; } + + const groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + + var out = 'Link Test: ' + groupName + '
'; + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + out += '
Master → ' + pInfo.name + ':
'; + var links = resolveLinks(groupInfo.master, pInfo.pageId); + links.forEach(function(l) { + var srcName = l.source.get('name') || l.source.get('id'); + if (l.target) { + var tgtName = l.target.get('name') || l.target.get('id'); + out += '✓ ' + srcName + ' → ' + tgtName + ' (step ' + l.step + ')
'; + } else { + out += '🟡 ' + srcName + ' — no match found
'; + } + }); + if (links.length === 0) out += '(no linkable tokens)
'; + }); + + // Global warnings + out += formatWarnings(checkWarnings(groupInfo)); + + reply(msg, out); + }; + + const doLink = (msg, args) => { + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--ignore-selected'; }); + + // Determine link name + var linkId; + if (args.length > 0 && args[0] === 'new') { + linkId = genId(); + args.shift(); + } else if (args.length > 0 && !args[0].startsWith('-')) { + // Check if first arg is a token ID or a link name + var maybeToken = getObj('graphic', args[0]); + if (!maybeToken) { + linkId = args.shift(); + } + } + + // Gather tokens (deduplicated by ID) + var tokenMap = {}; + if (!ignoreSelected && msg.selected) { + msg.selected.forEach(function(s) { + var obj = getObj(s._type, s._id); + if (obj) tokenMap[obj.get('id')] = obj; + }); + } + args.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj) tokenMap[obj.get('id')] = obj; + }); + var tokens = Object.values(tokenMap); + + if (tokens.length === 0) { reply(msg, 'Error', 'No tokens specified.'); return; } + + // If no linkId provided, use existing from first token or generate + if (!linkId) { + linkId = getLinkId(tokens[0]) || genId(); + } + + tokens.forEach(function(t) { setLinkId(t, linkId); }); + reply(msg, 'Link', tokens.length + ' token(s) linked as "' + linkId + '".'); + }; + + const doUnlink = (msg, args) => { + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--ignore-selected'; }); + + // Unlink entire group + var groupIdx = args.indexOf('--group'); + if (groupIdx !== -1) { + var groupName = args[groupIdx + 1]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight unlink --group <group>'); return; } + var groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + var count = 0; + var allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { + if (getLinkId(t)) { removeLinkId(t); count++; } + }); + }); + reply(msg, 'Unlink', 'Removed gaslight_link from ' + count + ' token(s) across group "' + groupName + '".'); + return; + } + + var tokens = []; + if (!ignoreSelected && msg.selected) { + msg.selected.forEach(function(s) { + var obj = getObj(s._type, s._id); + if (obj) tokens.push(obj); + }); + } + args.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj) tokens.push(obj); + }); + + if (tokens.length === 0) { reply(msg, 'Error', 'No tokens specified.'); return; } + tokens.forEach(removeLinkId); + reply(msg, 'Unlink', tokens.length + ' token(s) unlinked.'); + }; + + const logAllConfigs = () => { + setTimeout(function() { + const pages = findObjs({ _type: 'page' }); + log(SCRIPT_NAME + ': --- All Gaslight configs ---'); + pages.forEach(function(page) { + var configs = getConfigsOnPage(page.get('_id')); + if (configs.length === 0) return; + configs.forEach(function(c) { + log(SCRIPT_NAME + ': Page "' + page.get('name') + '" (' + page.get('_id') + '): ' + JSON.stringify(c.data)); + }); + }); + log(SCRIPT_NAME + ': --- End configs ---'); + }, 1000); + }; + + 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 + '.'); + logAllConfigs(); + }; + + 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 + '".'); + logAllConfigs(); + 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 + '".'); + } + logAllConfigs(); + }; + + 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) { + sendChat(SCRIPT_NAME, '/w gm ⚠️ Dangling groups with no master page: ' + + dangling.join(', ') + '. Use !gaslight ungroup <group> --all to clean up, or !gaslight master <group> to assign a master.'); + } + }; + + const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' + + '' + CMD + ' split <group> -- Activate group
' + + '' + CMD + ' merge [group] -- Tear down links
' + + '' + CMD + ' test <group> -- Dry-run linking
' + + '' + CMD + ' link [name|new] [ids...] -- Link tokens
' + + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + + '' + CMD + ' group <group> <player|GM> -- Assign page
' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' status -- Show state
' + + '' + CMD + ' --help -- This help
'; + + // ========================================================================= + // Command Router + // ========================================================================= + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (msg.content.split(' ')[0] !== CMD) return; + if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; + + const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const sub = (args.shift() || '').toLowerCase(); + + switch (sub) { + case 'split': doSplit(msg, args); break; + case 'merge': doMerge(msg, args); break; + case 'test': doTest(msg, args); break; + case 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('change:graphic:left', onGraphicChanged); + on('change:graphic:top', onGraphicChanged); + on('change:graphic:rotation', onGraphicChanged); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); From 33f4e1cfcd200c7636de17feb8e44eaa7ad8c2f3 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 14 Jun 2026 12:34:24 -0400 Subject: [PATCH 02/38] =?UTF-8?q?Gaslight:=20polish=20=E2=80=94=20remove?= =?UTF-8?q?=20debug=20logs,=20fix=20disambiguation,=20drop=20recursion=20g?= =?UTF-8?q?uard,=20improve=20startup=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/DESIGN.md | 23 ++++++++++++++++++----- Gaslight/Gaslight.js | 34 ++++++++-------------------------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/Gaslight/DESIGN.md b/Gaslight/DESIGN.md index b7228a1c43..2464f64c7a 100644 --- a/Gaslight/DESIGN.md +++ b/Gaslight/DESIGN.md @@ -147,12 +147,25 @@ No state changes are made. Use before `split` to verify setup. **Always sync** (hardcoded): left, top, rotation, width, height -**Configurable sync** (per-character via `gaslight_sync` attribute, comma-separated): -- `side` -- multi-sided token current side index (`currentSide`). Each page's token can have different images in its sides list but stay on the same frame index. +### Configurable Sync Properties + +Controlled by the `gaslight_sync` character attribute (v2): + +- **No attribute present** → sync `base` (default behavior) +- **Attribute present, empty value** → sync nothing (linked for identity tracking only, effectively excluded from sync) +- **Attribute with values** → sync only the listed properties (comma-separated) + +Available sync properties: +- `base` -- shorthand for left, top, rotation, width, height +- `left`, `top`, `rotation`, `width`, `height` -- individual position/size +- `side` -- multi-sided token current side index (`currentSide`) - `light` -- light emission (radius, dimradius, angle, otherplayers) -- `statusmarkers` -- conditions visible to all (all-or-nothing in v1) -- `bar1`, `bar2`, `bar3` -- HP/resource values visible to all -- `layer` -- if GM hides a token, hide everywhere +- `statusmarkers` -- conditions (all-or-nothing in v1; per-marker in v2+) +- `bar1`, `bar2`, `bar3` -- HP/resource values +- `layer` -- visibility layer +- `opacity` -- token opacity (baseOpacity) + +Example: `gaslight_sync = "base, light, opacity"` syncs position + light + opacity. **Sight rules** (hardcoded logic): - Child/peer tokens have sight stripped by default diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 6bc3d43773..5ccf892a9d 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -544,12 +544,9 @@ var Gaslight = Gaslight || (() => { // GM Override // ========================================================================= - var propagatingIds = new Set(); - const onGraphicChanged = (obj) => { if (typeof Anchor === 'undefined') return; const tokenId = obj.get('id'); - if (propagatingIds.has(tokenId)) return; const s = state[SCRIPT_NAME]; const pageId = obj.get('_pageid'); @@ -564,14 +561,12 @@ var Gaslight = Gaslight || (() => { if (anchor.get('_pageid') === pageId) return; // GM moved a child on master — set parent to match child's new position - propagatingIds.add(tokenId); anchor.set({ left: obj.get('left'), top: obj.get('top'), rotation: obj.get('rotation') }); Anchor.updateObj(anchor); - propagatingIds.delete(tokenId); }; // ========================================================================= @@ -791,21 +786,6 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Unlink', tokens.length + ' token(s) unlinked.'); }; - const logAllConfigs = () => { - setTimeout(function() { - const pages = findObjs({ _type: 'page' }); - log(SCRIPT_NAME + ': --- All Gaslight configs ---'); - pages.forEach(function(page) { - var configs = getConfigsOnPage(page.get('_id')); - if (configs.length === 0) return; - configs.forEach(function(c) { - log(SCRIPT_NAME + ': Page "' + page.get('name') + '" (' + page.get('_id') + '): ' + JSON.stringify(c.data)); - }); - }); - log(SCRIPT_NAME + ': --- End configs ---'); - }, 1000); - }; - const doGroup = (msg, args) => { if (args.length < 2) { reply(msg, 'Error', 'Usage: !gaslight group <group> <player|GM>'); return; } const groupName = args.shift(); @@ -825,7 +805,6 @@ var Gaslight = Gaslight || (() => { } setConfigOnPage(pageId, groupName, configData); reply(msg, 'Config', 'Page "' + pageName + '" (' + pageId + ') assigned to group "' + groupName + '" for ' + resolved.name + '.'); - logAllConfigs(); }; const doStatus = (msg) => { @@ -889,8 +868,7 @@ var Gaslight = Gaslight || (() => { if (cfg) { cfg.obj.remove(); removed++; } }); reply(msg, 'Ungroup', 'Removed all ' + removed + ' config(s) for group "' + groupName + '".'); - logAllConfigs(); - return; + return; } var playerArg = args.join(' ').replace(/^["']|["']$/g, ''); @@ -938,7 +916,6 @@ var Gaslight = Gaslight || (() => { if (!found) { reply(msg, 'Error', 'No config found for "' + playerArg + '" in group "' + groupName + '".'); } - logAllConfigs(); }; const checkDanglingGroups = () => { @@ -948,8 +925,13 @@ var Gaslight = Gaslight || (() => { if (!entry[1].master) dangling.push(entry[0]); }); if (dangling.length > 0) { - sendChat(SCRIPT_NAME, '/w gm ⚠️ Dangling groups with no master page: ' + - dangling.join(', ') + '. Use !gaslight ungroup <group> --all to clean up, or !gaslight master <group> to assign a master.'); + 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); } }; From 52f0e2c81c9c9bf80b5e6e518b61fd658032a747 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Sun, 14 Jun 2026 13:02:09 -0400 Subject: [PATCH 03/38] Gaslight: add script.json, README, TODO, versioned folder for PR --- Gaslight/1.0.0/Gaslight.js | 999 +++++++++++++++++++++++++++++++++++++ Gaslight/README.md | 74 +++ Gaslight/TODO.md | 34 ++ Gaslight/script.json | 20 + 4 files changed, 1127 insertions(+) create mode 100644 Gaslight/1.0.0/Gaslight.js create mode 100644 Gaslight/README.md create mode 100644 Gaslight/TODO.md create mode 100644 Gaslight/script.json diff --git a/Gaslight/1.0.0/Gaslight.js b/Gaslight/1.0.0/Gaslight.js new file mode 100644 index 0000000000..5ccf892a9d --- /dev/null +++ b/Gaslight/1.0.0/Gaslight.js @@ -0,0 +1,999 @@ +// ============================================================================= +// Gaslight v1.0.0 +// Last Updated: 2026-06-14 +// Author: Kenan Millet +// +// Description: +// Per-player map perception. Split players onto individual copies of a page +// with tokens synchronized via Anchor. Each player can see different things +// while token movement stays consistent across all copies. +// +// Dependencies: Anchor +// +// Commands: +// !gaslight split Activate a prepared gaslight group +// !gaslight merge [group] Tear down links, return players +// !gaslight test Dry-run linking resolution +// !gaslight link [|new] [ids...] Set gaslight_link on tokens +// !gaslight unlink [ids...] Remove gaslight_link from tokens +// !gaslight group Assign page to group +// !gaslight master Designate page as group master +// !gaslight status Show current state +// !gaslight --help Command reference +// ============================================================================= + +/* global on, sendChat, getObj, findObjs, createObj, Campaign, playerIsGM, log, state, generateUUID */ + +var Gaslight = Gaslight || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Gaslight'; + const SCRIPT_VERSION = '1.0.0'; + const CMD = '!gaslight'; + const CONFIG_HEADER = '---GASLIGHT---'; + const LINK_KEY = 'gaslight_link'; + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, tag, text) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const recipient = getPlayerName(msg.playerid); + sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body); + }; + + const genId = () => { + return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + }; + + const ensureState = () => { + if (!state[SCRIPT_NAME]) { + state[SCRIPT_NAME] = { + activeGroups: {}, + config: { autoCommit: false } + }; + } + }; + + // ========================================================================= + // 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) => { + const notes = token.get('gmnotes') || ''; + const match = notes.match(/gaslight_link:\s*(.+)/); + return match ? match[1].trim() : null; + }; + + const setLinkId = (token, linkId) => { + let notes = token.get('gmnotes') || ''; + 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) => { + let notes = token.get('gmnotes') || ''; + 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. + */ + 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')); + } + }; + + // ========================================================================= + // 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 || controlledBy === 'all') return 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 parent and anchor all others as children + Object.values(linkGroups).forEach(function(tokenMap) { + var tokens = Object.values(tokenMap); + if (tokens.length < 2) return; + + // Determine parent: player-controlled -> parent on player's page; NPC -> parent on master + var parent = null; + var controllerId = null; + for (var i = 0; i < tokens.length; i++) { + controllerId = getControllingPlayerName(tokens[i], groupInfo); + if (controllerId) break; + } + + if (controllerId) { + var playerPageId = groupInfo.players[controllerId].pageId; + parent = tokens.find(function(t) { return t.get('_pageid') === playerPageId; }); + } + if (!parent) { + 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')); + stripSight(t); + if (!active.linkedTokens[parent.get('id')]) active.linkedTokens[parent.get('id')] = []; + active.linkedTokens[parent.get('id')].push(t.get('id')); + }); + }); + }; + + // ========================================================================= + // GM Override + // ========================================================================= + + const onGraphicChanged = (obj) => { + if (typeof Anchor === 'undefined') return; + const tokenId = obj.get('id'); + const s = state[SCRIPT_NAME]; + const pageId = obj.get('_pageid'); + + // Only care about master pages of active groups + const activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); + if (!activeEntry) return; + + const anchor = Anchor.getAnchor(tokenId); + if (!anchor) return; + + // Parent lives on a different page — this is GM override + if (anchor.get('_pageid') === pageId) return; + + // GM moved a child on master — set parent to match child's new position + anchor.set({ + left: obj.get('left'), + top: obj.get('top'), + rotation: obj.get('rotation') + }); + Anchor.updateObj(anchor); + }; + + // ========================================================================= + // Commands + // ========================================================================= + + const doSplit = (msg, args) => { + var force = args.indexOf('--force') !== -1; + args = args.filter(function(a) { return a !== '--force'; }); + + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight split <group> [--force]'); return; } + + const groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + if (Object.keys(groupInfo.players).length === 0) { reply(msg, 'Error', 'No player pages for group "' + groupName + '".'); return; } + + // Auto-populate gaslight_link from character attributes + var allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + }); + + // Resolve links + var allLinks = []; + var unlinkWarnings = []; + Object.values(groupInfo.players).forEach(function(pInfo) { + var links = resolveLinks(groupInfo.master, pInfo.pageId); + links.forEach(function(l) { + if (l.target) allLinks.push(l); + else unlinkWarnings.push(l); + }); + }); + + // Check warnings + var globalWarnings = checkWarnings(groupInfo); + var hasErrors = globalWarnings.some(function(w) { return w.severity === 'error'; }); + var hasIssues = hasErrors || unlinkWarnings.length > 0 || globalWarnings.length > 0; + + // Test-first behavior (unless --force) + if (!force && hasIssues) { + var out = 'Split Test: ' + groupName + '
'; + out += allLinks.length + ' link(s) would be established.
'; + if (unlinkWarnings.length > 0) { + out += '
🟡 ' + unlinkWarnings.length + ' token(s) could not be linked: ' + + unlinkWarnings.map(function(w) { return w.source.get('name') || w.source.get('id'); }).join(', ') + '
'; + } + out += formatWarnings(globalWarnings); + if (hasErrors) { + out += '
Split blocked due to errors. Fix the issues above and try again.'; + } else { + out += '
[Proceed](' + CMD + ' split ' + groupName + ' --force)'; + } + reply(msg, 'Split', out); + return; + } + + // Assign players to pages + var psp = Campaign().get('playerspecificpages') || {}; + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + var player = getObj('player', playerId); + if (player) psp[playerId] = pInfo.pageId; + else reply(msg, 'Warning', 'Player "' + pInfo.name + '" (' + playerId + ') not found.'); + }); + Campaign().set('playerspecificpages', psp); + + // Establish links + establishLinks(groupName, groupInfo, allLinks); + + var summary = 'Group "' + groupName + '" activated. ' + + Object.keys(groupInfo.players).length + ' player(s), ' + + allLinks.length + ' link(s) established.'; + if (unlinkWarnings.length > 0) { + summary += '
' + unlinkWarnings.length + ' token(s) could not be linked: ' + + unlinkWarnings.map(function(w) { return w.source.get('name') || w.source.get('id'); }).join(', '); + } + summary += formatWarnings(globalWarnings); + reply(msg, 'Split', summary); + }; + + 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') { + Object.values(active.linkedTokens).forEach(function(childIds) { + childIds.forEach(function(cid) { Anchor.removeAnchor(cid); }); + }); + } + + var psp = Campaign().get('playerspecificpages') || {}; + Object.keys(active.playerPages).forEach(function(playerId) { + delete psp[playerId]; + }); + Campaign().set('playerspecificpages', Object.keys(psp).length > 0 ? psp : false); + delete s.activeGroups[gn]; + }); + + reply(msg, 'Merge', 'Merged ' + groupsToMerge.length + ' group(s). Players returned to shared page.'); + }; + + const doTest = (msg, args) => { + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight test <group>'); return; } + + const groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + + var out = 'Link Test: ' + groupName + '
'; + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + out += '
Master → ' + pInfo.name + ':
'; + var links = resolveLinks(groupInfo.master, pInfo.pageId); + links.forEach(function(l) { + var srcName = l.source.get('name') || l.source.get('id'); + if (l.target) { + var tgtName = l.target.get('name') || l.target.get('id'); + out += '✓ ' + srcName + ' → ' + tgtName + ' (step ' + l.step + ')
'; + } else { + out += '🟡 ' + srcName + ' — no match found
'; + } + }); + if (links.length === 0) out += '(no linkable tokens)
'; + }); + + // Global warnings + out += formatWarnings(checkWarnings(groupInfo)); + + reply(msg, out); + }; + + const doLink = (msg, args) => { + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--ignore-selected'; }); + + // Determine link name + var linkId; + if (args.length > 0 && args[0] === 'new') { + linkId = genId(); + args.shift(); + } else if (args.length > 0 && !args[0].startsWith('-')) { + // Check if first arg is a token ID or a link name + var maybeToken = getObj('graphic', args[0]); + if (!maybeToken) { + linkId = args.shift(); + } + } + + // Gather tokens (deduplicated by ID) + var tokenMap = {}; + if (!ignoreSelected && msg.selected) { + msg.selected.forEach(function(s) { + var obj = getObj(s._type, s._id); + if (obj) tokenMap[obj.get('id')] = obj; + }); + } + args.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj) tokenMap[obj.get('id')] = obj; + }); + var tokens = Object.values(tokenMap); + + if (tokens.length === 0) { reply(msg, 'Error', 'No tokens specified.'); return; } + + // If no linkId provided, use existing from first token or generate + if (!linkId) { + linkId = getLinkId(tokens[0]) || genId(); + } + + tokens.forEach(function(t) { setLinkId(t, linkId); }); + reply(msg, 'Link', tokens.length + ' token(s) linked as "' + linkId + '".'); + }; + + const doUnlink = (msg, args) => { + var ignoreSelected = args.indexOf('--ignore-selected') !== -1; + args = args.filter(function(a) { return a !== '--ignore-selected'; }); + + // Unlink entire group + var groupIdx = args.indexOf('--group'); + if (groupIdx !== -1) { + var groupName = args[groupIdx + 1]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight unlink --group <group>'); return; } + var groupInfo = discoverGroup(groupName); + if (!groupInfo.master) { reply(msg, 'Error', 'No master page for group "' + groupName + '".'); return; } + var count = 0; + var allPageIds = [groupInfo.master].concat(Object.values(groupInfo.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(function(t) { + if (getLinkId(t)) { removeLinkId(t); count++; } + }); + }); + reply(msg, 'Unlink', 'Removed gaslight_link from ' + count + ' token(s) across group "' + groupName + '".'); + return; + } + + var tokens = []; + if (!ignoreSelected && msg.selected) { + msg.selected.forEach(function(s) { + var obj = getObj(s._type, s._id); + if (obj) tokens.push(obj); + }); + } + args.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj) tokens.push(obj); + }); + + if (tokens.length === 0) { reply(msg, 'Error', 'No tokens specified.'); return; } + tokens.forEach(removeLinkId); + reply(msg, 'Unlink', tokens.length + ' token(s) unlinked.'); + }; + + const doGroup = (msg, args) => { + if (args.length < 2) { reply(msg, 'Error', 'Usage: !gaslight group <group> <player|GM>'); return; } + const groupName = args.shift(); + const playerArg = args.join(' ').replace(/^["']|["']$/g, ''); + const pageId = resolvePageId(msg, []); + const page = getObj('page', pageId); + const pageName = page ? page.get('name') : 'unknown'; + + var resolved = resolvePlayer(msg, playerArg, CMD + ' group ' + groupName); + if (!resolved || resolved === 'ambiguous') return; + + var configData; + if (resolved.id === 'GM') { + configData = { player: 'GM' }; + } else { + configData = { player: resolved.name, playerid: resolved.id }; + } + setConfigOnPage(pageId, groupName, configData); + reply(msg, 'Config', 'Page "' + pageName + '" (' + pageId + ') assigned to group "' + groupName + '" for ' + resolved.name + '.'); + }; + + const doStatus = (msg) => { + const s = state[SCRIPT_NAME]; + const groups = Object.keys(s.activeGroups); + + // Also show all configured groups (not just active) + const allGroups = discoverAllGroups(); + var out = 'Configured Groups:
'; + if (Object.keys(allGroups).length === 0) { + out += '(none)
'; + } else { + Object.entries(allGroups).forEach(function(entry) { + var gn = entry[0], info = entry[1]; + var masterName = info.master ? (getObj('page', info.master) || {get:function(){return '?';}}).get('name') : 'NO MASTER'; + var playerNames = Object.values(info.players).join(', ') || 'none'; + out += '' + gn + ': master="' + masterName + '", players=' + playerNames + + (groups.indexOf(gn) !== -1 ? ' [ACTIVE]' : '') + '
'; + }); + } + + if (groups.length > 0) { + out += '
Active Splits:
'; + groups.forEach(function(gn) { + var g = s.activeGroups[gn]; + out += '' + gn + ': ' + + Object.keys(g.playerPages).length + ' player(s), ' + + Object.keys(g.linkedTokens).length + ' parent(s)
'; + }); + } + reply(msg, out); + }; + + /** + * Discover ALL groups across all pages (not just one group). + */ + const discoverAllGroups = () => { + const pages = findObjs({ _type: 'page' }); + const groups = {}; + pages.forEach(function(page) { + var configs = getConfigsOnPage(page.get('_id')); + configs.forEach(function(c) { + var gn = c.data.group; + if (!groups[gn]) groups[gn] = { master: null, players: {} }; + if (c.data.player === 'GM') groups[gn].master = page.get('_id'); + else if (c.data.playerid) groups[gn].players[c.data.playerid] = c.data.player; + }); + }); + return groups; + }; + + const doUngroup = (msg, args) => { + const groupName = args[0]; + if (!groupName) { reply(msg, 'Error', 'Usage: !gaslight ungroup <group> <player|GM|--all>'); return; } + args = args.slice(1); + + if (args.indexOf('--all') !== -1) { + var removed = 0; + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (cfg) { cfg.obj.remove(); removed++; } + }); + reply(msg, 'Ungroup', 'Removed all ' + removed + ' config(s) for group "' + groupName + '".'); + return; + } + + var playerArg = args.join(' ').replace(/^["']|["']$/g, ''); + if (!playerArg) { reply(msg, 'Error', 'Specify a player name, GM, or --all.'); return; } + + // First try matching directly against stored player name in config + var found = false; + if (playerArg.toLowerCase() === 'gm' || playerArg.toLowerCase() === 'master') { + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (cfg && cfg.data.player === 'GM') { + cfg.obj.remove(); + found = true; + reply(msg, 'Ungroup', 'Removed GM (master) from group "' + groupName + '" (page: ' + page.get('name') + ').'); + } + }); + } else { + // Try matching by stored player name first + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg || cfg.data.player === 'GM') return; + if (cfg.data.player.toLowerCase() === playerArg.toLowerCase()) { + cfg.obj.remove(); + found = true; + reply(msg, 'Ungroup', 'Removed "' + cfg.data.player + '" from group "' + groupName + '" (page: ' + page.get('name') + ').'); + } + }); + + // If no match by stored name, try resolving as a player and match by ID + if (!found) { + var resolved = resolvePlayer(msg, playerArg, CMD + ' ungroup ' + groupName); + if (!resolved || resolved === 'ambiguous') return; + findObjs({ _type: 'page' }).forEach(function(page) { + var cfg = getGroupConfigOnPage(page.get('_id'), groupName); + if (!cfg || cfg.data.player === 'GM') return; + if (cfg.data.playerid === resolved.id) { + cfg.obj.remove(); + found = true; + reply(msg, 'Ungroup', 'Removed "' + resolved.name + '" from group "' + groupName + '" (page: ' + page.get('name') + ').'); + } + }); + } + } + + if (!found) { + reply(msg, 'Error', 'No config found for "' + playerArg + '" in group "' + groupName + '".'); + } + }; + + const checkDanglingGroups = () => { + const allGroups = discoverAllGroups(); + var dangling = []; + Object.entries(allGroups).forEach(function(entry) { + if (!entry[1].master) dangling.push(entry[0]); + }); + if (dangling.length > 0) { + var out = '⚠️ Dangling groups with no master page:
'; + dangling.forEach(function(gn) { + out += '' + gn + ': '; + out += '!gaslight ungroup ' + gn + ' --all to remove, or '; + out += '!gaslight group ' + gn + ' GM to assign a master.
'; + }); + sendChat(SCRIPT_NAME, '/w gm ' + out); + } + }; + + const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '

' + + '' + CMD + ' split <group> -- Activate group
' + + '' + CMD + ' merge [group] -- Tear down links
' + + '' + CMD + ' test <group> -- Dry-run linking
' + + '' + CMD + ' link [name|new] [ids...] -- Link tokens
' + + '' + CMD + ' unlink [ids...] -- Unlink tokens
' + + '' + CMD + ' group <group> <player|GM> -- Assign page
' + + '' + CMD + ' ungroup <group> <player|GM|--all> -- Remove config
' + + '' + CMD + ' status -- Show state
' + + '' + CMD + ' --help -- This help
'; + + // ========================================================================= + // Command Router + // ========================================================================= + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (msg.content.split(' ')[0] !== CMD) return; + if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return; + + const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean); + const sub = (args.shift() || '').toLowerCase(); + + switch (sub) { + case 'split': doSplit(msg, args); break; + case 'merge': doMerge(msg, args); break; + case 'test': doTest(msg, args); break; + case 'link': doLink(msg, args); break; + case 'unlink': doUnlink(msg, args); break; + case 'group': doGroup(msg, args); break; + case 'ungroup': doUngroup(msg, args); break; + case 'status': doStatus(msg); break; + case '--help': reply(msg, HELP_TEXT); break; + default: reply(msg, HELP_TEXT); break; + } + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + const checkInstall = () => { + ensureState(); + log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-'); + checkDanglingGroups(); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + on('change:graphic:left', onGraphicChanged); + on('change:graphic:top', onGraphicChanged); + on('change:graphic:rotation', onGraphicChanged); + }; + + return { checkInstall, registerEventHandlers }; +})(); + +on('ready', () => { + 'use strict'; + Gaslight.checkInstall(); + Gaslight.registerEventHandlers(); +}); diff --git a/Gaslight/README.md b/Gaslight/README.md new file mode 100644 index 0000000000..06a5aca1fd --- /dev/null +++ b/Gaslight/README.md @@ -0,0 +1,74 @@ +# Gaslight + +Per-player map perception for Roll20. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things while token movement stays consistent across all copies. + +## Requirements + +- Roll20 Pro subscription (API access required) +- [Anchor](https://github.com/Roll20/roll20-api-scripts/tree/master/Anchor) (cross-page position sync) + +## Use Cases + +- **Illusions**: One player sees a bridge, another sees empty air +- **Shapechangers**: A disguised NPC looks different to a player with truesight +- **Stealth/Perception**: A stealthing creature is invisible on most maps, semi-transparent for a perceptive player +- **Madness/Hallucinations**: A player sees enemies that aren't there +- **Secrets**: Information visible to only one player + +## Quick Start + +1. Create your master page and duplicate it once per player +2. On each page, select a token and assign the page to a group: + - Master: `!gaslight group mygroup GM` + - Player pages: `!gaslight group mygroup PlayerName` +3. Dry-run to verify linking: `!gaslight test mygroup` +4. Activate: `!gaslight split mygroup` +5. When done: `!gaslight merge` + +## Commands + +| Command | Description | +|---------|-------------| +| `!gaslight split ` | Activate group (test-first; blocks on errors, prompts on warnings) | +| `!gaslight split --force` | Activate group (skip test, split immediately) | +| `!gaslight merge [group]` | Tear down links, return players to shared page | +| `!gaslight test ` | Dry-run linking resolution, report results | +| `!gaslight link [name\|new] [ids...]` | Manually link tokens across pages | +| `!gaslight unlink [ids...]` | Remove gaslight_link from tokens | +| `!gaslight unlink --group ` | Remove all links in a group | +| `!gaslight group ` | Assign page to group | +| `!gaslight ungroup ` | Remove page from group | +| `!gaslight status` | Show configured and active groups | +| `!gaslight --help` | Command reference | + +## Token Linking + +Gaslight automatically links tokens across pages using a 4-step cascade: + +1. **`gaslight_link` in token GM notes** -- Explicit link ID (set via `!gaslight link` or auto-populated from character attribute). No character sheet required. +2. **`represents` + `name`** -- Unique character+name pair per page. +3. **`represents` + fingerprint** -- Position, size, rotation, and bar values for disambiguating duplicates. +4. **No match** -- Warning whispered to GM. + +After split, all linked tokens have `gaslight_link` IDs written to their GM notes for instant re-linking on future splits. + +## Sync Behavior + +- **NPC tokens** (no player controller): Parent on master page, children on player pages. GM moves NPCs from master. +- **Player tokens** (one controller in group): Parent on player's page, children on master + other pages. Player moves their own token. +- **GM override**: GM can move any token on the master page -- propagates to the parent automatically. +- **Sight**: All child tokens have sight stripped. Only the parent (on the player's own page) retains vision. + +## Configuration Storage + +Group config is stored as text objects on the GM layer of each page (visible when viewing that layer). Format: + +``` +---GASLIGHT--- +group: mygroup +player: GM +``` + +## License + +MIT diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md new file mode 100644 index 0000000000..02f2c95233 --- /dev/null +++ b/Gaslight/TODO.md @@ -0,0 +1,34 @@ +# Gaslight TODO + +## Done (v1.0.0) +- [x] Pre-setup split with test-first behavior +- [x] Merge (tear down Anchor, unassign players) +- [x] Anchor-mode sync (NPC + player tokens) +- [x] GM override (master child -> push to parent) +- [x] Token linking resolution (4-step cascade) +- [x] Manual linking (link/unlink/unlink --group) +- [x] Test command (dry-run) +- [x] Config storage (GM layer text objects with playerid) +- [x] Page resolution (selected token's page) +- [x] Party detection (selected -> tags fallback) +- [x] group/ungroup/status/--help +- [x] Startup dangling group warning +- [x] Sight stripping on children +- [x] Player disambiguation (clickable buttons) + +## v2 +- [ ] On-demand split (page cloning, adhoc flag, adhoc merge/cleanup) +- [ ] Ad-hoc test (no group arg) +- [ ] Peer sync mode (multi-controller tokens) +- [ ] Configurable sync properties (gaslight_sync attribute) +- [ ] Reaction suppression (interactionTriggered reset) +- [ ] Auto-commit / !gaslight commit +- [ ] Focus-ping players on split +- [ ] Config visibility toggle (hide text in HTML comment) +- [ ] Near-match suggestions in step 4 warnings +- [ ] Per-status-marker sync granularity +- [ ] Master page view toggling +- [ ] Choreograph/Sequence integration + +## Known Issues +- None currently diff --git a/Gaslight/script.json b/Gaslight/script.json new file mode 100644 index 0000000000..5553b332a8 --- /dev/null +++ b/Gaslight/script.json @@ -0,0 +1,20 @@ +{ + "name": "Gaslight", + "script": "Gaslight.js", + "version": "1.0.0", + "previousversions": [], + "description": "Per-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", + "authors": "Kenan Millet", + "roll20userid": "2614613", + "dependencies": ["Anchor"], + "modifies": { + "graphic": "read, write", + "text": "read, write", + "character": "read", + "attribute": "read", + "campaign": "read, write", + "page": "read" + }, + "conflicts": [], + "useroptions": [] +} From 0d7a644f74accbb86a4d488072fd87a9f87cc81e Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 07:31:24 -0400 Subject: [PATCH 04/38] Gaslight: use chainAnchorObjs for player tokens, remove GM override listener --- Gaslight/1.0.0/Gaslight.js | 84 +++++++++++++++++--------------------- Gaslight/Gaslight.js | 84 +++++++++++++++++--------------------- 2 files changed, 74 insertions(+), 94 deletions(-) diff --git a/Gaslight/1.0.0/Gaslight.js b/Gaslight/1.0.0/Gaslight.js index 5ccf892a9d..ea76e3a431 100644 --- a/Gaslight/1.0.0/Gaslight.js +++ b/Gaslight/1.0.0/Gaslight.js @@ -508,65 +508,55 @@ var Gaslight = Gaslight || (() => { linkGroups[linkId][tgt.get('id')] = tgt; }); - // For each link group, determine parent and anchor all others as children + // For each link group, determine anchoring strategy Object.values(linkGroups).forEach(function(tokenMap) { var tokens = Object.values(tokenMap); if (tokens.length < 2) return; - // Determine parent: player-controlled -> parent on player's page; NPC -> parent on master - var parent = null; + // Determine if this is a player-controlled token var controllerId = null; for (var i = 0; i < tokens.length; i++) { controllerId = getControllingPlayerName(tokens[i], groupInfo); if (controllerId) break; } + var ids = tokens.map(function(t) { return t.get('id'); }); + if (controllerId) { - var playerPageId = groupInfo.players[controllerId].pageId; - parent = tokens.find(function(t) { return t.get('_pageid') === playerPageId; }); - } - if (!parent) { - parent = tokens.find(function(t) { return t.get('_pageid') === groupInfo.master; }); + // Player token: chain-link all copies (mutual ring) + // GM can move the master copy, player can move their copy — all sync + Anchor.chainAnchorObjs(ids); + } else { + // 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')); + }); } - if (!parent) parent = tokens[0]; + // Strip sight from all tokens except the one on the controlling player's page tokens.forEach(function(t) { - if (t.get('id') === parent.get('id')) return; - Anchor.anchorObj(t.get('id'), parent.get('id')); - stripSight(t); - if (!active.linkedTokens[parent.get('id')]) active.linkedTokens[parent.get('id')] = []; - active.linkedTokens[parent.get('id')].push(t.get('id')); + if (controllerId) { + var playerPageId = groupInfo.players[controllerId].pageId; + if (t.get('_pageid') !== playerPageId) stripSight(t); + } else { + // NPC: strip sight from children (not master) + if (t.get('_pageid') !== groupInfo.master) stripSight(t); + } }); - }); - }; - // ========================================================================= - // GM Override - // ========================================================================= - - const onGraphicChanged = (obj) => { - if (typeof Anchor === 'undefined') return; - const tokenId = obj.get('id'); - const s = state[SCRIPT_NAME]; - const pageId = obj.get('_pageid'); - - // Only care about master pages of active groups - const activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); - if (!activeEntry) return; - - const anchor = Anchor.getAnchor(tokenId); - if (!anchor) return; - - // Parent lives on a different page — this is GM override - if (anchor.get('_pageid') === pageId) return; - - // GM moved a child on master — set parent to match child's new position - anchor.set({ - left: obj.get('left'), - top: obj.get('top'), - rotation: obj.get('rotation') + // 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); + }); + }); }); - Anchor.updateObj(anchor); }; // ========================================================================= @@ -659,9 +649,12 @@ var Gaslight = Gaslight || (() => { if (!active) { reply(msg, 'Warning', 'Group "' + gn + '" is not active.'); return; } if (typeof Anchor !== 'undefined') { - Object.values(active.linkedTokens).forEach(function(childIds) { - childIds.forEach(function(cid) { Anchor.removeAnchor(cid); }); + 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); }); } var psp = Campaign().get('playerspecificpages') || {}; @@ -984,9 +977,6 @@ var Gaslight = Gaslight || (() => { const registerEventHandlers = () => { on('chat:message', handleInput); - on('change:graphic:left', onGraphicChanged); - on('change:graphic:top', onGraphicChanged); - on('change:graphic:rotation', onGraphicChanged); }; return { checkInstall, registerEventHandlers }; diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 5ccf892a9d..ea76e3a431 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -508,65 +508,55 @@ var Gaslight = Gaslight || (() => { linkGroups[linkId][tgt.get('id')] = tgt; }); - // For each link group, determine parent and anchor all others as children + // For each link group, determine anchoring strategy Object.values(linkGroups).forEach(function(tokenMap) { var tokens = Object.values(tokenMap); if (tokens.length < 2) return; - // Determine parent: player-controlled -> parent on player's page; NPC -> parent on master - var parent = null; + // Determine if this is a player-controlled token var controllerId = null; for (var i = 0; i < tokens.length; i++) { controllerId = getControllingPlayerName(tokens[i], groupInfo); if (controllerId) break; } + var ids = tokens.map(function(t) { return t.get('id'); }); + if (controllerId) { - var playerPageId = groupInfo.players[controllerId].pageId; - parent = tokens.find(function(t) { return t.get('_pageid') === playerPageId; }); - } - if (!parent) { - parent = tokens.find(function(t) { return t.get('_pageid') === groupInfo.master; }); + // Player token: chain-link all copies (mutual ring) + // GM can move the master copy, player can move their copy — all sync + Anchor.chainAnchorObjs(ids); + } else { + // 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')); + }); } - if (!parent) parent = tokens[0]; + // Strip sight from all tokens except the one on the controlling player's page tokens.forEach(function(t) { - if (t.get('id') === parent.get('id')) return; - Anchor.anchorObj(t.get('id'), parent.get('id')); - stripSight(t); - if (!active.linkedTokens[parent.get('id')]) active.linkedTokens[parent.get('id')] = []; - active.linkedTokens[parent.get('id')].push(t.get('id')); + if (controllerId) { + var playerPageId = groupInfo.players[controllerId].pageId; + if (t.get('_pageid') !== playerPageId) stripSight(t); + } else { + // NPC: strip sight from children (not master) + if (t.get('_pageid') !== groupInfo.master) stripSight(t); + } }); - }); - }; - // ========================================================================= - // GM Override - // ========================================================================= - - const onGraphicChanged = (obj) => { - if (typeof Anchor === 'undefined') return; - const tokenId = obj.get('id'); - const s = state[SCRIPT_NAME]; - const pageId = obj.get('_pageid'); - - // Only care about master pages of active groups - const activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); - if (!activeEntry) return; - - const anchor = Anchor.getAnchor(tokenId); - if (!anchor) return; - - // Parent lives on a different page — this is GM override - if (anchor.get('_pageid') === pageId) return; - - // GM moved a child on master — set parent to match child's new position - anchor.set({ - left: obj.get('left'), - top: obj.get('top'), - rotation: obj.get('rotation') + // 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); + }); + }); }); - Anchor.updateObj(anchor); }; // ========================================================================= @@ -659,9 +649,12 @@ var Gaslight = Gaslight || (() => { if (!active) { reply(msg, 'Warning', 'Group "' + gn + '" is not active.'); return; } if (typeof Anchor !== 'undefined') { - Object.values(active.linkedTokens).forEach(function(childIds) { - childIds.forEach(function(cid) { Anchor.removeAnchor(cid); }); + 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); }); } var psp = Campaign().get('playerspecificpages') || {}; @@ -984,9 +977,6 @@ var Gaslight = Gaslight || (() => { const registerEventHandlers = () => { on('chat:message', handleInput); - on('change:graphic:left', onGraphicChanged); - on('change:graphic:top', onGraphicChanged); - on('change:graphic:rotation', onGraphicChanged); }; return { checkInstall, registerEventHandlers }; From 789ef7c6e89065c659ab7921d7741b1c0ebcceae Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 07:50:15 -0400 Subject: [PATCH 05/38] Gaslight: fix URL-encoded gmnotes, add peer mode for multi-controller tokens, fix 'all' controlledby --- Gaslight/1.0.0/Gaslight.js | 81 +++++++++++++++++++++++++++++--------- Gaslight/Gaslight.js | 81 +++++++++++++++++++++++++++++--------- 2 files changed, 126 insertions(+), 36 deletions(-) diff --git a/Gaslight/1.0.0/Gaslight.js b/Gaslight/1.0.0/Gaslight.js index ea76e3a431..991ab65df9 100644 --- a/Gaslight/1.0.0/Gaslight.js +++ b/Gaslight/1.0.0/Gaslight.js @@ -256,13 +256,15 @@ var Gaslight = Gaslight || (() => { // ========================================================================= const getLinkId = (token) => { - const notes = token.get('gmnotes') || ''; + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } const match = notes.match(/gaslight_link:\s*(.+)/); return match ? match[1].trim() : null; }; const setLinkId = (token, linkId) => { - let notes = token.get('gmnotes') || ''; + 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 { @@ -272,7 +274,8 @@ var Gaslight = Gaslight || (() => { }; const removeLinkId = (token) => { - let notes = token.get('gmnotes') || ''; + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} notes = notes.replace(/\n?gaslight_link:\s*.+/, '').trim(); token.set('gmnotes', notes); }; @@ -457,7 +460,12 @@ var Gaslight = Gaslight || (() => { const character = getObj('character', charId); if (!character) return null; const controlledBy = character.get('controlledby') || ''; - if (!controlledBy || controlledBy === 'all') return null; + 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]; @@ -513,20 +521,29 @@ var Gaslight = Gaslight || (() => { var tokens = Object.values(tokenMap); if (tokens.length < 2) return; - // Determine if this is a player-controlled token - var controllerId = null; + // 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++) { - controllerId = getControllingPlayerName(tokens[i], groupInfo); - if (controllerId) break; + 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'); }); - if (controllerId) { - // Player token: chain-link all copies (mutual ring) - // GM can move the master copy, player can move their copy — all sync - Anchor.chainAnchorObjs(ids); - } else { + 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]; @@ -534,16 +551,44 @@ var Gaslight = Gaslight || (() => { if (t.get('id') === parent.get('id')) return; Anchor.anchorObj(t.get('id'), parent.get('id')); }); + } else { + // Player-controlled: chain-link master + controlling players' pages + // Non-controlling player pages become children of one chain member + 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; }); + + // Chain-link the peer tokens + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds); + } + + // Non-controlling player page tokens become children of the first chain member + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id')); + }); + } } - // Strip sight from all tokens except the one on the controlling player's page + // Strip sight: only controlling players' pages keep sight tokens.forEach(function(t) { - if (controllerId) { - var playerPageId = groupInfo.players[controllerId].pageId; - if (t.get('_pageid') !== playerPageId) stripSight(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 (t.get('_pageid') !== groupInfo.master) stripSight(t); + if (pageId !== groupInfo.master) stripSight(t); } }); diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index ea76e3a431..991ab65df9 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -256,13 +256,15 @@ var Gaslight = Gaslight || (() => { // ========================================================================= const getLinkId = (token) => { - const notes = token.get('gmnotes') || ''; + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) { /* already decoded */ } const match = notes.match(/gaslight_link:\s*(.+)/); return match ? match[1].trim() : null; }; const setLinkId = (token, linkId) => { - let notes = token.get('gmnotes') || ''; + 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 { @@ -272,7 +274,8 @@ var Gaslight = Gaslight || (() => { }; const removeLinkId = (token) => { - let notes = token.get('gmnotes') || ''; + var notes = token.get('gmnotes') || ''; + try { notes = decodeURIComponent(notes); } catch(e) {} notes = notes.replace(/\n?gaslight_link:\s*.+/, '').trim(); token.set('gmnotes', notes); }; @@ -457,7 +460,12 @@ var Gaslight = Gaslight || (() => { const character = getObj('character', charId); if (!character) return null; const controlledBy = character.get('controlledby') || ''; - if (!controlledBy || controlledBy === 'all') return null; + 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]; @@ -513,20 +521,29 @@ var Gaslight = Gaslight || (() => { var tokens = Object.values(tokenMap); if (tokens.length < 2) return; - // Determine if this is a player-controlled token - var controllerId = null; + // 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++) { - controllerId = getControllingPlayerName(tokens[i], groupInfo); - if (controllerId) break; + 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'); }); - if (controllerId) { - // Player token: chain-link all copies (mutual ring) - // GM can move the master copy, player can move their copy — all sync - Anchor.chainAnchorObjs(ids); - } else { + 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]; @@ -534,16 +551,44 @@ var Gaslight = Gaslight || (() => { if (t.get('id') === parent.get('id')) return; Anchor.anchorObj(t.get('id'), parent.get('id')); }); + } else { + // Player-controlled: chain-link master + controlling players' pages + // Non-controlling player pages become children of one chain member + 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; }); + + // Chain-link the peer tokens + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds); + } + + // Non-controlling player page tokens become children of the first chain member + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id')); + }); + } } - // Strip sight from all tokens except the one on the controlling player's page + // Strip sight: only controlling players' pages keep sight tokens.forEach(function(t) { - if (controllerId) { - var playerPageId = groupInfo.players[controllerId].pageId; - if (t.get('_pageid') !== playerPageId) stripSight(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 (t.get('_pageid') !== groupInfo.master) stripSight(t); + if (pageId !== groupInfo.master) stripSight(t); } }); From abad7bf80e2b50c9482fd91abcc9a3d9fdf3097d Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 13:55:54 -0400 Subject: [PATCH 06/38] Gaslight v2: integrate Mirror for non-spatial property sync via gaslight_sync attribute --- Gaslight/Gaslight.js | 47 ++++++++++++++++++++++++++++++++++++++++++++ Gaslight/script.json | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 991ab65df9..934552da01 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -293,6 +293,34 @@ var Gaslight = Gaslight || (() => { } }; + /** + * Read the gaslight_sync character attribute. + * Returns: + * null — attribute absent (default: sync all non-spatial) + * '' — attribute present but empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (charId) => { + if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (!attr) return null; + var val = attr.get('current'); + if (val === undefined || val === null) return null; + val = val.trim(); + if (val === '') return ''; + // Parse comma-separated props, resolve groups + var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var resolved = []; + parts.forEach(function(p) { + if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[p]) { + resolved = resolved.concat(Mirror.PROP_GROUPS[p]); + } else { + resolved.push(p); + } + }); + return resolved.filter(function(p, i) { return resolved.indexOf(p) === i; }); // dedupe + }; + // ========================================================================= // Token Linking Resolution // ========================================================================= @@ -592,6 +620,17 @@ var Gaslight = Gaslight || (() => { } }); + // Set up Mirror chain for non-spatial property sync + if (typeof Mirror !== 'undefined') { + var syncProps = getGaslightSync(repCharId); + if (syncProps !== '') { + // syncProps: null = default (all minus anchor), array = specific, '' = no sync + var mirrorProps = syncProps || null; // null = api-all (minus anchor via exclude) + var mirrorExcludes = syncProps ? [] : Mirror.PROP_GROUPS.anchor; + Mirror.chainLink(ids, mirrorProps, mirrorExcludes); + } + } + // Track links for merge teardown ids.forEach(function(id) { if (!active.linkedTokens[id]) active.linkedTokens[id] = []; @@ -701,6 +740,14 @@ var Gaslight = Gaslight || (() => { }); allLinkedIds.forEach(function(id) { Anchor.removeAnchor(id); }); } + if (typeof Mirror !== 'undefined') { + var allIds = new Set(); + Object.keys(active.linkedTokens).forEach(function(id) { allIds.add(id); }); + Object.values(active.linkedTokens).forEach(function(ids) { + ids.forEach(function(id) { allIds.add(id); }); + }); + allIds.forEach(function(id) { Mirror.unlink([id]); }); + } var psp = Campaign().get('playerspecificpages') || {}; Object.keys(active.playerPages).forEach(function(playerId) { diff --git a/Gaslight/script.json b/Gaslight/script.json index 5553b332a8..2ca8524ecf 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -6,7 +6,7 @@ "description": "Per-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", - "dependencies": ["Anchor"], + "dependencies": ["Anchor", "Mirror"], "modifies": { "graphic": "read, write", "text": "read, write", From ca351984a7f645305a458bb72f7ecede7ee8daef Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 14:00:29 -0400 Subject: [PATCH 07/38] Gaslight v2: treat 'base' and 'anchor' as equivalent in gaslight_sync, fix Anchor/Mirror branching --- Gaslight/Gaslight.js | 94 +++++++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 934552da01..723bf7d3d4 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -312,7 +312,9 @@ var Gaslight = Gaslight || (() => { var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); var resolved = []; parts.forEach(function(p) { - if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[p]) { + if (p === 'base' || p === 'anchor') { + resolved = resolved.concat(['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']); + } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[p]) { resolved = resolved.concat(Mirror.PROP_GROUPS[p]); } else { resolved.push(p); @@ -571,37 +573,57 @@ var Gaslight = Gaslight || (() => { var ids = tokens.map(function(t) { return t.get('id'); }); - 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')); - }); - } else { - // Player-controlled: chain-link master + controlling players' pages - // Non-controlling player pages become children of one chain member - var chainPageIds = [groupInfo.master]; - controllerIds.forEach(function(pid) { - if (groupInfo.players[pid]) chainPageIds.push(groupInfo.players[pid].pageId); - }); + // Check gaslight_sync attribute + var syncProps = getGaslightSync(repCharId); + // syncProps: null = default (base spatial), '' = no sync at all, array = specific + + // If empty string, skip all linking for this group + if (syncProps === '') return; + + // Determine which props go to Anchor vs Mirror + var anchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + var needsAnchor = true; + var mirrorProps = null; // null = all non-anchor + if (Array.isArray(syncProps)) { + // Specific props: split between anchor and mirror + var anchorRequested = syncProps.filter(function(p) { return anchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return anchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0 || syncProps.indexOf('base') !== -1; + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : null; + if (mirrorRequested.length === 0 && anchorRequested.length > 0) mirrorProps = false; // no mirror needed + } - 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; }); + // 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')); + }); + } 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); + }); - // Chain-link the peer tokens - var chainIds = chainTokens.map(function(t) { return t.get('id'); }); - if (chainIds.length >= 2) { - Anchor.chainAnchorObjs(chainIds); - } + 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; }); - // Non-controlling player page tokens become children of the first chain member - if (childTokens.length > 0 && chainTokens.length > 0) { - var chainParent = chainTokens[0]; - childTokens.forEach(function(t) { - Anchor.anchorObj(t.get('id'), chainParent.get('id')); - }); + var chainIds = chainTokens.map(function(t) { return t.get('id'); }); + if (chainIds.length >= 2) { + Anchor.chainAnchorObjs(chainIds); + } + + if (childTokens.length > 0 && chainTokens.length > 0) { + var chainParent = chainTokens[0]; + childTokens.forEach(function(t) { + Anchor.anchorObj(t.get('id'), chainParent.get('id')); + }); + } } } @@ -621,13 +643,13 @@ var Gaslight = Gaslight || (() => { }); // Set up Mirror chain for non-spatial property sync - if (typeof Mirror !== 'undefined') { - var syncProps = getGaslightSync(repCharId); - if (syncProps !== '') { - // syncProps: null = default (all minus anchor), array = specific, '' = no sync - var mirrorProps = syncProps || null; // null = api-all (minus anchor via exclude) - var mirrorExcludes = syncProps ? [] : Mirror.PROP_GROUPS.anchor; - Mirror.chainLink(ids, mirrorProps, mirrorExcludes); + if (typeof Mirror !== 'undefined' && mirrorProps !== false) { + if (mirrorProps === null) { + // Default: sync all minus anchor props + Mirror.chainLink(ids, null, Mirror.PROP_GROUPS.anchor); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props + Mirror.chainLink(ids, mirrorProps); } } From 26d4b235d365ce743a673415bc9a4a374ee9a523 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 14:02:33 -0400 Subject: [PATCH 08/38] Gaslight v2: pass specific Anchor components from gaslight_sync (supports individual spatial props, layer) --- Gaslight/Gaslight.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 723bf7d3d4..8dbb822431 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -581,16 +581,20 @@ var Gaslight = Gaslight || (() => { if (syncProps === '') return; // Determine which props go to Anchor vs Mirror - var anchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; var needsAnchor = true; + var anchorComponents = null; // null = use Anchor defaults var mirrorProps = null; // null = all non-anchor if (Array.isArray(syncProps)) { - // Specific props: split between anchor and mirror - var anchorRequested = syncProps.filter(function(p) { return anchorProps.indexOf(p) !== -1; }); - var mirrorRequested = syncProps.filter(function(p) { return anchorProps.indexOf(p) === -1; }); - needsAnchor = anchorRequested.length > 0 || syncProps.indexOf('base') !== -1; - mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : null; - if (mirrorRequested.length === 0 && anchorRequested.length > 0) mirrorProps = false; // no mirror needed + var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0; + // Pass specific components to Anchor if not the full default set + if (needsAnchor) { + anchorComponents = {}; + anchorRequested.forEach(function(p) { anchorComponents[p] = true; }); + } + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; } // Set up Anchor links (spatial sync) @@ -601,7 +605,7 @@ var Gaslight = Gaslight || (() => { 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')); + Anchor.anchorObj(t.get('id'), parent.get('id'), anchorComponents); }); } else { // Player-controlled: chain-link master + controlling players' pages @@ -615,13 +619,13 @@ var Gaslight = Gaslight || (() => { var chainIds = chainTokens.map(function(t) { return t.get('id'); }); if (chainIds.length >= 2) { - Anchor.chainAnchorObjs(chainIds); + 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')); + Anchor.anchorObj(t.get('id'), chainParent.get('id'), anchorComponents); }); } } From b5bee07d2b4dc1c055afd20a3ba5d9907b795dd6 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 14:08:20 -0400 Subject: [PATCH 09/38] Gaslight v2: support ! exclusion prefix in gaslight_sync, Mirror excludes match Anchor scope --- Gaslight/Gaslight.js | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 8dbb822431..d0d5fce5ff 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -309,17 +309,35 @@ var Gaslight = Gaslight || (() => { val = val.trim(); if (val === '') return ''; // Parse comma-separated props, resolve groups + // Prefix with ! to exclude (e.g. "!anchor" = everything except anchor props) var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); - var resolved = []; + var includes = []; + var excludes = []; parts.forEach(function(p) { - if (p === 'base' || p === 'anchor') { - resolved = resolved.concat(['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']); - } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[p]) { - resolved = resolved.concat(Mirror.PROP_GROUPS[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 { - resolved.push(p); + 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 }; @@ -649,8 +667,9 @@ var Gaslight = Gaslight || (() => { // Set up Mirror chain for non-spatial property sync if (typeof Mirror !== 'undefined' && mirrorProps !== false) { if (mirrorProps === null) { - // Default: sync all minus anchor props - Mirror.chainLink(ids, null, Mirror.PROP_GROUPS.anchor); + // Default: sync all minus whatever Anchor is handling + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + Mirror.chainLink(ids, null, mirrorExcludes); } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { // Specific non-spatial props Mirror.chainLink(ids, mirrorProps); From 7cbe15d7276bb455c3d7af4a28d67fc34f6b5a25 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 14:37:18 -0400 Subject: [PATCH 10/38] Gaslight v2: add !gaslight stage command and gaslight_stage auto-propagation on add:graphic --- Gaslight/Gaslight.js | 140 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index d0d5fce5ff..b233b0a9b1 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -937,6 +937,144 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Config', 'Page "' + pageName + '" (' + pageId + ') assigned to group "' + groupName + '" for ' + resolved.name + '.'); }; + /** + * Stage selected tokens: duplicate to player pages and link. + * !gaslight stage [playerName1 playerName2 ...] + */ + const doStage = (msg, args) => { + var s = state[SCRIPT_NAME]; + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) to stage.'); return; } + + // Find which active group this page belongs to + var pageId = tokens[0].get('_pageid'); + var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId || Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); }); + if (!activeEntry) { reply(msg, 'Error', 'Token is not on an active gaslit page.'); return; } + var groupName = activeEntry[0]; + var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; + + // Determine target players + var targetPlayerIds = []; + if (args.length > 0) { + args.forEach(function(name) { + var resolved = Object.entries(groupInfo.players).find(function(e) { + return e[1].name && e[1].name.toLowerCase() === name.toLowerCase(); + }); + if (resolved) targetPlayerIds.push(resolved[0]); + else reply(msg, 'Warning', 'Player "' + name + '" not found in group.'); + }); + } else { + targetPlayerIds = Object.keys(groupInfo.players); + } + + if (targetPlayerIds.length === 0) { reply(msg, 'Error', 'No valid target players.'); return; } + + var staged = 0; + tokens.forEach(function(token) { + targetPlayerIds.forEach(function(pid) { + var targetPageId = groupInfo.players[pid].pageId; + // Check if already exists on target page + var existing = findMatchingToken(token, targetPageId); + if (existing) return; + // Clone token to target page + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + staged++; + }); + }); + + // Re-run linking for this group to pick up the new tokens + if (staged > 0) { + var groupDiscovered = discoverGroup(groupName); + var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + }); + var allLinks = []; + Object.values(groupDiscovered.players).forEach(function(pInfo) { + var links = resolveLinks(groupDiscovered.master, pInfo.pageId); + links.forEach(function(l) { if (l.target) allLinks.push(l); }); + }); + establishLinks(groupName, groupDiscovered, allLinks); + } + + reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); + }; + + /** + * Auto-stage: when a token is added to a gaslit page and its character has gaslight_stage=1. + */ + const onTokenAdded = (obj) => { + var s = state[SCRIPT_NAME]; + var charId = obj.get('represents'); + if (!charId) return; + + // Check gaslight_stage attribute + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_stage' })[0]; + if (!attr || attr.get('current') !== '1') return; + + // Find which active group this page belongs to + var pageId = obj.get('_pageid'); + var activeEntry = Object.entries(s.activeGroups).find(function(e) { + return e[1].masterPageId === pageId; + }); + if (!activeEntry) return; // only auto-stage from master page + + var groupName = activeEntry[0]; + var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; + + // Clone to all player pages + Object.values(groupInfo.players).forEach(function(pInfo) { + var existing = findMatchingToken(obj, pInfo.pageId); + if (existing) return; + var imgsrc = obj.get('imgsrc'); + if (!imgsrc) return; + createObj('graphic', { + _subtype: 'token', + pageid: pInfo.pageId, + imgsrc: imgsrc, + left: obj.get('left'), + top: obj.get('top'), + width: obj.get('width'), + height: obj.get('height'), + rotation: obj.get('rotation'), + layer: obj.get('layer'), + name: obj.get('name'), + represents: charId, + controlledby: obj.get('controlledby') || '' + }); + }); + + // Re-link after a short delay to let createObj finish + setTimeout(function() { + var groupDiscovered = discoverGroup(groupName); + var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + }); + var allLinks = []; + Object.values(groupDiscovered.players).forEach(function(pInfo) { + var links = resolveLinks(groupDiscovered.master, pInfo.pageId); + links.forEach(function(l) { if (l.target) allLinks.push(l); }); + }); + establishLinks(groupName, groupDiscovered, allLinks); + }, 500); + }; + const doStatus = (msg) => { const s = state[SCRIPT_NAME]; const groups = Object.keys(s.activeGroups); @@ -1096,6 +1234,7 @@ var Gaslight = Gaslight || (() => { case 'unlink': doUnlink(msg, args); break; case 'group': doGroup(msg, args); break; case 'ungroup': doUngroup(msg, args); break; + case 'stage': doStage(msg, args); break; case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; @@ -1114,6 +1253,7 @@ var Gaslight = Gaslight || (() => { const registerEventHandlers = () => { on('chat:message', handleInput); + on('add:graphic', onTokenAdded); }; return { checkInstall, registerEventHandlers }; From 433a33bebda09e27effba230f6ad8114e99df61b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 14:40:46 -0400 Subject: [PATCH 11/38] Gaslight v2: cascade-delete linked tokens when one is removed, with recursion guard --- Gaslight/Gaslight.js | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index b233b0a9b1..2679d1bcbb 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1251,9 +1251,56 @@ var Gaslight = Gaslight || (() => { checkDanglingGroups(); }; + /** + * When a linked token is deleted, delete its counterparts on other pages. + */ + var destroying = false; + const onTokenDestroyed = (obj) => { + if (destroying) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find if this token is tracked in any active group + var linkedIds = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + // Clean up tracking + delete active.linkedTokens[tokenId]; + linkedIds.forEach(function(id) { + if (active.linkedTokens[id]) { + active.linkedTokens[id] = active.linkedTokens[id].filter(function(lid) { return lid !== tokenId; }); + } + }); + } else { + // Check if it's in someone else's list + Object.entries(active.linkedTokens).forEach(function(entry) { + var idx = entry[1].indexOf(tokenId); + if (idx !== -1) { + entry[1].splice(idx, 1); + if (!linkedIds) linkedIds = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + + // Remove Anchor/Mirror links and delete counterparts + destroying = true; + linkedIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + if (typeof Mirror !== 'undefined') Mirror.unlink([id]); + var target = getObj('graphic', id); + if (target) target.remove(); + }); + destroying = false; + }; + const registerEventHandlers = () => { on('chat:message', handleInput); on('add:graphic', onTokenAdded); + on('destroy:graphic', onTokenDestroyed); }; return { checkInstall, registerEventHandlers }; From 11d42801f9620f8cd2b71e36d1b3de593a85d0f0 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:02:55 -0400 Subject: [PATCH 12/38] Gaslight v2: add !gaslight view, !gaslight relay, and view-mode command interception via SelectManager --- Gaslight/Gaslight.js | 164 ++++++++++++++++++++++++++++++++++++++++++- Gaslight/TODO.md | 14 ++-- 2 files changed, 173 insertions(+), 5 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 2679d1bcbb..50f66bd609 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -58,9 +58,11 @@ var Gaslight = Gaslight || (() => { if (!state[SCRIPT_NAME]) { state[SCRIPT_NAME] = { activeGroups: {}, - config: { autoCommit: false } + config: { autoCommit: false }, + view: null // null = master view, playerId = that player's view }; } + if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; }; // ========================================================================= @@ -937,6 +939,105 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Config', 'Page "' + pageName + '" (' + pageId + ') assigned to group "' + groupName + '" for ' + resolved.name + '.'); }; + /** + * Set the current view mode. + * !gaslight view [player|master] + */ + const doView = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + // Show current view + var current = s.view ? Object.values(s.activeGroups).reduce(function(name, g) { + if (name) return name; + var entry = g.playerPages[s.view]; + return entry ? entry.name : null; + }, null) || s.view : 'master'; + reply(msg, 'View', 'Current view: ' + current + ''); + return; + } + var arg = args.join(' ').replace(/^["']|["']$/g, ''); + if (arg.toLowerCase() === 'master' || arg.toLowerCase() === 'gm') { + s.view = null; + reply(msg, 'View', 'Switched to master view. Commands target master tokens; use !gaslight relay for player targeting.'); + } else { + // Resolve player + var resolved = resolvePlayer(msg, arg, CMD + ' view'); + if (!resolved || resolved === 'ambiguous') return; + s.view = resolved.id; + reply(msg, 'View', 'Switched to ' + resolved.name + ' view. Commands will auto-target their linked tokens.'); + } + }; + + /** + * Relay a command to linked tokens on other pages. + * !gaslight relay [--players name1 name2 --] + */ + 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; } + + // Parse --players + var targetPlayerNames = []; + var playersIdx = args.indexOf('--players'); + if (playersIdx !== -1) { + var endIdx = args.indexOf('--', playersIdx + 1); + if (endIdx === -1) endIdx = args.length; + targetPlayerNames = args.slice(playersIdx + 1, endIdx); + args = args.slice(0, playersIdx).concat(args.slice(endIdx + 1)); + } + + if (args.length === 0) { reply(msg, 'Error', 'No command to relay. Usage: !gaslight relay [--players name1 name2 --] <command>'); return; } + var command = args.join(' '); + + var relayed = 0; + tokens.forEach(function(token) { + var tokenId = token.get('id'); + // Find linked counterparts + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = linkedIds.concat(active.linkedTokens[tokenId]); + } else { + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) { + linkedIds.push(entry[0]); + linkedIds = linkedIds.concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + // Filter by target players if specified + if (targetPlayerNames.length > 0) { + linkedIds = linkedIds.filter(function(id) { + var obj = getObj('graphic', id); + if (!obj) return false; + var pageId = obj.get('_pageid'); + return Object.values(s.activeGroups).some(function(active) { + return Object.entries(active.playerPages).some(function(entry) { + return entry[1].pageId === pageId && targetPlayerNames.some(function(name) { + return entry[1].name && entry[1].name.toLowerCase() === name.toLowerCase(); + }); + }); + }); + }); + } + + // Deduplicate + linkedIds = linkedIds.filter(function(id, i) { return linkedIds.indexOf(id) === i && id !== tokenId; }); + + // Send command with each linked token as selection via SelectManager + linkedIds.forEach(function(id) { + var fullCmd = command + ' {& select ' + id + '}'; + sendChat('API', fullCmd); + relayed++; + }); + }); + + reply(msg, 'Relay', 'Relayed command to ' + relayed + ' linked token(s).'); + }; + /** * Stage selected tokens: duplicate to player pages and link. * !gaslight stage [playerName1 playerName2 ...] @@ -1234,6 +1335,8 @@ var Gaslight = Gaslight || (() => { case 'unlink': doUnlink(msg, args); break; case 'group': doGroup(msg, args); break; case 'ungroup': doUngroup(msg, args); break; + case 'relay': doRelay(msg, args); break; + case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; @@ -1297,8 +1400,67 @@ var Gaslight = Gaslight || (() => { destroying = false; }; + /** + * In player view mode, intercept non-gaslight API commands and re-emit + * with the linked player token as selection via SelectManager. + */ + const viewInterceptor = (msg) => { + if (msg.type !== 'api') return; + var s = state[SCRIPT_NAME]; + if (!s.view) return; // master view, don't intercept + if (msg.content.split(' ')[0] === CMD) return; // don't intercept our own commands + if (!playerIsGM(msg.playerid)) return; + if (!msg.selected || msg.selected.length === 0) return; + if (msg._gaslightRelayed) return; // prevent infinite loop + + var viewPlayerId = s.view; + var rewrittenIds = []; + var needsRewrite = false; + + msg.selected.forEach(function(sel) { + var tokenId = sel._id; + // Find the linked token on the viewed player's page + var linkedId = null; + Object.values(s.activeGroups).forEach(function(active) { + if (linkedId) return; + var playerPage = active.playerPages[viewPlayerId]; + if (!playerPage) return; + var targetPageId = playerPage.pageId; + + // Check if this token has links, find the one on the target page + var allLinked = active.linkedTokens[tokenId] || []; + // Also check if tokenId is in someone else's list + 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 && id !== tokenId; }); + + allLinked.forEach(function(id) { + if (linkedId) return; + var obj = getObj('graphic', id); + if (obj && obj.get('_pageid') === targetPageId) linkedId = id; + }); + }); + + if (linkedId) { rewrittenIds.push(linkedId); needsRewrite = true; } + else rewrittenIds.push(tokenId); // keep original if no match + }); + + if (needsRewrite) { + // Suppress original by marking as handled (other scripts will also see this msg, + // but we can't prevent that). Instead, re-emit with correct selection. + var newCmd = msg.content + ' {& select ' + rewrittenIds.join(', ') + '}'; + sendChat('API', newCmd); + // Note: original msg still fires to other handlers with original selection. + // This is a best-effort approach — SelectManager-aware scripts will use the new selection. + } + }; + const registerEventHandlers = () => { on('chat:message', handleInput); + on('chat:message', viewInterceptor); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); }; diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 02f2c95233..f03618f688 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -16,13 +16,19 @@ - [x] Sight stripping on children - [x] Player disambiguation (clickable buttons) -## v2 +## Done (v2) +- [x] Peer sync mode (multi-controller tokens via chain-anchoring) +- [x] Configurable sync properties (gaslight_sync attribute with ! exclusion) +- [x] Mirror integration (non-spatial property sync) +- [x] !gaslight stage command (propagate tokens to player pages) +- [x] gaslight_stage character attribute (auto-propagate on add) +- [x] Cascade-delete linked tokens +- [x] Anchor chain-linking for player tokens (removed GM override listener) + +## v2 Remaining - [ ] On-demand split (page cloning, adhoc flag, adhoc merge/cleanup) - [ ] Ad-hoc test (no group arg) -- [ ] Peer sync mode (multi-controller tokens) -- [ ] Configurable sync properties (gaslight_sync attribute) - [ ] Reaction suppression (interactionTriggered reset) -- [ ] Auto-commit / !gaslight commit - [ ] Focus-ping players on split - [ ] Config visibility toggle (hide text in HTML comment) - [ ] Near-match suggestions in step 4 warnings From a73292f6ff9d98ce1609214d89dea383aaf3da80 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:04:48 -0400 Subject: [PATCH 13/38] Gaslight v2: master view also intercepts and relays to all players --- Gaslight/Gaslight.js | 62 +++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 50f66bd609..4bcb46728c 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1401,60 +1401,64 @@ var Gaslight = Gaslight || (() => { }; /** - * In player view mode, intercept non-gaslight API commands and re-emit - * with the linked player token as selection via SelectManager. + * In any active view mode, intercept non-gaslight API commands and re-emit + * with linked player tokens as selection via SelectManager. + * Master view: relay to ALL player pages. + * Player view: relay to that player's page only. */ const viewInterceptor = (msg) => { if (msg.type !== 'api') return; var s = state[SCRIPT_NAME]; - if (!s.view) return; // master view, don't intercept + if (Object.keys(s.activeGroups).length === 0) return; // no active gaslight if (msg.content.split(' ')[0] === CMD) return; // don't intercept our own commands if (!playerIsGM(msg.playerid)) return; if (!msg.selected || msg.selected.length === 0) return; - if (msg._gaslightRelayed) return; // prevent infinite loop + if (msg.content.indexOf('{& select') !== -1) return; // already has SelectManager, skip + + var viewPlayerId = s.view; // null = master (all players), string = specific player - var viewPlayerId = s.view; var rewrittenIds = []; var needsRewrite = false; msg.selected.forEach(function(sel) { var tokenId = sel._id; - // Find the linked token on the viewed player's page - var linkedId = null; + var allLinked = []; + Object.values(s.activeGroups).forEach(function(active) { - if (linkedId) return; - var playerPage = active.playerPages[viewPlayerId]; - if (!playerPage) return; - var targetPageId = playerPage.pageId; - - // Check if this token has links, find the one on the target page - var allLinked = active.linkedTokens[tokenId] || []; - // Also check if tokenId is in someone else's list + var linked = active.linkedTokens[tokenId] || []; Object.entries(active.linkedTokens).forEach(function(entry) { if (entry[1].indexOf(tokenId) !== -1) { - allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + linked = linked.concat([entry[0]]).concat(entry[1]); } }); - allLinked = allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i && id !== tokenId; }); - - allLinked.forEach(function(id) { - if (linkedId) return; - var obj = getObj('graphic', id); - if (obj && obj.get('_pageid') === targetPageId) linkedId = id; - }); + linked = linked.filter(function(id, i) { return linked.indexOf(id) === i && id !== tokenId; }); + + if (viewPlayerId) { + // Player view: only the token on that player's page + var playerPage = active.playerPages[viewPlayerId]; + if (playerPage) { + linked.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj && obj.get('_pageid') === playerPage.pageId) rewrittenIds.push(id); + }); + } + } else { + // Master view: all linked tokens on all player pages + linked.forEach(function(id) { + var obj = getObj('graphic', id); + if (obj && obj.get('_pageid') !== active.masterPageId) rewrittenIds.push(id); + }); + } }); - if (linkedId) { rewrittenIds.push(linkedId); needsRewrite = true; } - else rewrittenIds.push(tokenId); // keep original if no match + if (rewrittenIds.length > 0) needsRewrite = true; }); if (needsRewrite) { - // Suppress original by marking as handled (other scripts will also see this msg, - // but we can't prevent that). Instead, re-emit with correct selection. + // Deduplicate + rewrittenIds = rewrittenIds.filter(function(id, i) { return rewrittenIds.indexOf(id) === i; }); var newCmd = msg.content + ' {& select ' + rewrittenIds.join(', ') + '}'; sendChat('API', newCmd); - // Note: original msg still fires to other handlers with original selection. - // This is a best-effort approach — SelectManager-aware scripts will use the new selection. } }; From e4f0a708b7c2443edda04fefb3a65f750553be31 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:15:09 -0400 Subject: [PATCH 14/38] Gaslight v2: rewrite relay with explicit view targets, ! command detection --- Gaslight/Gaslight.js | 119 ++++++++++++++++++++++++++----------------- 1 file changed, 72 insertions(+), 47 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 4bcb46728c..0776959ecd 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -969,73 +969,98 @@ var Gaslight = Gaslight || (() => { }; /** - * Relay a command to linked tokens on other pages. - * !gaslight relay [--players name1 name2 --] + * 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; } - // Parse --players - var targetPlayerNames = []; - var playersIdx = args.indexOf('--players'); - if (playersIdx !== -1) { - var endIdx = args.indexOf('--', playersIdx + 1); - if (endIdx === -1) endIdx = args.length; - targetPlayerNames = args.slice(playersIdx + 1, endIdx); - args = args.slice(0, playersIdx).concat(args.slice(endIdx + 1)); - } + // Split args: views are everything before first !-prefixed arg, command is the rest + var views = []; + var commandArgs = []; + var foundCmd = false; + args.forEach(function(a) { + if (!foundCmd && a.startsWith('!')) foundCmd = true; + if (foundCmd) commandArgs.push(a); + else views.push(a); + }); - if (args.length === 0) { reply(msg, 'Error', 'No command to relay. Usage: !gaslight relay [--players name1 name2 --] <command>'); return; } - var command = args.join(' '); + 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 !'); 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 relayed = 0; - tokens.forEach(function(token) { - var tokenId = token.get('id'); - // Find linked counterparts - var linkedIds = []; - Object.values(s.activeGroups).forEach(function(active) { - if (active.linkedTokens[tokenId]) { - linkedIds = linkedIds.concat(active.linkedTokens[tokenId]); - } else { + + // Master: run command with original token selection + if (includeMaster) { + var masterIds = tokens.map(function(t) { return t.get('id'); }); + sendChat('API', command + ' {& select ' + masterIds.join(', ') + '}'); + relayed += masterIds.length; + } + + // Player pages: find linked tokens + if (targetPlayerIds.length > 0) { + tokens.forEach(function(token) { + var tokenId = token.get('id'); + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + var allLinked = active.linkedTokens[tokenId] || []; Object.entries(active.linkedTokens).forEach(function(entry) { if (entry[1].indexOf(tokenId) !== -1) { - linkedIds.push(entry[0]); - linkedIds = linkedIds.concat(entry[1].filter(function(id) { return id !== tokenId; })); + allLinked = allLinked.concat([entry[0]]).concat(entry[1]); } }); - } - }); + allLinked = allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i && id !== tokenId; }); - // Filter by target players if specified - if (targetPlayerNames.length > 0) { - linkedIds = linkedIds.filter(function(id) { - var obj = getObj('graphic', id); - if (!obj) return false; - var pageId = obj.get('_pageid'); - return Object.values(s.activeGroups).some(function(active) { - return Object.entries(active.playerPages).some(function(entry) { - return entry[1].pageId === pageId && targetPlayerNames.some(function(name) { - return entry[1].name && entry[1].name.toLowerCase() === name.toLowerCase(); - }); + // Filter to target player pages + allLinked.forEach(function(id) { + var obj = getObj('graphic', id); + if (!obj) return; + var pageId = obj.get('_pageid'); + var isTarget = Object.entries(active.playerPages).some(function(entry) { + return targetPlayerIds.indexOf(entry[0]) !== -1 && entry[1].pageId === pageId; }); + if (isTarget) linkedIds.push(id); }); }); - } - // Deduplicate - linkedIds = linkedIds.filter(function(id, i) { return linkedIds.indexOf(id) === i && id !== tokenId; }); - - // Send command with each linked token as selection via SelectManager - linkedIds.forEach(function(id) { - var fullCmd = command + ' {& select ' + id + '}'; - sendChat('API', fullCmd); - relayed++; + linkedIds = linkedIds.filter(function(id, i) { return linkedIds.indexOf(id) === i; }); + linkedIds.forEach(function(id) { + sendChat('API', command + ' {& select ' + id + '}'); + relayed++; + }); }); - }); + } - reply(msg, 'Relay', 'Relayed command to ' + relayed + ' linked token(s).'); + reply(msg, 'Relay', 'Relayed to ' + relayed + ' token(s).'); }; /** From 3cd0ad9d34c4410dfe5171e24a6499a117020728 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:23:04 -0400 Subject: [PATCH 15/38] Gaslight v2: relay detects command start on ! # or % prefix --- Gaslight/Gaslight.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 0776959ecd..1352125354 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -978,18 +978,18 @@ var Gaslight = Gaslight || (() => { 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 !-prefixed arg, command is the rest + // 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('!')) foundCmd = true; + 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 !'); return; } + if (commandArgs.length === 0) { reply(msg, 'Error', 'No command provided. Command must start with !, #, or %'); return; } var command = commandArgs.join(' '); // Resolve views From d2d66248d62fd355722178ebc880e0ec9c627518 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:28:06 -0400 Subject: [PATCH 16/38] Gaslight v2: send relayed/intercepted commands as invoking player, not API --- Gaslight/Gaslight.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 1352125354..5908a925b6 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1017,12 +1017,14 @@ var Gaslight = Gaslight || (() => { }); targetPlayerIds = targetPlayerIds.filter(function(id, i) { return targetPlayerIds.indexOf(id) === i; }); + var sender = 'player|' + msg.playerid; + var relayed = 0; // Master: run command with original token selection if (includeMaster) { var masterIds = tokens.map(function(t) { return t.get('id'); }); - sendChat('API', command + ' {& select ' + masterIds.join(', ') + '}'); + sendChat(sender, command + ' {& select ' + masterIds.join(', ') + '}'); relayed += masterIds.length; } @@ -1054,7 +1056,7 @@ var Gaslight = Gaslight || (() => { linkedIds = linkedIds.filter(function(id, i) { return linkedIds.indexOf(id) === i; }); linkedIds.forEach(function(id) { - sendChat('API', command + ' {& select ' + id + '}'); + sendChat(sender, command + ' {& select ' + id + '}'); relayed++; }); }); @@ -1483,7 +1485,7 @@ var Gaslight = Gaslight || (() => { // Deduplicate rewrittenIds = rewrittenIds.filter(function(id, i) { return rewrittenIds.indexOf(id) === i; }); var newCmd = msg.content + ' {& select ' + rewrittenIds.join(', ') + '}'; - sendChat('API', newCmd); + sendChat('player|' + msg.playerid, newCmd); } }; From ece21eb67bc616da0ed015eb5382dd6eb9bdbe3f Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:31:44 -0400 Subject: [PATCH 17/38] Gaslight v2: refactor relay/interceptor to share executeRelay helper --- Gaslight/Gaslight.js | 79 ++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 50 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 5908a925b6..9333fcbcb2 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1019,16 +1019,24 @@ var Gaslight = Gaslight || (() => { var sender = 'player|' + msg.playerid; + var relayed = executeRelay(sender, tokens, command, targetPlayerIds, includeMaster); + reply(msg, 'Relay', 'Relayed to ' + relayed + ' token(s).'); + }; + + /** + * Shared relay execution: sends command to linked tokens on target pages. + * Returns number of tokens relayed to. + */ + const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { + var s = state[SCRIPT_NAME]; var relayed = 0; - // Master: run command with original token selection if (includeMaster) { var masterIds = tokens.map(function(t) { return t.get('id'); }); sendChat(sender, command + ' {& select ' + masterIds.join(', ') + '}'); relayed += masterIds.length; } - // Player pages: find linked tokens if (targetPlayerIds.length > 0) { tokens.forEach(function(token) { var tokenId = token.get('id'); @@ -1042,7 +1050,6 @@ var Gaslight = Gaslight || (() => { }); allLinked = allLinked.filter(function(id, i) { return allLinked.indexOf(id) === i && id !== tokenId; }); - // Filter to target player pages allLinked.forEach(function(id) { var obj = getObj('graphic', id); if (!obj) return; @@ -1062,7 +1069,8 @@ var Gaslight = Gaslight || (() => { }); } - reply(msg, 'Relay', 'Relayed to ' + relayed + ' token(s).'); + return relayed; + }; }; /** @@ -1436,57 +1444,28 @@ var Gaslight = Gaslight || (() => { const viewInterceptor = (msg) => { if (msg.type !== 'api') return; var s = state[SCRIPT_NAME]; - if (Object.keys(s.activeGroups).length === 0) return; // no active gaslight - if (msg.content.split(' ')[0] === CMD) return; // don't intercept our own commands + if (Object.keys(s.activeGroups).length === 0) return; + if (msg.content.split(' ')[0] === CMD) return; if (!playerIsGM(msg.playerid)) return; if (!msg.selected || msg.selected.length === 0) return; - if (msg.content.indexOf('{& select') !== -1) return; // already has SelectManager, skip - - var viewPlayerId = s.view; // null = master (all players), string = specific player - - var rewrittenIds = []; - var needsRewrite = false; + if (msg.content.indexOf('{& select') !== -1) return; - msg.selected.forEach(function(sel) { - var tokenId = sel._id; - var allLinked = []; + var viewPlayerId = s.view; + var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) return; - Object.values(s.activeGroups).forEach(function(active) { - var linked = active.linkedTokens[tokenId] || []; - Object.entries(active.linkedTokens).forEach(function(entry) { - if (entry[1].indexOf(tokenId) !== -1) { - linked = linked.concat([entry[0]]).concat(entry[1]); - } - }); - linked = linked.filter(function(id, i) { return linked.indexOf(id) === i && id !== tokenId; }); - - if (viewPlayerId) { - // Player view: only the token on that player's page - var playerPage = active.playerPages[viewPlayerId]; - if (playerPage) { - linked.forEach(function(id) { - var obj = getObj('graphic', id); - if (obj && obj.get('_pageid') === playerPage.pageId) rewrittenIds.push(id); - }); - } - } else { - // Master view: all linked tokens on all player pages - linked.forEach(function(id) { - var obj = getObj('graphic', id); - if (obj && obj.get('_pageid') !== active.masterPageId) rewrittenIds.push(id); - }); - } - }); - - if (rewrittenIds.length > 0) needsRewrite = true; - }); - - if (needsRewrite) { - // Deduplicate - rewrittenIds = rewrittenIds.filter(function(id, i) { return rewrittenIds.indexOf(id) === i; }); - var newCmd = msg.content + ' {& select ' + rewrittenIds.join(', ') + '}'; - sendChat('player|' + msg.playerid, newCmd); + // Determine target player IDs based on current view + var targetPlayerIds; + if (viewPlayerId) { + targetPlayerIds = [viewPlayerId]; + } else { + targetPlayerIds = Object.keys(s.activeGroups).reduce(function(acc, gn) { + return acc.concat(Object.keys(s.activeGroups[gn].playerPages)); + }, []); + targetPlayerIds = targetPlayerIds.filter(function(id, i) { return targetPlayerIds.indexOf(id) === i; }); } + + executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); }; const registerEventHandlers = () => { From 4e0e7478e019e2b0fcd16071ad77e1ef268ffb33 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:33:36 -0400 Subject: [PATCH 18/38] Gaslight v2: interceptor skips !mirror and !anchor commands --- Gaslight/Gaslight.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 9333fcbcb2..966856c2ed 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1446,6 +1446,8 @@ var Gaslight = Gaslight || (() => { var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; if (msg.content.split(' ')[0] === CMD) return; + var firstWord = msg.content.split(' ')[0]; + if (firstWord === '!mirror' || firstWord === '!anchor') return; if (!playerIsGM(msg.playerid)) return; if (!msg.selected || msg.selected.length === 0) return; if (msg.content.indexOf('{& select') !== -1) return; From 4793edfc0a3c0fdb91924031cbbfa3ef5bcc70e8 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:37:27 -0400 Subject: [PATCH 19/38] Gaslight v2: relay preserves selection order in single batched SelectManager call --- Gaslight/Gaslight.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 966856c2ed..320084ad54 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1038,9 +1038,10 @@ var Gaslight = Gaslight || (() => { } if (targetPlayerIds.length > 0) { + // Collect linked tokens in selection order + var orderedLinkedIds = []; tokens.forEach(function(token) { var tokenId = token.get('id'); - var linkedIds = []; Object.values(s.activeGroups).forEach(function(active) { var allLinked = active.linkedTokens[tokenId] || []; Object.entries(active.linkedTokens).forEach(function(entry) { @@ -1057,16 +1058,15 @@ var Gaslight = Gaslight || (() => { var isTarget = Object.entries(active.playerPages).some(function(entry) { return targetPlayerIds.indexOf(entry[0]) !== -1 && entry[1].pageId === pageId; }); - if (isTarget) linkedIds.push(id); + if (isTarget && orderedLinkedIds.indexOf(id) === -1) orderedLinkedIds.push(id); }); }); - - linkedIds = linkedIds.filter(function(id, i) { return linkedIds.indexOf(id) === i; }); - linkedIds.forEach(function(id) { - sendChat(sender, command + ' {& select ' + id + '}'); - relayed++; - }); }); + + if (orderedLinkedIds.length > 0) { + sendChat(sender, command + ' {& select ' + orderedLinkedIds.join(', ') + '}'); + relayed += orderedLinkedIds.length; + } } return relayed; From e8411098443424e6676bebcf3e982cfa774f155a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:44:26 -0400 Subject: [PATCH 20/38] Gaslight v2: interceptor only fires for tokens on master page, skip if GM is on player page --- Gaslight/Gaslight.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 320084ad54..7503baa93e 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1445,26 +1445,28 @@ var Gaslight = Gaslight || (() => { if (msg.type !== 'api') return; var s = state[SCRIPT_NAME]; if (Object.keys(s.activeGroups).length === 0) return; - if (msg.content.split(' ')[0] === CMD) return; var firstWord = msg.content.split(' ')[0]; - if (firstWord === '!mirror' || firstWord === '!anchor') return; + if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; if (!playerIsGM(msg.playerid)) return; if (!msg.selected || msg.selected.length === 0) return; if (msg.content.indexOf('{& select') !== -1) return; - var viewPlayerId = s.view; var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); if (tokens.length === 0) return; + // Only intercept if tokens are on a master page + var pageId = tokens[0].get('_pageid'); + var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); + if (!activeEntry) return; // tokens not on master, let command run normally + + var viewPlayerId = s.view; + // Determine target player IDs based on current view var targetPlayerIds; if (viewPlayerId) { targetPlayerIds = [viewPlayerId]; } else { - targetPlayerIds = Object.keys(s.activeGroups).reduce(function(acc, gn) { - return acc.concat(Object.keys(s.activeGroups[gn].playerPages)); - }, []); - targetPlayerIds = targetPlayerIds.filter(function(id, i) { return targetPlayerIds.indexOf(id) === i; }); + targetPlayerIds = Object.keys(activeEntry[1].playerPages); } executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); From b5952ad372c560fda8d59d21ea4b1f93820cf821 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 15:57:44 -0400 Subject: [PATCH 21/38] Gaslight v2: add relay-commands config for player auto-relay, loop prevention via {& select} check --- Gaslight/Gaslight.js | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 7503baa93e..39b3e062e4 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -58,11 +58,12 @@ var Gaslight = Gaslight || (() => { if (!state[SCRIPT_NAME]) { state[SCRIPT_NAME] = { activeGroups: {}, - config: { autoCommit: false }, - view: null // null = master view, playerId = that player's view + config: { autoCommit: false, relayCommands: [] }, + view: null }; } if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; }; // ========================================================================= @@ -1447,29 +1448,42 @@ var Gaslight = Gaslight || (() => { if (Object.keys(s.activeGroups).length === 0) return; var firstWord = msg.content.split(' ')[0]; if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; - if (!playerIsGM(msg.playerid)) return; if (!msg.selected || msg.selected.length === 0) return; if (msg.content.indexOf('{& select') !== -1) return; var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); if (tokens.length === 0) return; - // Only intercept if tokens are on a master page var pageId = tokens[0].get('_pageid'); - var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); - if (!activeEntry) return; // tokens not on master, let command run normally + var isGM = playerIsGM(msg.playerid); - var viewPlayerId = s.view; + // Case 1: GM on master page — relay based on view + if (isGM) { + var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); + if (!activeEntry) return; - // Determine target player IDs based on current view - var targetPlayerIds; - if (viewPlayerId) { - targetPlayerIds = [viewPlayerId]; - } else { - targetPlayerIds = Object.keys(activeEntry[1].playerPages); + var viewPlayerId = s.view; + var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); + executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); + return; } - executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); + // Case 2: Player on their page — relay if command is in relay-commands list + if (s.config.relayCommands.indexOf(firstWord) === -1) return; + + // Find which group/player this page belongs to + var activeEntry = null; + var sourcePlayerId = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + if (pp[1].pageId === pageId) { activeEntry = e; sourcePlayerId = pp[0]; } + }); + }); + if (!activeEntry) return; + + // Relay to all OTHER player pages + master + var targetPlayerIds = Object.keys(activeEntry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, true); }; const registerEventHandlers = () => { From 6550f853b4373a22e6d82164dce04ebc478afe8c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 16:04:37 -0400 Subject: [PATCH 22/38] Gaslight v2: add !gaslight config relay-add/relay-remove/relay-list commands --- Gaslight/Gaslight.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 39b3e062e4..38fb60412f 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1212,6 +1212,32 @@ var Gaslight = Gaslight || (() => { }, 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); @@ -1374,6 +1400,7 @@ var Gaslight = Gaslight || (() => { case 'relay': doRelay(msg, args); break; case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; + case 'config': doConfig(msg, args); break; case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; From 9d26265be2915a955348eced3016617a78a68390 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 16:07:20 -0400 Subject: [PATCH 23/38] Gaslight: update TODO with completed v2 items --- Gaslight/TODO.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index f03618f688..bd2899f0ca 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -24,16 +24,29 @@ - [x] gaslight_stage character attribute (auto-propagate on add) - [x] Cascade-delete linked tokens - [x] Anchor chain-linking for player tokens (removed GM override listener) +- [x] !gaslight view (master/player view switching) +- [x] !gaslight relay (explicit command relay to views) +- [x] View interceptor (auto-relay commands from master page) +- [x] Player relay-commands (auto-relay configured commands from player pages) +- [x] !gaslight config (relay-add/relay-remove/relay-list) +- [x] Relay preserves selection order +- [x] Relay sends as invoking player (macros/permissions work) +- [x] Interceptor skips !gaslight, !mirror, !anchor +- [x] Loop prevention via {& select} check ## v2 Remaining - [ ] On-demand split (page cloning, adhoc flag, adhoc merge/cleanup) - [ ] Ad-hoc test (no group arg) - [ ] Reaction suppression (interactionTriggered reset) - [ ] Focus-ping players on split -- [ ] Config visibility toggle (hide text in HTML comment) + +## v3 Ideas +- [ ] Config handout (editable in-game, live reload) +- [ ] Group/page-level relay-command overrides +- [ ] Config visibility toggle (hide gaslight text in HTML comment) - [ ] Near-match suggestions in step 4 warnings -- [ ] Per-status-marker sync granularity -- [ ] Master page view toggling +- [ ] Per-status-marker sync granularity (manual for now) +- [ ] Replay command (re-run last N commands against different views) - [ ] Choreograph/Sequence integration ## Known Issues From 487e778c728049f8db470b4254956e1f94c68179 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 16:15:47 -0400 Subject: [PATCH 24/38] Gaslight v2: add focus-ping on split, remove reaction suppression (API moves don't trigger reactions) --- Gaslight/Gaslight.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 38fb60412f..68c163799c 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -768,6 +768,26 @@ var Gaslight = Gaslight || (() => { } summary += formatWarnings(globalWarnings); reply(msg, 'Split', summary); + + // Focus-ping each player to their character token on their page + setTimeout(function() { + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + // Find a token on the player's page that they control + var playerTokens = findObjs({ _type: 'graphic', _pageid: pInfo.pageId, _subtype: 'token' }); + var charToken = playerTokens.find(function(t) { + var charId = t.get('represents'); + if (!charId) return false; + var character = getObj('character', charId); + if (!character) return false; + var cb = character.get('controlledby') || ''; + return cb === 'all' || cb.split(',').indexOf(playerId) !== -1; + }); + if (charToken) { + sendPing(charToken.get('left'), charToken.get('top'), pInfo.pageId, playerId, true, [playerId]); + } + }); + }, 500); }; const doMerge = (msg, args) => { From 815c7c684e75efcdc671a0e91381f8f1ec9fdcb0 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 16:37:37 -0400 Subject: [PATCH 25/38] Gaslight v2: add !gaslight setup for quick group configuration from duplicate pages --- Gaslight/Gaslight.js | 100 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 68c163799c..46d0f0ae89 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -695,6 +695,105 @@ var Gaslight = Gaslight || (() => { // Commands // ========================================================================= + /** + * Quick setup: auto-configure a group from duplicate pages. + * !gaslight setup [--selected | player1 player2 ...] + * Expects N+1 pages with the same name (or name prefix). Assigns master + players. + */ + const doSetup = (msg, args) => { + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + var groupName = args.shift(); + + // Determine players + var useSelected = args.indexOf('--selected') !== -1; + args = args.filter(function(a) { return a !== '--selected'; }); + + var playerIds = []; + if (useSelected && msg.selected && msg.selected.length > 0) { + // Get players from selected tokens' character controllers + 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); + }); + } + }); + } else if (args.length > 0) { + // Resolve player names + 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); + } + }); + } else { + // Fallback: party-tagged characters + 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: pages with the same name or name starting with masterName + var allPages = findObjs({ _type: 'page' }); + var candidates = allPages.filter(function(p) { + return p.get('name') === masterName || p.get('name').indexOf(masterName) === 0; + }); + + // 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'; }); @@ -1410,6 +1509,7 @@ var Gaslight = Gaslight || (() => { 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; From 79f5ac647d3f49466543a116228673a1cd641a87 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 16:39:53 -0400 Subject: [PATCH 26/38] Gaslight v2: setup uses selected + args together, party tags only as fallback --- Gaslight/Gaslight.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 46d0f0ae89..e2368327d6 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -704,13 +704,11 @@ var Gaslight = Gaslight || (() => { if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } var groupName = args.shift(); - // Determine players - var useSelected = args.indexOf('--selected') !== -1; - args = args.filter(function(a) { return a !== '--selected'; }); - + // Determine players: selected tokens + named args, fallback to party tags var playerIds = []; - if (useSelected && msg.selected && msg.selected.length > 0) { - // Get players from selected tokens' character controllers + + // 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; @@ -725,16 +723,18 @@ var Gaslight = Gaslight || (() => { }); } }); - } else if (args.length > 0) { - // Resolve player names - 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); - } - }); - } else { - // Fallback: party-tagged characters + } + + // 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') || ''; From 8f9813ff0bdabff25f3402b943921349edd864af Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 16:41:43 -0400 Subject: [PATCH 27/38] Gaslight: update TODO - v2 features complete, testing needed --- Gaslight/TODO.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index bd2899f0ca..a668753e1b 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -33,12 +33,19 @@ - [x] Relay sends as invoking player (macros/permissions work) - [x] Interceptor skips !gaslight, !mirror, !anchor - [x] Loop prevention via {& select} check +- [x] Focus-ping players on split +- [x] Reaction suppression confirmed unnecessary (API moves don't trigger reactions) +- [x] !gaslight setup (quick group config from duplicate pages) -## v2 Remaining -- [ ] On-demand split (page cloning, adhoc flag, adhoc merge/cleanup) -- [ ] Ad-hoc test (no group arg) -- [ ] Reaction suppression (interactionTriggered reset) -- [ ] Focus-ping players on split +## Needs Testing (v2) +- [ ] gaslight_sync attribute (all combos: absent, empty, specific, !exclusion) +- [ ] Mirror chain setup/teardown on split/merge +- [ ] !gaslight stage + gaslight_stage auto-propagation +- [ ] Cascade-delete +- [ ] View interceptor + relay commands +- [ ] Player relay-commands config +- [ ] !gaslight setup workflow +- [ ] Focus-ping on split ## v3 Ideas - [ ] Config handout (editable in-game, live reload) @@ -48,6 +55,7 @@ - [ ] Per-status-marker sync granularity (manual for now) - [ ] Replay command (re-run last N commands against different views) - [ ] Choreograph/Sequence integration +- [ ] On-demand page cloning (if TruePageCopy exposes API) ## Known Issues - None currently From f3c9599204e49c51cf29ba5904dd7597e96a323c Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 16:50:14 -0400 Subject: [PATCH 28/38] Gaslight: replace Choreograph integration with conditional relay/per-player scripting in v3 ideas --- Gaslight/TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index a668753e1b..956aaad1f4 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -54,7 +54,7 @@ - [ ] Near-match suggestions in step 4 warnings - [ ] Per-status-marker sync granularity (manual for now) - [ ] Replay command (re-run last N commands against different views) -- [ ] Choreograph/Sequence integration +- [ ] Conditional relay / per-player scripting (evaluate conditions per player page, run different commands based on results — e.g. stealth/perception visibility. Stored in pins or handouts as reusable logic scripts.) - [ ] On-demand page cloning (if TruePageCopy exposes API) ## Known Issues From 72f7fb851addf183885e6868b5b1c8addfc7d4aa Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 17:12:58 -0400 Subject: [PATCH 29/38] Gaslight v2: fix syntax error (duplicate closing brace) --- Gaslight/Gaslight.js | 1 - 1 file changed, 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index e2368327d6..09389f1c29 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1191,7 +1191,6 @@ var Gaslight = Gaslight || (() => { return relayed; }; - }; /** * Stage selected tokens: duplicate to player pages and link. From b09938c13ff0c4a19765b1fd642cad2abaa2feba Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 17:20:47 -0400 Subject: [PATCH 30/38] Gaslight v2: setup strips recursive 'Copy of' prefixes when matching pages --- Gaslight/Gaslight.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 09389f1c29..5b317ff570 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -756,10 +756,19 @@ var Gaslight = Gaslight || (() => { 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: pages with the same name or name starting with masterName + // 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) { - return p.get('name') === masterName || p.get('name').indexOf(masterName) === 0; + 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) From 7bd78edd650cf3fbd0ff7db5314fd4641cae6d2b Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 17:52:18 -0400 Subject: [PATCH 31/38] Gaslight v2: fix stage/auto-stage with 3-step logic (link check, fill gaps, or generate new) --- Gaslight/Gaslight.js | 81 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 5b317ff570..f8907deee3 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -286,6 +286,28 @@ var Gaslight = Gaslight || (() => { /** * 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; + }; + const autoPopulateLinkId = (token) => { if (getLinkId(token)) return; // already has one const charId = token.get('represents'); @@ -1235,15 +1257,31 @@ var Gaslight = Gaslight || (() => { var staged = 0; tokens.forEach(function(token) { - targetPlayerIds.forEach(function(pid) { - var targetPageId = groupInfo.players[pid].pageId; - // Check if already exists on target page - var existing = findMatchingToken(token, targetPageId); - if (existing) return; - // Clone token to target page + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPlayerIds.forEach(function(pid) { + var targetPageId = groupInfo.players[pid].pageId; + var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(targetPageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPlayerIds.map(function(pid) { return groupInfo.players[pid].pageId; }); + } + + // Clone to determined pages + pagesToCloneTo.forEach(function(targetPageId) { var imgsrc = token.get('imgsrc'); if (!imgsrc) return; - createObj('graphic', { + var newToken = createObj('graphic', { _subtype: 'token', pageid: targetPageId, imgsrc: imgsrc, @@ -1257,6 +1295,7 @@ var Gaslight = Gaslight || (() => { represents: token.get('represents') || '', controlledby: token.get('controlledby') || '' }); + if (newToken) setLinkId(newToken, getLinkId(token)); staged++; }); }); @@ -1301,15 +1340,30 @@ var Gaslight = Gaslight || (() => { var groupName = activeEntry[0]; var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; - // Clone to all player pages - Object.values(groupInfo.players).forEach(function(pInfo) { - var existing = findMatchingToken(obj, pInfo.pageId); - if (existing) return; + // Clone to player pages using 3-step staging logic + var linkId = getLinkId(obj); + var pagesToCloneTo = []; + + if (linkId) { + Object.values(groupInfo.players).forEach(function(pInfo) { + var targets = findObjs({ _type: 'graphic', _pageid: pInfo.pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pInfo.pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + var newLinkId = genId(); + setLinkId(obj, newLinkId); + pagesToCloneTo = Object.values(groupInfo.players).map(function(pInfo) { return pInfo.pageId; }); + } + + pagesToCloneTo.forEach(function(targetPageId) { var imgsrc = obj.get('imgsrc'); if (!imgsrc) return; - createObj('graphic', { + var newToken = createObj('graphic', { _subtype: 'token', - pageid: pInfo.pageId, + pageid: targetPageId, imgsrc: imgsrc, left: obj.get('left'), top: obj.get('top'), @@ -1321,6 +1375,7 @@ var Gaslight = Gaslight || (() => { represents: charId, controlledby: obj.get('controlledby') || '' }); + if (newToken) setLinkId(newToken, getLinkId(obj)); }); // Re-link after a short delay to let createObj finish From 13e0e638ace5e2659824190b92e7d8b1f09bc54a Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 17:53:52 -0400 Subject: [PATCH 32/38] Gaslight v2: deduplicate staging logic into stageTokenToPages helper --- Gaslight/Gaslight.js | 131 +++++++++++++++++-------------------------- 1 file changed, 53 insertions(+), 78 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index f8907deee3..0408ad15c2 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -308,6 +308,54 @@ var Gaslight = Gaslight || (() => { return null; }; + /** + * Stage a single token to target pages using 3-step logic. + * Returns number of clones created. + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPageIds.forEach(function(pageId) { + var targets = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPageIds; + } + + var cloned = 0; + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + var newToken = createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + if (newToken) setLinkId(newToken, getLinkId(token)); + cloned++; + }); + return cloned; + }; + const autoPopulateLinkId = (token) => { if (getLinkId(token)) return; // already has one const charId = token.get('represents'); @@ -1257,47 +1305,8 @@ var Gaslight = Gaslight || (() => { var staged = 0; tokens.forEach(function(token) { - var linkId = getLinkId(token); - var pagesToCloneTo = []; - - if (linkId) { - // Step 1-2: find pages missing a token with this gaslight_link - targetPlayerIds.forEach(function(pid) { - var targetPageId = groupInfo.players[pid].pageId; - var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); - var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); - if (!hasMatch) pagesToCloneTo.push(targetPageId); - }); - } - - if (!linkId || pagesToCloneTo.length === 0) { - // Step 3: generate new gaslight_link and clone to all target pages - var newLinkId = genId(); - setLinkId(token, newLinkId); - pagesToCloneTo = targetPlayerIds.map(function(pid) { return groupInfo.players[pid].pageId; }); - } - - // Clone to determined pages - pagesToCloneTo.forEach(function(targetPageId) { - var imgsrc = token.get('imgsrc'); - if (!imgsrc) return; - var newToken = createObj('graphic', { - _subtype: 'token', - pageid: targetPageId, - imgsrc: imgsrc, - left: token.get('left'), - top: token.get('top'), - width: token.get('width'), - height: token.get('height'), - rotation: token.get('rotation'), - layer: token.get('layer'), - name: token.get('name'), - represents: token.get('represents') || '', - controlledby: token.get('controlledby') || '' - }); - if (newToken) setLinkId(newToken, getLinkId(token)); - staged++; - }); + var targetPages = targetPlayerIds.map(function(pid) { return groupInfo.players[pid].pageId; }); + staged += stageTokenToPages(token, targetPages); }); // Re-run linking for this group to pick up the new tokens @@ -1340,43 +1349,9 @@ var Gaslight = Gaslight || (() => { var groupName = activeEntry[0]; var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; - // Clone to player pages using 3-step staging logic - var linkId = getLinkId(obj); - var pagesToCloneTo = []; - - if (linkId) { - Object.values(groupInfo.players).forEach(function(pInfo) { - var targets = findObjs({ _type: 'graphic', _pageid: pInfo.pageId, _subtype: 'token' }); - var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); - if (!hasMatch) pagesToCloneTo.push(pInfo.pageId); - }); - } - - if (!linkId || pagesToCloneTo.length === 0) { - var newLinkId = genId(); - setLinkId(obj, newLinkId); - pagesToCloneTo = Object.values(groupInfo.players).map(function(pInfo) { return pInfo.pageId; }); - } - - pagesToCloneTo.forEach(function(targetPageId) { - var imgsrc = obj.get('imgsrc'); - if (!imgsrc) return; - var newToken = createObj('graphic', { - _subtype: 'token', - pageid: targetPageId, - imgsrc: imgsrc, - left: obj.get('left'), - top: obj.get('top'), - width: obj.get('width'), - height: obj.get('height'), - rotation: obj.get('rotation'), - layer: obj.get('layer'), - name: obj.get('name'), - represents: charId, - controlledby: obj.get('controlledby') || '' - }); - if (newToken) setLinkId(newToken, getLinkId(obj)); - }); + // Clone to player pages + var targetPages = Object.values(groupInfo.players).map(function(pInfo) { return pInfo.pageId; }); + stageTokenToPages(obj, targetPages); // Re-link after a short delay to let createObj finish setTimeout(function() { From aa637337c18e28465f9762a54540b22d99e86081 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 18:02:25 -0400 Subject: [PATCH 33/38] Gaslight v2: stage/auto-stage works from any gaslit page, targets all other pages including master --- Gaslight/Gaslight.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 0408ad15c2..493b6dfbec 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1305,7 +1305,12 @@ var Gaslight = Gaslight || (() => { var staged = 0; tokens.forEach(function(token) { - var targetPages = targetPlayerIds.map(function(pid) { return groupInfo.players[pid].pageId; }); + var sourcePageId = token.get('_pageid'); + var targetPages = targetPlayerIds + .map(function(pid) { return groupInfo.players[pid].pageId; }) + .filter(function(pid) { return pid !== sourcePageId; }); + // Include master if source is not master + if (sourcePageId !== groupInfo.master) targetPages.push(groupInfo.master); staged += stageTokenToPages(token, targetPages); }); @@ -1342,15 +1347,20 @@ var Gaslight = Gaslight || (() => { // Find which active group this page belongs to var pageId = obj.get('_pageid'); var activeEntry = Object.entries(s.activeGroups).find(function(e) { - return e[1].masterPageId === pageId; + if (e[1].masterPageId === pageId) return true; + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); }); - if (!activeEntry) return; // only auto-stage from master page + if (!activeEntry) return; var groupName = activeEntry[0]; var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; - // Clone to player pages - var targetPages = Object.values(groupInfo.players).map(function(pInfo) { return pInfo.pageId; }); + // 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 From c9c6bbaefc51cc52911b7f437f018370d3aa3176 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 19:54:58 -0400 Subject: [PATCH 34/38] =?UTF-8?q?Gaslight=20v2:=20dual-path=20relay=20?= =?UTF-8?q?=E2=80=94=20ID=20replacement=20(cross-page)=20+=20queue/poll=20?= =?UTF-8?q?(selection-based)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/Gaslight.js | 172 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 144 insertions(+), 28 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 493b6dfbec..94da123ea3 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1226,6 +1226,111 @@ var Gaslight = Gaslight || (() => { * Shared relay execution: sends command to linked tokens on target pages. * Returns number of tokens relayed to. */ + /** + * Find all Roll20 IDs (starting with -) in a command string that match linked tokens. + * Returns { found: [{id, linkedIds}], hasIds: bool } + */ + const findLinkedIdsInCommand = (command, activeGroups) => { + var idRx = /-[A-Za-z0-9_-]{19}/g; + var matches = command.match(idRx) || []; + var found = []; + matches.forEach(function(id) { + var linkedIds = []; + Object.values(activeGroups).forEach(function(active) { + var allLinked = active.linkedTokens[id] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(id) !== -1) { + allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + } + }); + allLinked = allLinked.filter(function(lid, i) { return allLinked.indexOf(lid) === i && lid !== id; }); + linkedIds = linkedIds.concat(allLinked); + }); + if (linkedIds.length > 0) found.push({ id: id, linkedIds: linkedIds }); + }); + return { found: found, hasIds: found.length > 0 }; + }; + + /** + * Path 2: Replace token IDs in command with linked counterparts per target page, emit immediately. + */ + const relayByIdReplacement = (sender, command, activeGroups, targetPlayerIds) => { + var idInfo = findLinkedIdsInCommand(command, activeGroups); + if (!idInfo.hasIds) return 0; + + var relayed = 0; + targetPlayerIds.forEach(function(playerId) { + var newCmd = command; + idInfo.found.forEach(function(entry) { + // Find the linked token that's on this player's page + var targetId = null; + Object.values(activeGroups).forEach(function(active) { + if (targetId) return; + var playerPage = active.playerPages[playerId]; + if (!playerPage) return; + entry.linkedIds.forEach(function(lid) { + if (targetId) return; + var obj = getObj('graphic', lid); + if (obj && obj.get('_pageid') === playerPage.pageId) targetId = lid; + }); + }); + if (targetId) newCmd = newCmd.replace(entry.id, targetId); + }); + if (newCmd !== command) { + sendChat(sender, newCmd); + relayed++; + } + }); + return relayed; + }; + + /** + * Path 1: Queue commands for execution when GM visits the target page. + */ + const queueRelay = (sender, tokens, command, targetPlayerIds) => { + var s = state[SCRIPT_NAME]; + if (!s.relayQueue) s.relayQueue = {}; + var tokenIds = tokens.map(function(t) { return t.get('id'); }); + + targetPlayerIds.forEach(function(playerId) { + // Find the linked token IDs for this player page + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + var playerPage = active.playerPages[playerId]; + if (!playerPage) return; + tokenIds.forEach(function(tokenId) { + var allLinked = active.linkedTokens[tokenId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id) { + var obj = getObj('graphic', id); + return obj && obj.get('_pageid') === playerPage.pageId; + }).forEach(function(id) { + if (linkedIds.indexOf(id) === -1) linkedIds.push(id); + }); + }); + }); + + if (linkedIds.length > 0) { + var pageId = null; + Object.values(s.activeGroups).forEach(function(active) { + var pp = active.playerPages[playerId]; + if (pp) pageId = pp.pageId; + }); + if (pageId) { + if (!s.relayQueue[pageId]) s.relayQueue[pageId] = []; + s.relayQueue[pageId].push({ sender: sender, command: command, selectIds: linkedIds }); + } + } + }); + + var queuedPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }); + if (queuedPages.length > 0) { + sendChat(SCRIPT_NAME, '/w gm Queued relay commands for ' + queuedPages.length + ' page(s). Navigate to player pages to execute.'); + } + }; + const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { var s = state[SCRIPT_NAME]; var relayed = 0; @@ -1237,40 +1342,41 @@ var Gaslight = Gaslight || (() => { } if (targetPlayerIds.length > 0) { - // Collect linked tokens in selection order - var orderedLinkedIds = []; - tokens.forEach(function(token) { - var tokenId = token.get('id'); - Object.values(s.activeGroups).forEach(function(active) { - 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 && id !== tokenId; }); - - allLinked.forEach(function(id) { - var obj = getObj('graphic', id); - if (!obj) return; - var pageId = obj.get('_pageid'); - var isTarget = Object.entries(active.playerPages).some(function(entry) { - return targetPlayerIds.indexOf(entry[0]) !== -1 && entry[1].pageId === pageId; - }); - if (isTarget && orderedLinkedIds.indexOf(id) === -1) orderedLinkedIds.push(id); - }); - }); - }); - - if (orderedLinkedIds.length > 0) { - sendChat(sender, command + ' {& select ' + orderedLinkedIds.join(', ') + '}'); - relayed += orderedLinkedIds.length; + // Path 2: try ID replacement first (works cross-page) + var idRelayed = relayByIdReplacement(sender, command, s.activeGroups, targetPlayerIds); + if (idRelayed > 0) { + relayed += idRelayed; + } else { + // Path 1: queue for when GM visits page (selection-based) + queueRelay(sender, tokens, command, targetPlayerIds); } } return relayed; }; + /** + * Poll _lastpage to fire queued relay commands when GM arrives on a target page. + */ + const pollRelayQueue = () => { + var s = state[SCRIPT_NAME]; + if (!s.relayQueue) return; + + var gmPlayers = findObjs({ _type: 'player' }).filter(function(p) { return playerIsGM(p.get('_id')); }); + gmPlayers.forEach(function(gm) { + var lastPage = gm.get('_lastpage'); + if (!lastPage) return; + var queue = s.relayQueue[lastPage]; + if (!queue || queue.length === 0) return; + + // Fire all queued commands for this page + queue.forEach(function(entry) { + sendChat(entry.sender, entry.command + ' {& select ' + entry.selectIds.join(', ') + '}'); + }); + delete s.relayQueue[lastPage]; + }); + }; + /** * Stage selected tokens: duplicate to player pages and link. * !gaslight stage [playerName1 playerName2 ...] @@ -1569,6 +1675,15 @@ var Gaslight = Gaslight || (() => { case 'view': doView(msg, args); break; case 'stage': doStage(msg, args); break; case 'config': doConfig(msg, args); break; + case 'test-relay': { + // Temporary: test sendChat with {& select} + var testId = args[0] || ''; + if (!testId) { reply(msg, 'Error', 'Provide a token ID'); break; } + var testCmd = '!token-mod --set bar1_value|42 {& select ' + testId + '}'; + log(SCRIPT_NAME + ': test-relay sending: ' + testCmd); + sendChat(getPlayerName(msg.playerid), testCmd); + break; + } case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; @@ -1686,6 +1801,7 @@ var Gaslight = Gaslight || (() => { on('chat:message', viewInterceptor); on('add:graphic', onTokenAdded); on('destroy:graphic', onTokenDestroyed); + setInterval(pollRelayQueue, 500); }; return { checkInstall, registerEventHandlers }; From 3df63abb37b6b62b6f2b4035520d66e5308c84f9 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 20:13:33 -0400 Subject: [PATCH 35/38] Gaslight v2: fix queue whisper, remove debug logs, relay confirmed working --- Gaslight/Gaslight.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 94da123ea3..5d3d260263 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1291,6 +1291,7 @@ var Gaslight = Gaslight || (() => { var s = state[SCRIPT_NAME]; if (!s.relayQueue) s.relayQueue = {}; var tokenIds = tokens.map(function(t) { return t.get('id'); }); + var newlyQueued = 0; targetPlayerIds.forEach(function(playerId) { // Find the linked token IDs for this player page @@ -1321,13 +1322,14 @@ var Gaslight = Gaslight || (() => { if (pageId) { if (!s.relayQueue[pageId]) s.relayQueue[pageId] = []; s.relayQueue[pageId].push({ sender: sender, command: command, selectIds: linkedIds }); + newlyQueued++; } } }); - var queuedPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }); - if (queuedPages.length > 0) { - sendChat(SCRIPT_NAME, '/w gm Queued relay commands for ' + queuedPages.length + ' page(s). Navigate to player pages to execute.'); + if (newlyQueued > 0) { + var totalPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }).length; + sendChat(SCRIPT_NAME, '/w gm Queued for ' + newlyQueued + ' page(s). Total pending: ' + totalPages + ' page(s). Navigate to player pages to execute.'); } }; From d1c6913796dd8ed34cebc71be26b7b58d379d217 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 20:27:11 -0400 Subject: [PATCH 36/38] Gaslight v2: revert send-as-player attempt, always queue for selection-based relay --- Gaslight/Gaslight.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gaslight/Gaslight.js b/Gaslight/Gaslight.js index 5d3d260263..94089efa96 100644 --- a/Gaslight/Gaslight.js +++ b/Gaslight/Gaslight.js @@ -1314,6 +1314,7 @@ var Gaslight = Gaslight || (() => { }); if (linkedIds.length > 0) { + // Queue for when GM visits the page var pageId = null; Object.values(s.activeGroups).forEach(function(active) { var pp = active.playerPages[playerId]; @@ -1329,7 +1330,7 @@ var Gaslight = Gaslight || (() => { if (newlyQueued > 0) { var totalPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }).length; - sendChat(SCRIPT_NAME, '/w gm Queued for ' + newlyQueued + ' page(s). Total pending: ' + totalPages + ' page(s). Navigate to player pages to execute.'); + sendChat(SCRIPT_NAME, '/w gm Queued for ' + newlyQueued + ' page(s). Total pending: ' + totalPages + '. Navigate to player pages to execute.'); } }; From 5f7d0fdf5b73bbad846f60e4534751060f526b96 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 20:30:29 -0400 Subject: [PATCH 37/38] Gaslight: merge v2, update README/script.json/versioned folder with full feature set --- Gaslight/1.0.0/Gaslight.js | 841 +++++++++++++++++++++++++++++++++++-- Gaslight/README.md | 79 ++-- Gaslight/script.json | 2 +- 3 files changed, 861 insertions(+), 61 deletions(-) diff --git a/Gaslight/1.0.0/Gaslight.js b/Gaslight/1.0.0/Gaslight.js index 991ab65df9..94089efa96 100644 --- a/Gaslight/1.0.0/Gaslight.js +++ b/Gaslight/1.0.0/Gaslight.js @@ -58,9 +58,12 @@ var Gaslight = Gaslight || (() => { if (!state[SCRIPT_NAME]) { state[SCRIPT_NAME] = { activeGroups: {}, - config: { autoCommit: false } + config: { autoCommit: false, relayCommands: [] }, + view: null }; } + if (!state[SCRIPT_NAME].view) state[SCRIPT_NAME].view = null; + if (!state[SCRIPT_NAME].config.relayCommands) state[SCRIPT_NAME].config.relayCommands = []; }; // ========================================================================= @@ -283,6 +286,76 @@ var Gaslight = Gaslight || (() => { /** * Auto-populate gaslight_link from character attribute if token doesn't already have one. */ + /** + * Find a matching token on another page by gaslight_link, represents+name, or represents alone. + */ + const findMatchingToken = (sourceToken, targetPageId) => { + // By gaslight_link + var linkId = getLinkId(sourceToken); + if (linkId) { + var targets = findObjs({ _type: 'graphic', _pageid: targetPageId, _subtype: 'token' }); + var match = targets.find(function(t) { return getLinkId(t) === linkId; }); + if (match) return match; + } + // By represents + name + var charId = sourceToken.get('represents'); + if (charId) { + var name = sourceToken.get('name'); + var byName = findObjs({ _type: 'graphic', _pageid: targetPageId, represents: charId, _subtype: 'token' }); + if (name) byName = byName.filter(function(t) { return t.get('name') === name; }); + if (byName.length === 1) return byName[0]; + } + return null; + }; + + /** + * Stage a single token to target pages using 3-step logic. + * Returns number of clones created. + */ + const stageTokenToPages = (token, targetPageIds) => { + var linkId = getLinkId(token); + var pagesToCloneTo = []; + + if (linkId) { + // Step 1-2: find pages missing a token with this gaslight_link + targetPageIds.forEach(function(pageId) { + var targets = findObjs({ _type: 'graphic', _pageid: pageId, _subtype: 'token' }); + var hasMatch = targets.some(function(t) { return getLinkId(t) === linkId; }); + if (!hasMatch) pagesToCloneTo.push(pageId); + }); + } + + if (!linkId || pagesToCloneTo.length === 0) { + // Step 3: generate new gaslight_link and clone to all target pages + var newLinkId = genId(); + setLinkId(token, newLinkId); + pagesToCloneTo = targetPageIds; + } + + var cloned = 0; + pagesToCloneTo.forEach(function(targetPageId) { + var imgsrc = token.get('imgsrc'); + if (!imgsrc) return; + var newToken = createObj('graphic', { + _subtype: 'token', + pageid: targetPageId, + imgsrc: imgsrc, + left: token.get('left'), + top: token.get('top'), + width: token.get('width'), + height: token.get('height'), + rotation: token.get('rotation'), + layer: token.get('layer'), + name: token.get('name'), + represents: token.get('represents') || '', + controlledby: token.get('controlledby') || '' + }); + if (newToken) setLinkId(newToken, getLinkId(token)); + cloned++; + }); + return cloned; + }; + const autoPopulateLinkId = (token) => { if (getLinkId(token)) return; // already has one const charId = token.get('represents'); @@ -293,6 +366,54 @@ var Gaslight = Gaslight || (() => { } }; + /** + * Read the gaslight_sync character attribute. + * Returns: + * null — attribute absent (default: sync all non-spatial) + * '' — attribute present but empty (no sync) + * ['prop1','prop2',...] — specific props to sync + */ + const getGaslightSync = (charId) => { + if (!charId) return null; + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_sync' })[0]; + if (!attr) return null; + var val = attr.get('current'); + if (val === undefined || val === null) return null; + val = val.trim(); + if (val === '') return ''; + // Parse comma-separated props, resolve groups + // Prefix with ! to exclude (e.g. "!anchor" = everything except anchor props) + var parts = val.split(',').map(function(s) { return s.trim(); }).filter(Boolean); + var includes = []; + var excludes = []; + parts.forEach(function(p) { + var isExclude = p.startsWith('!'); + var name = isExclude ? p.slice(1) : p; + var expanded; + if (name === 'base' || name === 'anchor') { + expanded = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph']; + } else if (typeof Mirror !== 'undefined' && Mirror.PROP_GROUPS[name]) { + expanded = Mirror.PROP_GROUPS[name]; + } else { + expanded = [name]; + } + if (isExclude) excludes = excludes.concat(expanded); + else includes = includes.concat(expanded); + }); + // If only excludes specified, start from all known props and subtract + var resolved; + if (includes.length === 0 && excludes.length > 0) { + var allProps = typeof Mirror !== 'undefined' ? Mirror.getKnownProps() : + ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer', + 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max', + 'statusmarkers', 'tint_color', 'name', 'light_radius', 'light_dimradius', 'baseOpacity', 'currentSide']; + resolved = allProps.filter(function(p) { return excludes.indexOf(p) === -1; }); + } else { + resolved = includes.filter(function(p) { return excludes.indexOf(p) === -1; }); + } + return resolved.filter(function(p, i) { return resolved.indexOf(p) === i; }); // dedupe + }; + // ========================================================================= // Token Linking Resolution // ========================================================================= @@ -543,37 +664,61 @@ var Gaslight = Gaslight || (() => { var ids = tokens.map(function(t) { return t.get('id'); }); - 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')); - }); - } else { - // Player-controlled: chain-link master + controlling players' pages - // Non-controlling player pages become children of one chain member - 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; }); - - // Chain-link the peer tokens - var chainIds = chainTokens.map(function(t) { return t.get('id'); }); - if (chainIds.length >= 2) { - Anchor.chainAnchorObjs(chainIds); + // Check gaslight_sync attribute + var syncProps = getGaslightSync(repCharId); + // syncProps: null = default (base spatial), '' = no sync at all, array = specific + + // If empty string, skip all linking for this group + if (syncProps === '') return; + + // Determine which props go to Anchor vs Mirror + var allAnchorProps = ['left', 'top', 'rotation', 'width', 'height', 'flipv', 'fliph', 'layer']; + var needsAnchor = true; + var anchorComponents = null; // null = use Anchor defaults + var mirrorProps = null; // null = all non-anchor + if (Array.isArray(syncProps)) { + var anchorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) !== -1; }); + var mirrorRequested = syncProps.filter(function(p) { return allAnchorProps.indexOf(p) === -1; }); + needsAnchor = anchorRequested.length > 0; + // Pass specific components to Anchor if not the full default set + if (needsAnchor) { + anchorComponents = {}; + anchorRequested.forEach(function(p) { anchorComponents[p] = true; }); } + mirrorProps = mirrorRequested.length > 0 ? mirrorRequested : false; + } - // Non-controlling player page tokens become children of the first chain member - if (childTokens.length > 0 && chainTokens.length > 0) { - var chainParent = chainTokens[0]; - childTokens.forEach(function(t) { - Anchor.anchorObj(t.get('id'), chainParent.get('id')); + // 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); + }); + } } } @@ -592,6 +737,18 @@ var Gaslight = Gaslight || (() => { } }); + // Set up Mirror chain for non-spatial property sync + if (typeof Mirror !== 'undefined' && mirrorProps !== false) { + if (mirrorProps === null) { + // Default: sync all minus whatever Anchor is handling + var mirrorExcludes = anchorComponents ? Object.keys(anchorComponents) : allAnchorProps; + Mirror.chainLink(ids, null, mirrorExcludes); + } else if (Array.isArray(mirrorProps) && mirrorProps.length > 0) { + // Specific non-spatial props + Mirror.chainLink(ids, mirrorProps); + } + } + // Track links for merge teardown ids.forEach(function(id) { if (!active.linkedTokens[id]) active.linkedTokens[id] = []; @@ -608,6 +765,114 @@ var Gaslight = Gaslight || (() => { // Commands // ========================================================================= + /** + * Quick setup: auto-configure a group from duplicate pages. + * !gaslight setup [--selected | player1 player2 ...] + * Expects N+1 pages with the same name (or name prefix). Assigns master + players. + */ + const doSetup = (msg, args) => { + if (args.length < 1) { reply(msg, 'Error', 'Usage: !gaslight setup <group_name> [--selected | player names...]'); return; } + var groupName = args.shift(); + + // Determine players: selected tokens + named args, fallback to party tags + var playerIds = []; + + // From selected tokens + if (msg.selected && msg.selected.length > 0) { + msg.selected.forEach(function(sel) { + var obj = getObj(sel._type, sel._id); + if (!obj) return; + var charId = obj.get('represents'); + if (!charId) return; + var character = getObj('character', charId); + if (!character) return; + var cb = character.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + // From named args + args.forEach(function(name) { + var resolved = resolvePlayer(msg, name, CMD + ' setup ' + groupName); + if (resolved && resolved !== 'ambiguous' && resolved.id !== 'GM') { + if (playerIds.indexOf(resolved.id) === -1) playerIds.push(resolved.id); + } + }); + + // Fallback: party-tagged characters (only if no selected and no args) + if (playerIds.length === 0) { + var characters = findObjs({ _type: 'character' }); + characters.forEach(function(c) { + var tags = c.get('tags') || ''; + if (!tags.toLowerCase().includes('party')) return; + var cb = c.get('controlledby') || ''; + if (cb && cb !== 'all') { + cb.split(',').filter(Boolean).forEach(function(pid) { + if (playerIds.indexOf(pid) === -1) playerIds.push(pid); + }); + } + }); + } + + if (playerIds.length === 0) { reply(msg, 'Error', 'No players found. Use --selected, provide names, or tag party characters.'); return; } + + // Find the master page (where selected token is, or current player page) + var masterPageId = resolvePageId(msg, []); + var masterPage = getObj('page', masterPageId); + if (!masterPage) { reply(msg, 'Error', 'Could not determine master page. Select a token on the master page.'); return; } + var masterName = masterPage.get('name'); + + // Find candidate pages: same base name (strip recursive "Copy of " prefixes), or already has this group's config + var allPages = findObjs({ _type: 'page' }); + var stripCopyOf = function(name) { + while (name.indexOf('Copy of ') === 0) name = name.slice(8); + return name; + }; + var candidates = allPages.filter(function(p) { + var name = stripCopyOf(p.get('name')); + if (name === masterName) return true; + // Check if page already has config for this group + var cfg = getGroupConfigOnPage(p.get('_id'), groupName); + if (cfg) return true; + return false; + }); + + // We need N+1 pages (1 master + N players) + var needed = playerIds.length + 1; + if (candidates.length < needed) { + reply(msg, 'Error', 'Found ' + candidates.length + ' page(s) named "' + masterName + '..." but need ' + needed + ' (1 master + ' + playerIds.length + ' players). Duplicate the page ' + (needed - candidates.length) + ' more time(s).'); + return; + } + + // Assign: first candidate = master, rest = players (arbitrary order) + var masterCandidate = candidates.find(function(p) { return p.get('_id') === masterPageId; }) || candidates[0]; + var playerCandidates = candidates.filter(function(p) { return p.get('_id') !== masterCandidate.get('_id'); }).slice(0, playerIds.length); + + // Rename and configure + masterCandidate.set('name', masterName + ' (master)'); + setConfigOnPage(masterCandidate.get('_id'), groupName, { player: 'GM' }); + + var assignments = []; + playerIds.forEach(function(pid, i) { + var page = playerCandidates[i]; + var player = getObj('player', pid); + var playerName = player ? player.get('_displayname') : pid; + page.set('name', masterName + ' (' + playerName + ')'); + setConfigOnPage(page.get('_id'), groupName, { player: playerName, playerid: pid }); + assignments.push(playerName + ' → ' + page.get('name')); + }); + + var out = 'Group "' + groupName + '" set up:
'; + out += 'Master: ' + masterCandidate.get('name') + '
'; + out += assignments.join('
'); + out += '

Run !gaslight test ' + groupName + ' to verify, then !gaslight split ' + groupName + ' to activate.'; + reply(msg, 'Setup', out); + }; + const doSplit = (msg, args) => { var force = args.indexOf('--force') !== -1; args = args.filter(function(a) { return a !== '--force'; }); @@ -681,6 +946,26 @@ var Gaslight = Gaslight || (() => { } summary += formatWarnings(globalWarnings); reply(msg, 'Split', summary); + + // Focus-ping each player to their character token on their page + setTimeout(function() { + Object.entries(groupInfo.players).forEach(function(entry) { + var playerId = entry[0], pInfo = entry[1]; + // Find a token on the player's page that they control + var playerTokens = findObjs({ _type: 'graphic', _pageid: pInfo.pageId, _subtype: 'token' }); + var charToken = playerTokens.find(function(t) { + var charId = t.get('represents'); + if (!charId) return false; + var character = getObj('character', charId); + if (!character) return false; + var cb = character.get('controlledby') || ''; + return cb === 'all' || cb.split(',').indexOf(playerId) !== -1; + }); + if (charToken) { + sendPing(charToken.get('left'), charToken.get('top'), pInfo.pageId, playerId, true, [playerId]); + } + }); + }, 500); }; const doMerge = (msg, args) => { @@ -701,6 +986,14 @@ var Gaslight = Gaslight || (() => { }); allLinkedIds.forEach(function(id) { Anchor.removeAnchor(id); }); } + if (typeof Mirror !== 'undefined') { + var allIds = new Set(); + Object.keys(active.linkedTokens).forEach(function(id) { allIds.add(id); }); + Object.values(active.linkedTokens).forEach(function(ids) { + ids.forEach(function(id) { allIds.add(id); }); + }); + allIds.forEach(function(id) { Mirror.unlink([id]); }); + } var psp = Campaign().get('playerspecificpages') || {}; Object.keys(active.playerPages).forEach(function(playerId) { @@ -845,6 +1138,382 @@ var Gaslight = Gaslight || (() => { reply(msg, 'Config', 'Page "' + pageName + '" (' + pageId + ') assigned to group "' + groupName + '" for ' + resolved.name + '.'); }; + /** + * Set the current view mode. + * !gaslight view [player|master] + */ + const doView = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + // Show current view + var current = s.view ? Object.values(s.activeGroups).reduce(function(name, g) { + if (name) return name; + var entry = g.playerPages[s.view]; + return entry ? entry.name : null; + }, null) || s.view : 'master'; + reply(msg, 'View', 'Current view: ' + current + ''); + return; + } + var arg = args.join(' ').replace(/^["']|["']$/g, ''); + if (arg.toLowerCase() === 'master' || arg.toLowerCase() === 'gm') { + s.view = null; + reply(msg, 'View', 'Switched to master view. Commands target master tokens; use !gaslight relay for player targeting.'); + } else { + // Resolve player + var resolved = resolvePlayer(msg, arg, CMD + ' view'); + if (!resolved || resolved === 'ambiguous') return; + s.view = resolved.id; + reply(msg, 'View', 'Switched to ' + resolved.name + ' view. Commands will auto-target their linked tokens.'); + } + }; + + /** + * Relay a command to linked tokens on specific views. + * !gaslight relay + * Views: player names, "all", "master"/"GM" + */ + const doRelay = (msg, args) => { + var s = state[SCRIPT_NAME]; + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) to relay from.'); return; } + + // Split args: views are everything before first command-prefixed arg (! # %), command is the rest + var views = []; + var commandArgs = []; + var foundCmd = false; + args.forEach(function(a) { + if (!foundCmd && (a.startsWith('!') || a.startsWith('#') || a.startsWith('%'))) foundCmd = true; + if (foundCmd) commandArgs.push(a); + else views.push(a); + }); + + if (views.length === 0) { reply(msg, 'Error', 'Specify view target(s): player names, "all", or "master". Usage: !gaslight relay <views> <!command>'); return; } + if (commandArgs.length === 0) { reply(msg, 'Error', 'No command provided. Command must start with !, #, or %'); return; } + var command = commandArgs.join(' '); + + // Resolve views + var includeMaster = false; + var targetPlayerIds = []; + views.forEach(function(v) { + var lower = v.toLowerCase().replace(/^["']|["']$/g, ''); + if (lower === 'all') { + targetPlayerIds = Object.keys(s.activeGroups).reduce(function(acc, gn) { + return acc.concat(Object.keys(s.activeGroups[gn].playerPages)); + }, []); + includeMaster = true; + } else if (lower === 'master' || lower === 'gm') { + includeMaster = true; + } else { + // Resolve as player name + Object.values(s.activeGroups).forEach(function(active) { + Object.entries(active.playerPages).forEach(function(entry) { + if (entry[1].name && entry[1].name.toLowerCase() === lower) { + if (targetPlayerIds.indexOf(entry[0]) === -1) targetPlayerIds.push(entry[0]); + } + }); + }); + } + }); + targetPlayerIds = targetPlayerIds.filter(function(id, i) { return targetPlayerIds.indexOf(id) === i; }); + + var sender = 'player|' + msg.playerid; + + var relayed = executeRelay(sender, tokens, command, targetPlayerIds, includeMaster); + reply(msg, 'Relay', 'Relayed to ' + relayed + ' token(s).'); + }; + + /** + * Shared relay execution: sends command to linked tokens on target pages. + * Returns number of tokens relayed to. + */ + /** + * Find all Roll20 IDs (starting with -) in a command string that match linked tokens. + * Returns { found: [{id, linkedIds}], hasIds: bool } + */ + const findLinkedIdsInCommand = (command, activeGroups) => { + var idRx = /-[A-Za-z0-9_-]{19}/g; + var matches = command.match(idRx) || []; + var found = []; + matches.forEach(function(id) { + var linkedIds = []; + Object.values(activeGroups).forEach(function(active) { + var allLinked = active.linkedTokens[id] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(id) !== -1) { + allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + } + }); + allLinked = allLinked.filter(function(lid, i) { return allLinked.indexOf(lid) === i && lid !== id; }); + linkedIds = linkedIds.concat(allLinked); + }); + if (linkedIds.length > 0) found.push({ id: id, linkedIds: linkedIds }); + }); + return { found: found, hasIds: found.length > 0 }; + }; + + /** + * Path 2: Replace token IDs in command with linked counterparts per target page, emit immediately. + */ + const relayByIdReplacement = (sender, command, activeGroups, targetPlayerIds) => { + var idInfo = findLinkedIdsInCommand(command, activeGroups); + if (!idInfo.hasIds) return 0; + + var relayed = 0; + targetPlayerIds.forEach(function(playerId) { + var newCmd = command; + idInfo.found.forEach(function(entry) { + // Find the linked token that's on this player's page + var targetId = null; + Object.values(activeGroups).forEach(function(active) { + if (targetId) return; + var playerPage = active.playerPages[playerId]; + if (!playerPage) return; + entry.linkedIds.forEach(function(lid) { + if (targetId) return; + var obj = getObj('graphic', lid); + if (obj && obj.get('_pageid') === playerPage.pageId) targetId = lid; + }); + }); + if (targetId) newCmd = newCmd.replace(entry.id, targetId); + }); + if (newCmd !== command) { + sendChat(sender, newCmd); + relayed++; + } + }); + return relayed; + }; + + /** + * Path 1: Queue commands for execution when GM visits the target page. + */ + const queueRelay = (sender, tokens, command, targetPlayerIds) => { + var s = state[SCRIPT_NAME]; + if (!s.relayQueue) s.relayQueue = {}; + var tokenIds = tokens.map(function(t) { return t.get('id'); }); + var newlyQueued = 0; + + targetPlayerIds.forEach(function(playerId) { + // Find the linked token IDs for this player page + var linkedIds = []; + Object.values(s.activeGroups).forEach(function(active) { + var playerPage = active.playerPages[playerId]; + if (!playerPage) return; + tokenIds.forEach(function(tokenId) { + var allLinked = active.linkedTokens[tokenId] || []; + Object.entries(active.linkedTokens).forEach(function(entry) { + if (entry[1].indexOf(tokenId) !== -1) allLinked = allLinked.concat([entry[0]]).concat(entry[1]); + }); + allLinked.filter(function(id) { + var obj = getObj('graphic', id); + return obj && obj.get('_pageid') === playerPage.pageId; + }).forEach(function(id) { + if (linkedIds.indexOf(id) === -1) linkedIds.push(id); + }); + }); + }); + + if (linkedIds.length > 0) { + // Queue for when GM visits the page + var pageId = null; + Object.values(s.activeGroups).forEach(function(active) { + var pp = active.playerPages[playerId]; + if (pp) pageId = pp.pageId; + }); + if (pageId) { + if (!s.relayQueue[pageId]) s.relayQueue[pageId] = []; + s.relayQueue[pageId].push({ sender: sender, command: command, selectIds: linkedIds }); + newlyQueued++; + } + } + }); + + if (newlyQueued > 0) { + var totalPages = Object.keys(s.relayQueue).filter(function(pid) { return s.relayQueue[pid].length > 0; }).length; + sendChat(SCRIPT_NAME, '/w gm Queued for ' + newlyQueued + ' page(s). Total pending: ' + totalPages + '. Navigate to player pages to execute.'); + } + }; + + const executeRelay = (sender, tokens, command, targetPlayerIds, includeMaster) => { + var s = state[SCRIPT_NAME]; + var relayed = 0; + + if (includeMaster) { + var masterIds = tokens.map(function(t) { return t.get('id'); }); + sendChat(sender, command + ' {& select ' + masterIds.join(', ') + '}'); + relayed += masterIds.length; + } + + if (targetPlayerIds.length > 0) { + // Path 2: try ID replacement first (works cross-page) + var idRelayed = relayByIdReplacement(sender, command, s.activeGroups, targetPlayerIds); + if (idRelayed > 0) { + relayed += idRelayed; + } else { + // Path 1: queue for when GM visits page (selection-based) + queueRelay(sender, tokens, command, targetPlayerIds); + } + } + + return relayed; + }; + + /** + * Poll _lastpage to fire queued relay commands when GM arrives on a target page. + */ + const pollRelayQueue = () => { + var s = state[SCRIPT_NAME]; + if (!s.relayQueue) return; + + var gmPlayers = findObjs({ _type: 'player' }).filter(function(p) { return playerIsGM(p.get('_id')); }); + gmPlayers.forEach(function(gm) { + var lastPage = gm.get('_lastpage'); + if (!lastPage) return; + var queue = s.relayQueue[lastPage]; + if (!queue || queue.length === 0) return; + + // Fire all queued commands for this page + queue.forEach(function(entry) { + sendChat(entry.sender, entry.command + ' {& select ' + entry.selectIds.join(', ') + '}'); + }); + delete s.relayQueue[lastPage]; + }); + }; + + /** + * Stage selected tokens: duplicate to player pages and link. + * !gaslight stage [playerName1 playerName2 ...] + */ + const doStage = (msg, args) => { + var s = state[SCRIPT_NAME]; + var tokens = (msg.selected || []).map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) { reply(msg, 'Error', 'Select token(s) to stage.'); return; } + + // Find which active group this page belongs to + var pageId = tokens[0].get('_pageid'); + var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId || Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); }); + if (!activeEntry) { reply(msg, 'Error', 'Token is not on an active gaslit page.'); return; } + var groupName = activeEntry[0]; + var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; + + // Determine target players + var targetPlayerIds = []; + if (args.length > 0) { + args.forEach(function(name) { + var resolved = Object.entries(groupInfo.players).find(function(e) { + return e[1].name && e[1].name.toLowerCase() === name.toLowerCase(); + }); + if (resolved) targetPlayerIds.push(resolved[0]); + else reply(msg, 'Warning', 'Player "' + name + '" not found in group.'); + }); + } else { + targetPlayerIds = Object.keys(groupInfo.players); + } + + if (targetPlayerIds.length === 0) { reply(msg, 'Error', 'No valid target players.'); return; } + + var staged = 0; + tokens.forEach(function(token) { + var sourcePageId = token.get('_pageid'); + var targetPages = targetPlayerIds + .map(function(pid) { return groupInfo.players[pid].pageId; }) + .filter(function(pid) { return pid !== sourcePageId; }); + // Include master if source is not master + if (sourcePageId !== groupInfo.master) targetPages.push(groupInfo.master); + staged += stageTokenToPages(token, targetPages); + }); + + // Re-run linking for this group to pick up the new tokens + if (staged > 0) { + var groupDiscovered = discoverGroup(groupName); + var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + }); + var allLinks = []; + Object.values(groupDiscovered.players).forEach(function(pInfo) { + var links = resolveLinks(groupDiscovered.master, pInfo.pageId); + links.forEach(function(l) { if (l.target) allLinks.push(l); }); + }); + establishLinks(groupName, groupDiscovered, allLinks); + } + + reply(msg, 'Stage', 'Staged ' + staged + ' token(s) to ' + targetPlayerIds.length + ' player page(s).'); + }; + + /** + * Auto-stage: when a token is added to a gaslit page and its character has gaslight_stage=1. + */ + const onTokenAdded = (obj) => { + var s = state[SCRIPT_NAME]; + var charId = obj.get('represents'); + if (!charId) return; + + // Check gaslight_stage attribute + var attr = findObjs({ _type: 'attribute', _characterid: charId, name: 'gaslight_stage' })[0]; + if (!attr || attr.get('current') !== '1') return; + + // Find which active group this page belongs to + var pageId = obj.get('_pageid'); + var activeEntry = Object.entries(s.activeGroups).find(function(e) { + if (e[1].masterPageId === pageId) return true; + return Object.values(e[1].playerPages).some(function(p) { return p.pageId === pageId; }); + }); + if (!activeEntry) return; + + var groupName = activeEntry[0]; + var groupInfo = { master: activeEntry[1].masterPageId, players: activeEntry[1].playerPages }; + + // Clone to all OTHER pages (master + players, excluding source page) + var targetPages = []; + if (pageId !== groupInfo.master) targetPages.push(groupInfo.master); + Object.values(groupInfo.players).forEach(function(pInfo) { + if (pInfo.pageId !== pageId) targetPages.push(pInfo.pageId); + }); + stageTokenToPages(obj, targetPages); + + // Re-link after a short delay to let createObj finish + setTimeout(function() { + var groupDiscovered = discoverGroup(groupName); + var allPageIds = [groupDiscovered.master].concat(Object.values(groupDiscovered.players).map(function(p) { return p.pageId; })); + allPageIds.forEach(function(pid) { + findObjs({ _type: 'graphic', _pageid: pid, _subtype: 'token' }).forEach(autoPopulateLinkId); + }); + var allLinks = []; + Object.values(groupDiscovered.players).forEach(function(pInfo) { + var links = resolveLinks(groupDiscovered.master, pInfo.pageId); + links.forEach(function(l) { if (l.target) allLinks.push(l); }); + }); + establishLinks(groupName, groupDiscovered, allLinks); + }, 500); + }; + + const doConfig = (msg, args) => { + var s = state[SCRIPT_NAME]; + if (args.length === 0) { + var cmds = s.config.relayCommands.length > 0 ? s.config.relayCommands.join(', ') : '(none)'; + reply(msg, 'Config', 'relay-commands: ' + cmds); + return; + } + var sub = args.shift(); + if (sub === 'relay-add') { + if (args.length === 0) { reply(msg, 'Error', 'Specify command(s) to add.'); return; } + args.forEach(function(cmd) { + if (s.config.relayCommands.indexOf(cmd) === -1) s.config.relayCommands.push(cmd); + }); + reply(msg, 'Config', 'relay-commands: ' + s.config.relayCommands.join(', ')); + } else if (sub === 'relay-remove') { + if (args.length === 0) { reply(msg, 'Error', 'Specify command(s) to remove.'); return; } + s.config.relayCommands = s.config.relayCommands.filter(function(c) { return args.indexOf(c) === -1; }); + reply(msg, 'Config', 'relay-commands: ' + (s.config.relayCommands.length > 0 ? s.config.relayCommands.join(', ') : '(none)')); + } else if (sub === 'relay-list') { + var cmds = s.config.relayCommands.length > 0 ? s.config.relayCommands.join(', ') : '(none)'; + reply(msg, 'Config', 'relay-commands: ' + cmds); + } else { + reply(msg, 'Error', 'Usage: !gaslight config [relay-add|relay-remove|relay-list] [commands...]'); + } + }; + const doStatus = (msg) => { const s = state[SCRIPT_NAME]; const groups = Object.keys(s.activeGroups); @@ -997,6 +1666,7 @@ var Gaslight = Gaslight || (() => { 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; @@ -1004,6 +1674,19 @@ var Gaslight = Gaslight || (() => { case 'unlink': doUnlink(msg, args); break; case 'group': doGroup(msg, args); break; case 'ungroup': doUngroup(msg, args); break; + case 'relay': doRelay(msg, args); break; + case 'view': doView(msg, args); break; + case 'stage': doStage(msg, args); break; + case 'config': doConfig(msg, args); break; + case 'test-relay': { + // Temporary: test sendChat with {& select} + var testId = args[0] || ''; + if (!testId) { reply(msg, 'Error', 'Provide a token ID'); break; } + var testCmd = '!token-mod --set bar1_value|42 {& select ' + testId + '}'; + log(SCRIPT_NAME + ': test-relay sending: ' + testCmd); + sendChat(getPlayerName(msg.playerid), testCmd); + break; + } case 'status': doStatus(msg); break; case '--help': reply(msg, HELP_TEXT); break; default: reply(msg, HELP_TEXT); break; @@ -1020,8 +1703,108 @@ var Gaslight = Gaslight || (() => { checkDanglingGroups(); }; + /** + * When a linked token is deleted, delete its counterparts on other pages. + */ + var destroying = false; + const onTokenDestroyed = (obj) => { + if (destroying) return; + var s = state[SCRIPT_NAME]; + var tokenId = obj.get('id'); + + // Find if this token is tracked in any active group + var linkedIds = null; + Object.values(s.activeGroups).forEach(function(active) { + if (active.linkedTokens[tokenId]) { + linkedIds = active.linkedTokens[tokenId]; + // Clean up tracking + delete active.linkedTokens[tokenId]; + linkedIds.forEach(function(id) { + if (active.linkedTokens[id]) { + active.linkedTokens[id] = active.linkedTokens[id].filter(function(lid) { return lid !== tokenId; }); + } + }); + } else { + // Check if it's in someone else's list + Object.entries(active.linkedTokens).forEach(function(entry) { + var idx = entry[1].indexOf(tokenId); + if (idx !== -1) { + entry[1].splice(idx, 1); + if (!linkedIds) linkedIds = [entry[0]].concat(entry[1].filter(function(id) { return id !== tokenId; })); + } + }); + } + }); + + if (!linkedIds || linkedIds.length === 0) return; + + // Remove Anchor/Mirror links and delete counterparts + destroying = true; + linkedIds.forEach(function(id) { + if (typeof Anchor !== 'undefined') Anchor.removeAnchor(id); + if (typeof Mirror !== 'undefined') Mirror.unlink([id]); + var target = getObj('graphic', id); + if (target) target.remove(); + }); + destroying = false; + }; + + /** + * In any active view mode, intercept non-gaslight API commands and re-emit + * with linked player tokens as selection via SelectManager. + * Master view: relay to ALL player pages. + * Player view: relay to that player's page only. + */ + const viewInterceptor = (msg) => { + if (msg.type !== 'api') return; + var s = state[SCRIPT_NAME]; + if (Object.keys(s.activeGroups).length === 0) return; + var firstWord = msg.content.split(' ')[0]; + if (firstWord === CMD || firstWord === '!mirror' || firstWord === '!anchor') return; + if (!msg.selected || msg.selected.length === 0) return; + if (msg.content.indexOf('{& select') !== -1) return; + + var tokens = msg.selected.map(function(sel) { return getObj(sel._type, sel._id); }).filter(Boolean); + if (tokens.length === 0) return; + + var pageId = tokens[0].get('_pageid'); + var isGM = playerIsGM(msg.playerid); + + // Case 1: GM on master page — relay based on view + if (isGM) { + var activeEntry = Object.entries(s.activeGroups).find(function(e) { return e[1].masterPageId === pageId; }); + if (!activeEntry) return; + + var viewPlayerId = s.view; + var targetPlayerIds = viewPlayerId ? [viewPlayerId] : Object.keys(activeEntry[1].playerPages); + executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, false); + return; + } + + // Case 2: Player on their page — relay if command is in relay-commands list + if (s.config.relayCommands.indexOf(firstWord) === -1) return; + + // Find which group/player this page belongs to + var activeEntry = null; + var sourcePlayerId = null; + Object.entries(s.activeGroups).forEach(function(e) { + Object.entries(e[1].playerPages).forEach(function(pp) { + if (pp[1].pageId === pageId) { activeEntry = e; sourcePlayerId = pp[0]; } + }); + }); + if (!activeEntry) return; + + // Relay to all OTHER player pages + master + var targetPlayerIds = Object.keys(activeEntry[1].playerPages).filter(function(id) { return id !== sourcePlayerId; }); + executeRelay('player|' + msg.playerid, tokens, msg.content, targetPlayerIds, true); + }; + const registerEventHandlers = () => { on('chat:message', handleInput); + on('chat:message', viewInterceptor); + on('add:graphic', onTokenAdded); + on('destroy:graphic', onTokenDestroyed); + setInterval(pollRelayQueue, 500); }; return { checkInstall, registerEventHandlers }; diff --git a/Gaslight/README.md b/Gaslight/README.md index 06a5aca1fd..f2886ce638 100644 --- a/Gaslight/README.md +++ b/Gaslight/README.md @@ -1,68 +1,85 @@ # Gaslight -Per-player map perception for Roll20. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things while token movement stays consistent across all copies. +Per-player map perception for Roll20. Split players onto individual copies of a page with tokens synchronized via Anchor and Mirror. Each player can see different things while token movement and properties stay consistent across all copies. ## Requirements - Roll20 Pro subscription (API access required) -- [Anchor](https://github.com/Roll20/roll20-api-scripts/tree/master/Anchor) (cross-page position sync) +- [Anchor](https://github.com/Roll20/roll20-api-scripts/tree/master/Anchor) (spatial sync) +- [Mirror](https://github.com/Roll20/roll20-api-scripts/tree/master/Mirror) (property sync) +- [SelectManager](https://github.com/Roll20/roll20-api-scripts/tree/master/SelectManager) (command relay) ## Use Cases - **Illusions**: One player sees a bridge, another sees empty air - **Shapechangers**: A disguised NPC looks different to a player with truesight -- **Stealth/Perception**: A stealthing creature is invisible on most maps, semi-transparent for a perceptive player +- **Stealth/Perception**: Per-player visibility based on perception rolls - **Madness/Hallucinations**: A player sees enemies that aren't there - **Secrets**: Information visible to only one player ## Quick Start -1. Create your master page and duplicate it once per player -2. On each page, select a token and assign the page to a group: - - Master: `!gaslight group mygroup GM` - - Player pages: `!gaslight group mygroup PlayerName` -3. Dry-run to verify linking: `!gaslight test mygroup` -4. Activate: `!gaslight split mygroup` -5. When done: `!gaslight merge` +1. Create your master page +2. Duplicate it once per player (Roll20's built-in Duplicate Page) +3. Select party tokens, run: `!gaslight setup mygroup` +4. Verify: `!gaslight test mygroup` +5. Activate: `!gaslight split mygroup` +6. When done: `!gaslight merge` ## Commands | Command | Description | |---------|-------------| -| `!gaslight split ` | Activate group (test-first; blocks on errors, prompts on warnings) | -| `!gaslight split --force` | Activate group (skip test, split immediately) | -| `!gaslight merge [group]` | Tear down links, return players to shared page | -| `!gaslight test ` | Dry-run linking resolution, report results | -| `!gaslight link [name\|new] [ids...]` | Manually link tokens across pages | -| `!gaslight unlink [ids...]` | Remove gaslight_link from tokens | -| `!gaslight unlink --group ` | Remove all links in a group | +| `!gaslight setup ` | Quick-configure from duplicate pages | +| `!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 group ` | Assign page to group | | `!gaslight ungroup ` | Remove page from group | -| `!gaslight status` | Show configured and active groups | +| `!gaslight stage [players...]` | Propagate tokens to player pages | +| `!gaslight view [player\|master]` | Switch relay view | +| `!gaslight relay ` | Relay command to views | +| `!gaslight config [relay-add\|relay-remove\|relay-list] [cmds]` | Configure relay | +| `!gaslight status` | Show state | | `!gaslight --help` | Command reference | ## Token Linking -Gaslight automatically links tokens across pages using a 4-step cascade: +4-step cascade: +1. **`gaslight_link` in token GM notes** — explicit link ID +2. **`represents` + `name`** — unique pair per page +3. **`represents` + fingerprint** — position + bars for duplicates +4. **No match** — warning to GM -1. **`gaslight_link` in token GM notes** -- Explicit link ID (set via `!gaslight link` or auto-populated from character attribute). No character sheet required. -2. **`represents` + `name`** -- Unique character+name pair per page. -3. **`represents` + fingerprint** -- Position, size, rotation, and bar values for disambiguating duplicates. -4. **No match** -- Warning whispered to GM. +## Sync Behavior -After split, all linked tokens have `gaslight_link` IDs written to their GM notes for instant re-linking on future splits. +Controlled by `gaslight_sync` character attribute: +- **Absent** → Anchor (spatial) + Mirror (all non-spatial) +- **Empty** → no sync at all +- **`"base"`** → Anchor only (position, rotation, scale, flip) +- **`"base, bars, light"`** → Anchor + Mirror for bars/light +- **`"!anchor"`** → Mirror everything except spatial +- **`"anchor, !left"`** → Anchor minus left, Mirror nothing extra -## Sync Behavior +## Command Relay -- **NPC tokens** (no player controller): Parent on master page, children on player pages. GM moves NPCs from master. -- **Player tokens** (one controller in group): Parent on player's page, children on master + other pages. Player moves their own token. -- **GM override**: GM can move any token on the master page -- propagates to the parent automatically. -- **Sight**: All child tokens have sight stripped. Only the parent (on the player's own page) retains vision. +Commands run on master page auto-relay to player pages: +- **Path 1 (IDs in command)**: immediate cross-page via ID replacement +- **Path 2 (selection only)**: queued, fires when GM navigates to target page -## Configuration Storage +Configure player auto-relay: `!gaslight config relay-add !token-mod` -Group config is stored as text objects on the GM layer of each page (visible when viewing that layer). Format: +## Staging + +- `!gaslight stage` — propagate selected tokens to all player pages +- `gaslight_stage = 1` character attribute — auto-propagate on placement +- Linked tokens cascade-delete when removed + +## Configuration Storage +Group config stored as text objects on GM layer per page: ``` ---GASLIGHT--- group: mygroup diff --git a/Gaslight/script.json b/Gaslight/script.json index 2ca8524ecf..bf0a45dfef 100644 --- a/Gaslight/script.json +++ b/Gaslight/script.json @@ -6,7 +6,7 @@ "description": "Per-player map perception. Split players onto individual copies of a page with tokens synchronized via Anchor. Each player can see different things (different token art, names, hidden tokens) while token movement stays consistent across all copies.\n\nUse cases: illusions, shapechangers, stealth/perception, madness/hallucinations, secrets.\n\nCommands:\n- `!gaslight split ` -- Activate a gaslight group (test-first)\n- `!gaslight merge [group]` -- Tear down links, return players\n- `!gaslight test ` -- Dry-run linking resolution\n- `!gaslight link [name|new] [ids...]` -- Manually link tokens\n- `!gaslight unlink [ids...]` -- Remove links\n- `!gaslight group ` -- Assign page to group\n- `!gaslight ungroup ` -- Remove page from group\n- `!gaslight status` -- Show current state\n- `!gaslight --help` -- Command reference", "authors": "Kenan Millet", "roll20userid": "2614613", - "dependencies": ["Anchor", "Mirror"], + "dependencies": ["Anchor", "Mirror", "SelectManager"], "modifies": { "graphic": "read, write", "text": "read, write", From f028f92b9c35cb14a259fe0b0354a25298a45317 Mon Sep 17 00:00:00 2001 From: Kenan Millet Date: Mon, 15 Jun 2026 20:33:32 -0400 Subject: [PATCH 38/38] =?UTF-8?q?Gaslight:=20rewrite=20TODO=20=E2=80=94=20?= =?UTF-8?q?v2=20merged=20into=20v1,=20v3=20becomes=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gaslight/TODO.md | 48 ++++++++++++++++++++---------------------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/Gaslight/TODO.md b/Gaslight/TODO.md index 956aaad1f4..2f90899c60 100644 --- a/Gaslight/TODO.md +++ b/Gaslight/TODO.md @@ -2,9 +2,11 @@ ## Done (v1.0.0) - [x] Pre-setup split with test-first behavior -- [x] Merge (tear down Anchor, unassign players) -- [x] Anchor-mode sync (NPC + player tokens) -- [x] GM override (master child -> push to parent) +- [x] Merge (tear down Anchor + Mirror, unassign players) +- [x] Anchor-mode sync (NPC + player tokens via chain-linking) +- [x] Peer sync mode (multi-controller tokens via chain-anchoring) +- [x] Mirror integration (non-spatial property sync) +- [x] Configurable sync properties (gaslight_sync attribute with ! exclusion) - [x] Token linking resolution (4-step cascade) - [x] Manual linking (link/unlink/unlink --group) - [x] Test command (dry-run) @@ -15,47 +17,37 @@ - [x] Startup dangling group warning - [x] Sight stripping on children - [x] Player disambiguation (clickable buttons) - -## Done (v2) -- [x] Peer sync mode (multi-controller tokens via chain-anchoring) -- [x] Configurable sync properties (gaslight_sync attribute with ! exclusion) -- [x] Mirror integration (non-spatial property sync) -- [x] !gaslight stage command (propagate tokens to player pages) -- [x] gaslight_stage character attribute (auto-propagate on add) +- [x] !gaslight stage command + gaslight_stage auto-propagation - [x] Cascade-delete linked tokens -- [x] Anchor chain-linking for player tokens (removed GM override listener) - [x] !gaslight view (master/player view switching) -- [x] !gaslight relay (explicit command relay to views) +- [x] !gaslight relay (explicit command relay with dual-path) - [x] View interceptor (auto-relay commands from master page) -- [x] Player relay-commands (auto-relay configured commands from player pages) +- [x] Player relay-commands config - [x] !gaslight config (relay-add/relay-remove/relay-list) -- [x] Relay preserves selection order -- [x] Relay sends as invoking player (macros/permissions work) -- [x] Interceptor skips !gaslight, !mirror, !anchor -- [x] Loop prevention via {& select} check -- [x] Focus-ping players on split -- [x] Reaction suppression confirmed unnecessary (API moves don't trigger reactions) - [x] !gaslight setup (quick group config from duplicate pages) +- [x] Focus-ping players on split +- [x] Relay Path 2: ID replacement (immediate cross-page) +- [x] Relay Path 1: queue + _lastpage poll (selection-based) -## Needs Testing (v2) +## Needs Testing - [ ] gaslight_sync attribute (all combos: absent, empty, specific, !exclusion) - [ ] Mirror chain setup/teardown on split/merge -- [ ] !gaslight stage + gaslight_stage auto-propagation +- [ ] !gaslight setup workflow end-to-end - [ ] Cascade-delete -- [ ] View interceptor + relay commands -- [ ] Player relay-commands config -- [ ] !gaslight setup workflow +- [ ] View + relay with both paths - [ ] Focus-ping on split -## v3 Ideas +## v2 Ideas - [ ] Config handout (editable in-game, live reload) - [ ] Group/page-level relay-command overrides - [ ] Config visibility toggle (hide gaslight text in HTML comment) - [ ] Near-match suggestions in step 4 warnings -- [ ] Per-status-marker sync granularity (manual for now) +- [ ] Per-status-marker sync granularity - [ ] Replay command (re-run last N commands against different views) -- [ ] Conditional relay / per-player scripting (evaluate conditions per player page, run different commands based on results — e.g. stealth/perception visibility. Stored in pins or handouts as reusable logic scripts.) +- [ ] Conditional relay / per-player scripting (evaluate conditions per player page, run different commands based on results -- e.g. stealth/perception visibility. Stored in pins or handouts as reusable logic scripts.) - [ ] On-demand page cloning (if TruePageCopy exposes API) ## Known Issues -- None currently +- Relay Path 1 (selection-based) requires GM to navigate to target page +- Roll20 limitation: sendChat as player carries their UI selection state +- linkedTokens accumulates duplicates on repeated splits (cosmetic, deduped at use)