From 229d06b7a39bb8bb281096d2604d252d8f38da62 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Wed, 12 Aug 2026 12:01:43 -0700 Subject: [PATCH 1/9] Add files via upload --- Locksmith/1.0.0/Locksmith.js | 2350 ++++++++++++++++++++++++++++++++++ Locksmith/Locksmith.js | 2350 ++++++++++++++++++++++++++++++++++ Locksmith/README.md | 48 + Locksmith/script.json | 17 + 4 files changed, 4765 insertions(+) create mode 100644 Locksmith/1.0.0/Locksmith.js create mode 100644 Locksmith/Locksmith.js create mode 100644 Locksmith/README.md create mode 100644 Locksmith/script.json diff --git a/Locksmith/1.0.0/Locksmith.js b/Locksmith/1.0.0/Locksmith.js new file mode 100644 index 000000000..f32813072 --- /dev/null +++ b/Locksmith/1.0.0/Locksmith.js @@ -0,0 +1,2350 @@ +// Script: Locksmith +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +var Locksmith = Locksmith || (function() { + 'use strict'; + + var VERSION = '1.0.0'; + //Changelog + // 1.0.0 Debut + var SCRIPT_NAME = 'Locksmith'; + + // Grid constants for the proximity check (pixels/square assumed; see + // Geometry below for the door/window axis-inversion assumption) + var PIXELS_PER_SQUARE = 70; + var RANGE_SQUARES = 1.5; // adjacent square plus a safety margin for edge cases/measurement + + // Report cache sizing - reports collapse to a header link, body + // cached here by short id, oldest evicted past this cap + var MAX_CACHED_REPORTS = 50; + // Help handout - find-or-create pattern, same as Chronicle's help + // handout. Reuses the same avatar image used across other scripts. + var HELP_NAME = 'Help: Locksmith'; + var HELP_AVATAR = 'https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147'; + + // Metal-panel background for every chat card - CSS.container below + // references this directly. + var BG_IMAGE_URL = 'https://files.d20.io/images/496924732/hbrOYO3vwmWFoP9KuD8a2w/original.webp?1786261303'; + + // A function, not a static string - the TOC links have to point at + // this handout's own URL (Roll20's internal link format is + // journal.roll20.net/handout//#, not a + // same-page anchor), so the text can't be built until the handout + // object exists and its id is known. + function buildHelpText(handoutId) { + var base = 'http://journal.roll20.net/handout/' + handoutId + '/'; + var tocGM = base + '#For%20GMs'; + var tocPlayers = base + '#For%20Players'; + // Floated, no border - text wraps around the image instead of + // the image breaking the paragraph flow. float (not flexbox) to + // match this project's usual handout/chat rendering approach. + function floatImg(url, alt, caption, side) { + var margin = (side === 'right') ? '4px 0 10px 14px' : '4px 14px 10px 0'; + return '
' + alt + '' + + '
' + caption + '
'; + } + var clear = '
'; + + return ` +
+

Locksmith

+

Locksmith manages locked doors and windows, and automatically resolves lock-picking attempts (Thieves' Tools checks) and the Knock spell against them.

+

Works with both versions of Roll20's D&D 5th Edition sheet - 5e (legacy) and 5.5e (Beacon).

+

Jump to: For GMs  |  For Players

+ +

For GMs

+ +

Setting Up a Lock

+

Command: !lock

+` + floatImg( + 'https://files.d20.io/images/497202465/3wfLwxnDmlSW93c12dIvUA/original.webp?1786471913', + 'The Locksmith menu with several doors selected', + 'The Locksmith menu with a few doors selected - each row shows a status badge plus +Key/+Loot buttons.', + 'right' +) + ` +

Select one or more doors/windows and run the bare command to open the Locksmith menu. The menu has two rows of buttons:

+
    +
  • Selected - Lock, Unlock, and Report act on whatever doors/windows are currently selected. Lock/Unlock toggle Roll20's own locked state. Report shows each selected item's current status (or, with nothing selected, every door/window on the page).
  • +
  • Set - DC, Unpickable, and Magic configure the selected door(s)/window(s). DC prompts for a difficulty (8-50). Unpickable marks it unbeatable by mundane lock-picking. Magic marks it as openable only by magic (e.g. the Knock spell), not mundane picking.
  • +
+

A door or window needs both a DC/Unpickable/Magic setting and Roll20's native locked state turned on before a pick attempt or Knock will do anything with it - a configured-but-unlocked door is treated as just an ordinary unlocked door.

+ +

Show/Hide All Lock DCs

+

Toggles a small text label above every door/window on the current page, showing its status at a glance (a difficulty number, Unpickable, Magic, or Unset). Handy while placing locks; click it again to remove the labels. A label turns yellow if a character with a token currently on that page holds a key for that door/window - a quick way to spot "someone here can already open this." This is only as current as the last time labels were shown or refreshed - giving away a key or moving a token afterward won't update the color until labels are toggled again.

+ +

Report DC

+

!lock --report-dc (also on the menu) lists every selected door/window's status, or every door/window on the page if nothing is selected. Each name is clickable and pings its location for you.

+` + clear + ` + +

How locks are stored

+

Locksmith works off Roll20's standard color property for doors and windows - it doesn't add any extra data to your game. Because of this, manually changing the color of a door or window Locksmith has configured can break its ability to track that lock. If you need to recolor something Locksmith manages, use the menu to reconfigure it afterward rather than hand-editing its color.

+ +

Keys and Keyrings

+

Anywhere a door or window's name is shown as a clickable ping (the menu's selected list, Report DC), there's a +Key button right after it. Clicking it prompts you to pick a token on the map - that token's character receives a key for that door/window, letting them bypass its lock (DC, Unpickable, or Magic) entirely with the Use/Try all Keys buttons on their own !lock --keyring report. Keys, once given, live entirely with the players - there's no further GM management needed unless you want to hand out more.

+

Next to +Key is +Loot, for handing out a key without picking a token yourself - useful for treasure. Clicking it whispers you a ready-to-use macro you can paste into a handout, treasure horde, or macro button. Whoever clicks that pasted macro with their own token selected gets the key automatically - no GM step needed at that point.

+ +

Troubleshooting

+

!lock --coords - select exactly one token and one door/window, and this reports their measured distance. Useful if pick attempts or Knock seem to be missing doors that should be in range.

+ +

For Players

+ +

Picking a Lock

+

There's no special command - just make a Thieves' Tools check as you normally would (from your character sheet) while your token is near the door or window in question. Range: roughly an adjacent square, plus a small safety margin for measurement. Locksmith automatically finds the nearest locked door/window within that range and compares your roll:

+
    +
  • If you beat the DC, you'll see a success message with an Unlock button - click it to actually unlock it.
  • +
  • If you don't beat the DC, you're told your attempt failed, but never the DC itself - only your GM knows how close you came.
  • +
  • If nothing is in range, or the door isn't actually locked, or it's unpickable/magically sealed, you'll be told plainly.
  • +
+ +

Knock

+

Casting Knock works the same way - no extra steps. Range: the spell's own range (60 feet) - Locksmith finds the nearest valid locked door/window within that range and offers the same kind of Unlock button. Knock can open magically-sealed doors that mundane lock-picking can't, but not ones marked Unpickable.

+ +

Keys

+` + floatImg( + 'https://files.d20.io/images/497202464/0QJEpMcZqgG47q1zBtw4rw/original.webp?1786471913', + 'A sample keyring report showing several keys', + 'A sample keyring - Use/Give/Drop per key, plus Try all Keys at the bottom.', + 'right' +) + ` +

If your GM has given your character a key, select your token and run !lock --keyring (or just !keyring) to see what you're holding - only you and your GM will see this. Each key has three buttons:

+
    +
  • Use toggles that specific door/window's locked state if you're close enough - bypassing whatever lock it has entirely.
  • +
  • Give lets you hand the key off to another character by targeting their token.
  • +
  • Drop removes the key from your keyring entirely - nobody receives it, and it'll ask you to confirm first since this can't be undone.
  • +
+

There's also a Try all Keys button that toggles the locked state of the nearest door/window within range that you hold a key for - handy for "I try my keys" moments without picking through your list one by one.

+` + clear + ` +` + floatImg( + 'https://files.d20.io/images/497202466/gLS4JX1BCSkLWPVAy9uF3Q/original.webp?1786471917', + 'The token action created by the Create Keys Token Action button', + 'The resulting "Keys." token action, ready to click from the token itself.', + 'right' +) + ` +

The first time you (or your GM) run !keyring for a character, you'll also see a Create "Keys." Token Action button - this adds a one-click token action so you can run !keyring straight from your token in the future without typing anything. It only shows up if that character doesn't already have one.

+` + clear + ` +
+`; + } + // Logger + var Logger = { + info: function(msg) { log('[' + SCRIPT_NAME + '] ' + msg); }, + warn: function(msg) { log('[' + SCRIPT_NAME + '][WARN] ' + msg); }, + error: function(msg) { log('[' + SCRIPT_NAME + '][ERROR] ' + msg); } + }; + // CSS - centralized style definitions for whispered chat cards + // (float-based layout only; Roll20 chat does not support flexbox) + // No emoji/icons anywhere in this system - text only. + var CSS = { + // Metal-panel background (B1 palette). #8c949b is the image's own + // average tone, kept as a solid fallback while the image loads or + // if it fails to load. The source image is a tileable texture, so + // it's repeated at native size rather than stretched with + // background-size:cover - cover was scaling a small (300x225) + // image up to fill the card, blurring out the fine texture detail + // that tiling preserves. + container: 'style="border:1px solid #17212c;border-radius:6px;' + + 'background-color:#8c949b;background-image:url(\'' + BG_IMAGE_URL + '\');' + + 'background-repeat:repeat;' + + 'padding:0;margin:4px 0;overflow:hidden;' + + 'font-family:proxima-nova,sans-serif;"', + // CONFIRMED live: background:linear-gradient(...) and box-shadow + // do not survive Roll20's chat rendering the way a flat + // background-color does - the whole declaration gets dropped, + // not just downgraded, which is why buttons fell back to Roll20's + // default pink and the header lost its panel entirely (revealing + // the container's image behind it). Flat colors only from here. + header: 'style="background-color:#223142;' + + 'color:#e4eaf0;padding:4px 8px;font-weight:bold;font-size:1.05em;overflow:hidden;"', + headerLink: 'style="display:block;color:#e4eaf0 !important;' + + 'background-color:transparent !important;border:none !important;' + + 'text-decoration:none;font-weight:bold;cursor:pointer;"', + // Same as headerLink, but inline (no display:block) - the menu's + // "Locksmith" title has to share its line with the floated "?" + // help link, and a block-level box there would push that float + // out of position. + headerTitleLink: 'style="color:#e4eaf0 !important;' + + 'background-color:transparent !important;border:none !important;' + + 'text-decoration:none;font-weight:bold;cursor:pointer;"', + // Lower opacity than before (was 0.82, nearly obscuring the + // texture entirely) - just enough tint to keep text legible + // while letting the tiled metal texture actually show through. + body: 'style="padding:4px 6px;background-color:rgba(140,148,155,0.35);"', + row: 'style="padding:3px 0;border-bottom:1px solid #6b7278;overflow:hidden;"', + rowLast: 'style="padding:3px 0;overflow:hidden;"', + label: 'style="float:left;color:#223142;font-weight:bold;"', + value: 'style="float:right;color:#1a1a1a;"', + // Ping links reuse the report's own label/header navy - no new color. + nameLink: 'style="color:#223142 !important;background-color:transparent !important;' + + 'border:none !important;text-decoration:underline;font-weight:bold;"', + // Filled "badge" style - a , not a link, so there's no + // button affordance at all (no href, no clickability); pill + // shape (much more rounded than a button's 4px corners) and no + // border reinforce the same thing visually. Deliberately compact + // (small font, tight padding) and never nowrap - the row already + // has a name and two buttons sharing space with this, so if + // something has to give under a narrow chat panel, it should be + // this text wrapping within its own cell, not the whole row + // forcing horizontal overflow. line-height is tightened since + // "Magic only (Knock)" wraps to two lines and default leading + // looked loose - safe to tighten since all-caps text has no + // ascenders/descenders to clip. + statusPickable: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#2e7d32;"', + statusUnpickable: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#8b0000;"', + statusMagic: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#4527a0;"', + statusUnset: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#777777;"', + footer: 'style="clear:both;padding:3px 8px;font-size:0.8em;' + + 'color:#3f4d5c;border-top:1px solid #6b7278;"', + // Unlock button: the one place a non-neutral color (green, already + // used for statusPickable) is deliberately kept distinct. + button: 'style="display:block;margin:4px 0;padding:6px 8px;' + + 'background-color:#2e7d32 !important;' + + 'color:#fff !important;border:1px solid #143d18 !important;text-align:center;border-radius:4px;' + + 'text-decoration:none;font-weight:bold;"', + // Every other button (Lock/Unlock Selected, Set DC, Report DC, + // Unpickable, Magically Locked) shares this single neutral style - + // the header's own navy, not a distinct color per action. + buttonNeutral: 'style="display:block;margin:4px 0;padding:6px 8px;' + + 'background-color:#223142 !important;' + + 'color:#dfe6ec !important;border:1px solid #17212c !important;text-align:center;border-radius:4px;' + + 'text-decoration:none;font-weight:bold;"', + // Same colors/treatment as buttonNeutral, but inline for the + // grouped two-column menu rows (several buttons side by side). + buttonNeutralInline: 'style="display:inline-block;margin:2px 3px 2px 0;padding:3px 6px;' + + 'background-color:#223142 !important;' + + 'color:#dfe6ec !important;border:1px solid #17212c !important;border-radius:4px;' + + 'text-decoration:none;font-weight:bold;font-size:0.85em;"', + menuGroupLabel: 'style="font-weight:bold;color:#223142;padding:4px 6px 4px 0;' + + 'vertical-align:middle;white-space:nowrap;"', + menuGroupCell: 'style="padding:4px 0;vertical-align:middle;"', + menuTable: 'style="width:100%;border-collapse:collapse;"', + // Raw CSS text (no style="" wrapper) - _buildDoorStatusTable + // combines these with an extra property for the status column, + // and a wrapped constant can't be safely merged with more CSS + // without producing two style="" attributes on one element. + lockListCellCss: 'padding:2px 3px 2px 0;border-bottom:1px solid #6b7278;', + lockListCellLastCss: 'padding:2px 3px 2px 0;', + menuDivider: 'style="border-top:1px solid #6b7278;margin:6px 0;"', + // No border/padding/background - just a plain "?" floated in the + // header bar, unlike every other button in this system. + headerHelpLink: 'style="float:right;color:#e4eaf0 !important;' + + 'background:transparent !important;border:none !important;' + + 'padding:0;margin:0;text-decoration:none;font-weight:bold;cursor:pointer;"', + sectionLabel: 'style="font-weight:bold;color:#223142;padding:4px 0 2px 0;"' + }; + // Core - door/window color/DC encoding logic + var Core = { + MIN_DC: 8, + MAX_DC: 50, + DC_VALUE_OFFSET: 5, // DC = value + 5 + + UNSET_VALUE: 0, + UNPICKABLE_VALUE: 1, + MAGIC_ONLY_VALUE: 2, + MIN_DC_VALUE: 3, // encodes MIN_DC + MAX_DC_VALUE: 45, // encodes MAX_DC + + DOOR_PREFIX: 'FF99', + WINDOW_SUFFIX: 'FFFF', + + // Normalizes a color string to a 6-char uppercase hex string, + // or null if it isn't a recognizable 6-digit hex color. + normalizeColor: function(colorStr) { + if (!colorStr) return null; + var hex = colorStr.replace('#', '').toUpperCase(); + if (/^[0-9A-F]{6}$/.test(hex)) return hex; + return null; + }, + + // Is this door/window color one this script manages? + isInScope: function(type, colorStr) { + var hex = this.normalizeColor(colorStr); + if (!hex) return false; + if (type === 'door') return hex.substring(0, 4) === this.DOOR_PREFIX; + if (type === 'window') return hex.substring(2, 6) === this.WINDOW_SUFFIX; + return false; + }, + + // Pulls the single encoded byte (0-255) out of an in-scope color. + // Caller must have already checked isInScope. + getEncodedByte: function(type, colorStr) { + var hex = this.normalizeColor(colorStr); + var byteHex = (type === 'door') ? hex.substring(4, 6) : hex.substring(0, 2); + return parseInt(byteHex, 16); + }, + + // Rebuilds the full 6-char color string with a new encoded byte. + buildColor: function(type, value) { + var byteHex = value.toString(16).toUpperCase(); + if (byteHex.length < 2) byteHex = '0' + byteHex; + if (type === 'door') return '#' + this.DOOR_PREFIX + byteHex; + return '#' + byteHex + this.WINDOW_SUFFIX; + }, + + valueToDC: function(value) { + return value + this.DC_VALUE_OFFSET; + }, + + // returns null if dc is outside the representable range + dcToValue: function(dc) { + if (dc < this.MIN_DC || dc > this.MAX_DC) return null; + return dc - this.DC_VALUE_OFFSET; + }, + + // -> { state: 'out_of_scope' } | { state: 'unset' } | + // { state: 'unpickable' } | { state: 'magic_only' } | + // { state: 'set', dc: N } | { state: 'unrecognized', raw: N } + getLockStatus: function(type, colorStr) { + if (!this.isInScope(type, colorStr)) return { state: 'out_of_scope' }; + var value = this.getEncodedByte(type, colorStr); + if (value === this.UNSET_VALUE) return { state: 'unset' }; + if (value === this.UNPICKABLE_VALUE) return { state: 'unpickable' }; + if (value === this.MAGIC_ONLY_VALUE) return { state: 'magic_only' }; + if (value >= this.MIN_DC_VALUE && value <= this.MAX_DC_VALUE) { + return { state: 'set', dc: this.valueToDC(value) }; + } + return { state: 'unrecognized', raw: value }; + } + }; + // RollParser - pulls a Thieves' Tools roll's total/mode out of chat + // messages, for either the Beacon or legacy character sheet. + var RollParser = { + + // Letters-only, lowercased - lets us compare "Thieves' Tools", + // "Thieves’ Tools", "Thieves Tools", etc. as equivalent. + normalize: function(str) { + return (str || '').replace(/[^a-z]/gi, '').toLowerCase(); + }, + + // Resolves totals that arrive as unresolved arithmetic + // strings (e.g. "14+2") instead of numbers. Whitelist-only, + // safer than eval. + resolveExpression: function(value) { + if (typeof value === 'number') return value; + if (typeof value !== 'string') return null; + var trimmed = value.trim(); + if (trimmed === '') return null; + var asNumber = Number(trimmed); + if (!isNaN(asNumber)) return asNumber; + if (!/^[\d+\-*/().\s]+$/.test(trimmed)) return null; + try { + /* eslint-disable no-new-func */ + var result = Function('"use strict"; return (' + trimmed + ');')(); + return (typeof result === 'number' && !isNaN(result)) ? result : null; + } catch (e) { + return null; + } + }, + + // Walks a structured `rolls` array for the first real die + // (dice > 0), ignoring flat "0d20+N" stub groups (e.g. + // Reliable Talent wrappers). + findFirstDie: function(rollsArray) { + if (!Array.isArray(rollsArray)) return null; + for (var i = 0; i < rollsArray.length; i++) { + var item = rollsArray[i]; + if (!item) continue; + if (item.type === 'R' && item.dice > 0 && item.results && item.results.length > 0) { + return item.results[0].v; + } + if (item.type === 'G' && Array.isArray(item.rolls)) { + for (var j = 0; j < item.rolls.length; j++) { + var found = this.findFirstDie(item.rolls[j]); + if (found !== null) return found; + } + } + } + return null; + }, + + // --- Beacon (D&D 2024) sheet ----------------------------- + // Extracts the visible roll title, e.g. "Thieves' Tools", from + // the rendered rolltemplate HTML. + extractBeaconTitle: function(content) { + if (!content) return null; + var m = /
\s*([^<]+?)\s*<\/div>/i.exec(content); + return m ? m[1] : null; + }, + + // Extracts the rolling character's name from the HTML meta + // block, e.g. "Callexis". + extractBeaconCharname: function(content) { + if (!content) return null; + var m = /
\s*([^<]+?)\s*<\/div>/i.exec(content); + return m ? m[1] : null; + }, + + // Per-source bonus breakdown only exists in Beacon's HTML, + // not the structured rolls data. + extractBeaconBonuses: function(content) { + var bonuses = []; + if (!content) return bonuses; + var re = /
\s*([^<]*)<\/span>\s*([^<]*)<\/span>\s*<\/div>/gi; + var m; + while ((m = re.exec(content))) { + bonuses.push({ label: m[1].trim(), value: m[2].trim() }); + } + return bonuses; + }, + + // NOTE: rolls.Base.results.total is pre-modifier - the real + // total comes from the HTML die__total spans. Structured data + // is still used for natural/crit/fumble. + extractBeaconRollData: function(msg) { + if (!msg || msg.type !== 'advancedroll' || !msg.content) return null; + + var title = this.extractBeaconTitle(msg.content); + if (this.normalize(title) !== 'thievestools') return null; + + var modeMatch = /dnd-2024__header--(Normal|Advantage|Disadvantage)/i.exec(msg.content); + var mode = modeMatch ? modeMatch[1].toLowerCase() : 'unknown'; + + // First die__total span is always the preferred/kept one; + // a second (if present) is the dropped advantage/disadvantage die. + var dieTotals = []; + var dieTotalRe = / r1Total) { total = r2Total; otherTotal = r1Total; natural = r2Natural; otherNatural = r1Natural; } + else { total = r1Total; otherTotal = r2Total; natural = r1Natural; otherNatural = r2Natural; } + } else if (mode === 'disadvantage') { + if (r2Total < r1Total) { total = r2Total; otherTotal = r1Total; natural = r2Natural; otherNatural = r1Natural; } + else { total = r1Total; otherTotal = r2Total; natural = r1Natural; otherNatural = r2Natural; } + } else { + // normal - r2, if present, is an unused stub + total = r1Total; natural = r1Natural; + } + } + + var modValue = this.extractLegacyModValue(msg.content); + var bonuses = (modValue !== null) + ? [{ label: 'Ability + PB', value: (modValue >= 0 ? '+' : '') + modValue }] + : []; + + var result = { + title: rname, + charname: charname, + mode: mode, + total: total, + otherTotal: otherTotal, + natural: natural, + otherNatural: otherNatural, + isCrit: (natural === 20) || null, + isFumble: (natural === 1) || null, + bonuses: bonuses, + sheet: 'legacy', + playerid: msg.playerid, + characterId: msg.rolledByCharacterId, + needsConfirmation: needsConfirmation + }; + + if (needsConfirmation) { + result.confirmation = { + r1: { total: r1Total, natural: r1Natural }, + r2: r2 ? { total: r2Total, natural: r2Natural } : null, + extraRolls: extraRolls + }; + } + + return result; + }, + + extractNaturalFromInlineRoll: function(inlineRoll) { + if (!inlineRoll || !inlineRoll.results || !Array.isArray(inlineRoll.results.rolls)) return null; + return this.findFirstDie(inlineRoll.results.rolls); + }, + + // Knock is a no-roll spell, detected by title only. + // Beacon: type 'advancedroll', characterId included. + // Legacy: type 'whisper' (sheet whispers to GM by default), + // rolltemplate 'spell', raw macros - NO characterId, only + // {{charname=...}}, resolved downstream by name. + extractKnockCast: function(msg) { + if (!msg || !msg.content) return null; + + if (msg.type === 'advancedroll') { + var title = this.extractBeaconTitle(msg.content); + if (this.normalize(title) !== 'knock') return null; + return { + charname: this.extractBeaconCharname(msg.content), + playerid: msg.playerid, + characterId: msg.characterId + }; + } + + if (msg.rolltemplate === 'spell') { + var nameMatch = /\{\{name=([^}]*)\}\}/i.exec(msg.content); + var spellName = nameMatch ? nameMatch[1].trim() : null; + if (this.normalize(spellName) !== 'knock') return null; + + var charnameMatch = /\{\{charname=([^}]*)\}\}/i.exec(msg.content); + return { + charname: charnameMatch ? charnameMatch[1].trim() : null, + playerid: msg.playerid, + characterId: null + }; + } + + return null; + }, + + // Tries Beacon then legacy; null if neither matches. + extractRollData: function(msg) { + return this.extractBeaconRollData(msg) || this.extractLegacyRollData(msg); + } + }; + // PageUtils + var PageUtils = { + // Used verbatim for all page lookups. + getPageForPlayer: function(playerid) { + var player = getObj('player', playerid); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + var psp = Campaign().get('playerspecificpages') || {}; + if (psp[playerid]) { + return psp[playerid]; + } + return Campaign().get('playerpageid'); + }, + + // Real-world distance per grid square, read from the page itself + // rather than assumed - a page can be set to anything (10ft + // squares, meters, etc), not just the 5ft D&D default. Falls back + // to 5 if the page can't be found or has no scale set. + getFeetPerSquare: function(pageId) { + var page = getObj('page', pageId); + var scale = page && page.get('scale_number'); + return (scale && scale > 0) ? scale : 5; + }, + + // The 70px/square pixel geometry this script assumes throughout + // is a Roll20 platform constant for square grids specifically, + // not something scale_number affects - but it does NOT hold for + // hex grids, which have entirely different cell geometry. This + // only detects the mismatch and lets callers warn; it doesn't + // attempt real hex-grid support. + isSquareGrid: function(pageId) { + var page = getObj('page', pageId); + var gridType = page && page.get('grid_type'); + return !gridType || gridType === 'square'; + } + }; + // Geometry + var Geometry = { + // ASSUMPTION (unverified for x): door/window x/y use an + // inverted axis vs a token's left/top - Roll20's docs only + // confirm this for y (top 100 = y -100). Verify with !lock --coords. + doorPixelPosition: function(doorOrWindowObj) { + return { + left: doorOrWindowObj.get('x'), + top: -doorOrWindowObj.get('y') + }; + }, + + tokenPixelPosition: function(tokenObj) { + return { + left: tokenObj.get('left'), + top: tokenObj.get('top') + }; + }, + + distance: function(pointA, pointB) { + var dx = pointA.left - pointB.left; + var dy = pointA.top - pointB.top; + return Math.sqrt(dx * dx + dy * dy); + }, + + maxRangePixels: function() { + return PIXELS_PER_SQUARE * RANGE_SQUARES; + } + }; + // TokenUtils + var TokenUtils = { + findTokensForCharacter: function(pageId, characterId) { + if (!pageId || !characterId) return []; + return findObjs({ _type: 'graphic', _pageid: pageId, represents: characterId }) || []; + }, + + // For messages with only a charname, no id (legacy Knock). + // First match wins if names collide. + findCharacterIdByName: function(charname) { + if (!charname) return null; + var matches = findObjs({ _type: 'character', name: charname }); + return (matches && matches.length > 0) ? matches[0].id : null; + } + }; + + // Keys and keyrings - a custom character attribute ("keyring") + // storing a comma-separated list of "type:id" tokens (e.g. + // "door:-Nabc123"), one per key held. Type is stored alongside + // the id rather than guessed later by trying both object types. + var KeyringUtils = { + ATTR_NAME: 'keyring', + TOKEN_ACTION_NAME: 'Keys.', + + // The trailing period is intentional - see the comment on + // handleKeyCreateAction for why. + hasTokenAction: function(characterId) { + return findObjs({ _type: 'ability', characterid: characterId, name: this.TOKEN_ACTION_NAME }).length > 0; + }, + + getAttr: function(characterId) { + return findObjs({ _type: 'attribute', _characterid: characterId, name: this.ATTR_NAME })[0] || null; + }, + + // -> [{ type, id }, ...] + getKeys: function(characterId) { + var attr = this.getAttr(characterId); + if (!attr) return []; + var raw = attr.get('current') || ''; + return raw.split(',') + .map(function(s) { return s.trim(); }) + .filter(function(s) { return s.length > 0; }) + .map(function(token) { + var parts = token.split(':'); + return { type: parts[0], id: parts[1] }; + }) + .filter(function(k) { return k.type && k.id; }); + }, + + hasKey: function(characterId, type, id) { + var keys = this.getKeys(characterId); + for (var i = 0; i < keys.length; i++) { + if (keys[i].type === type && keys[i].id === id) return true; + } + return false; + }, + + // returns false if the character already had this key + addKey: function(characterId, type, id) { + if (this.hasKey(characterId, type, id)) return false; + var attr = this.getAttr(characterId); + var tokens = this.getKeys(characterId).map(function(k) { return k.type + ':' + k.id; }); + tokens.push(type + ':' + id); + var newValue = tokens.join(','); + if (attr) { + attr.set('current', newValue); + } else { + createObj('attribute', { _characterid: characterId, name: this.ATTR_NAME, current: newValue }); + } + return true; + }, + + // returns false if the character didn't have this key + removeKey: function(characterId, type, id) { + var attr = this.getAttr(characterId); + if (!attr) return false; + var keys = this.getKeys(characterId); + var filtered = keys.filter(function(k) { return !(k.type === type && k.id === id); }); + if (filtered.length === keys.length) return false; + attr.set('current', filtered.map(function(k) { return k.type + ':' + k.id; }).join(',')); + return true; + } + }; + // DoorFinder - locates the nearest Locksmith-managed door/window + var DoorFinder = { + // Only considers doors/windows Core.isInScope recognizes - + // an unmanaged (non-FF99/FFFF) door is invisible to this feature, + // same as it is to any GM setup command. + findNearestInScopeLock: function(pageId, tokenPoint) { + var candidates = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + + var maxRange = Geometry.maxRangePixels(); + var best = null; + + for (var i = 0; i < candidates.length; i++) { + var obj = candidates[i]; + var type = obj.get('_type'); + var color = obj.get('color'); + if (!Core.isInScope(type, color)) continue; + + var doorPoint = Geometry.doorPixelPosition(obj); + var dist = Geometry.distance(tokenPoint, doorPoint); + if (dist > maxRange) continue; + + if (!best || dist < best.distance) { + best = { obj: obj, type: type, color: color, distance: dist }; + } + } + + return best; + }, + + // For the Knock spell: 60ft range (no melee safety-margin + // tolerance - that's specific to pick-attempt measurement + // error, not spell range), and only locked, non-unpickable targets - + // Knock explicitly can bypass magic locks (per the spell's own + // text about suppressing Arcane Lock), just not whatever + // "unpickable" represents in this system. + KNOCK_RANGE_FEET: 60, + + findNearestKnockableLock: function(pageId, casterPoint) { + var candidates = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + + var feetPerSquare = PageUtils.getFeetPerSquare(pageId); + var maxRange = PIXELS_PER_SQUARE * (this.KNOCK_RANGE_FEET / feetPerSquare); + var best = null; + + for (var i = 0; i < candidates.length; i++) { + var obj = candidates[i]; + var type = obj.get('_type'); + var color = obj.get('color'); + if (!Core.isInScope(type, color)) continue; + if (!obj.get('isLocked')) continue; + + var status = Core.getLockStatus(type, color); + if (status.state === 'unpickable') continue; + + var doorPoint = Geometry.doorPixelPosition(obj); + var dist = Geometry.distance(casterPoint, doorPoint); + if (dist > maxRange) continue; + + if (!best || dist < best.distance) { + best = { obj: obj, type: type, color: color, distance: dist, status: status }; + } + } + + return best; + } + }; + // "Show/Hide All Lock DCs": GM-layer text label above every + // door/window (id + status on two lines, one createObj call + // each). No stored state - existing labels found live via the + // distinctive FONT_SIZE. Fill/stroke are the same hex properties + // as paths. OFFSET_X_* tunable per label type, default 0 - text + // has no true centerpoint, tune in-game. + var TextLabels = { + FONT_SIZE: 14.01, // fractional on purpose - fingerprints script-made labels for toggle detection + FONT_FAMILY: 'Candal', + FILL_COLOR: '#FFFFFF', + // Whole label (both lines) uses this instead of FILL_COLOR + // when a character with a token on this page holds a key + // for that specific door/window. Both lines turn yellow, + // not just the status line - the id+status label is one + // merged text object (see the note above this block on why), + // and a Roll20 text object can't have mixed colors within + // itself. + KEY_PRESENT_COLOR: '#ffff00', + STROKE_COLOR: '#000000', + OFFSET_Y: -43, // 30px above the door/window, plus 8px descender clearance, plus 5px adjustment from live testing + + OFFSET_X_DC: 0, + OFFSET_X_UNSET: 0, + OFFSET_X_UNPICKABLE: 0, + OFFSET_X_MAGIC: 0, + + labelTextFor: function(status) { + if (status.state === 'set') return 'DC' + status.dc; + if (status.state === 'unpickable') return 'Unpickable'; + if (status.state === 'magic_only') return 'Magic'; + // unset, out_of_scope, and unrecognized all read the same + // to a GM glancing at the map - none of them are pickable + // in their current state. + return 'Unset'; + }, + + // The id line (always 4 chars) reads as off-center above a + // much wider status word below it - these leading spaces are + // a manual, approximate nudge toward center, not true + // centering (Candal isn't monospace, so exact math doesn't + // apply anyway). DC##/Unset are already close enough in + // width to the id line that they don't need this. + idPaddingFor: function(status) { + if (status.state === 'unpickable') return ' '; + if (status.state === 'magic_only') return ' '; + return ''; + }, + + offsetXFor: function(status) { + if (status.state === 'set') return this.OFFSET_X_DC; + if (status.state === 'unpickable') return this.OFFSET_X_UNPICKABLE; + if (status.state === 'magic_only') return this.OFFSET_X_MAGIC; + return this.OFFSET_X_UNSET; + }, + + findExisting: function(pageId) { + return findObjs({ _type: 'text', _pageid: pageId, layer: 'gmlayer', font_size: this.FONT_SIZE }) || []; + }, + + // Every "type:id" key held by any character with a token on + // this page, computed once per createAll() call rather than + // re-scanning all tokens for every door - a plain object used + // as a hash-set, matching this file's existing style rather + // than ES6 Set. Minimal/snapshot-at-refresh-time by design - + // this does NOT stay live if a key changes hands or a token + // moves after labels are shown; it's only as current as the + // last label refresh, same as everything else labels show. + _keysPresentOnPage: function(pageId) { + var present = {}; + var tokens = findObjs({ _type: 'graphic', _pageid: pageId }) || []; + for (var i = 0; i < tokens.length; i++) { + var characterId = tokens[i].get('represents'); + if (!characterId) continue; + var keys = KeyringUtils.getKeys(characterId); + for (var k = 0; k < keys.length; k++) { + present[keys[k].type + ':' + keys[k].id] = true; + } + } + return present; + }, + + createAll: function(pageId) { + var targets = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + + var keysPresent = this._keysPresentOnPage(pageId); + + var created = 0; + for (var i = 0; i < targets.length; i++) { + var obj = targets[i]; + var type = obj.get('_type'); + var status = Core.getLockStatus(type, obj.get('color')); + // One object, two lines (id, then status) - one + // createObj call per door/window instead of two, since + // that call is the actual cost driving how slow this + // is on a map with many doors/windows. + var text = this.idPaddingFor(status) + obj.id.slice(-4) + '\n' + this.labelTextFor(status); + var point = Geometry.doorPixelPosition(obj); + var fillColor = keysPresent[type + ':' + obj.id] ? this.KEY_PRESENT_COLOR : this.FILL_COLOR; + + var textObj = createObj('text', { + _pageid: pageId, + layer: 'gmlayer', + left: point.left + this.offsetXFor(status), + top: point.top + this.OFFSET_Y, + text: text, + font_size: this.FONT_SIZE, + font_family: this.FONT_FAMILY, + color: fillColor, + stroke: this.STROKE_COLOR + }); + + if (textObj) { + created++; + // One-time sanity check: if Roll20 rounds the + // fractional font_size, toggle detection silently + // stops working on the next click - surface that + // immediately rather than let it fail quietly. + if (created === 1 && textObj.get('font_size') !== this.FONT_SIZE) { + Logger.warn('font_size stored as ' + textObj.get('font_size') + + ' instead of ' + this.FONT_SIZE + ' - toggle detection may not work reliably.'); + } + } + } + return created; + }, + + deleteAll: function(pageId) { + var existing = this.findExisting(pageId); + for (var i = 0; i < existing.length; i++) existing[i].remove(); + return existing.length; + }, + + // Regenerates all shown labels after any DC/state change, + // rather than tracking/updating one specific label. + refreshIfShown: function(pageId) { + if (this.findExisting(pageId).length === 0) return; + this.deleteAll(pageId); + this.createAll(pageId); + } + }; + // Command-arg encoding for the unlock button (spaces would otherwise + // split a character name across multiple chat command args) + // Command-arg encoding (spaces would otherwise split a character + // name across multiple chat command args) + function encodeForCommand(str) { + return String(str || '').replace(/\s+/g, '_'); + } + function decodeFromCommand(str) { + return String(str || '').replace(/_/g, ' '); + } + // Backs the collapse/expand report pattern (except Report DC, always + // full). Capped, oldest evicted. Only persistent state this script + // keeps - DC data lives in door/window color, never ages out. + var ReportCache = { + _ensureState: function() { + if (!state.Locksmith) state.Locksmith = { reports: {}, order: [] }; + return state.Locksmith; + }, + + store: function(playerid, headerText, bodyHtml, footerText, isPublic) { + var s = this._ensureState(); + var id = Math.floor(Math.random() * 1679616).toString(36); // up to 4 base36 chars + + s.reports[id] = { + playerid: playerid, headerText: headerText, bodyHtml: bodyHtml, + footerText: footerText || null, isPublic: !!isPublic + }; + s.order.push(id); + + while (s.order.length > MAX_CACHED_REPORTS) { + var evictedId = s.order.shift(); + delete s.reports[evictedId]; + } + + return id; + }, + + get: function(id) { + var s = this._ensureState(); + return s.reports[id] || null; + } + }; + // Chat - all whispered/public output for the whole system. + // No emoji/icons anywhere - headers are plain text. + var Chat = { + _displayNameFor: function(playerid) { + var player = getObj('player', playerid); + return player ? player.get('_displayname') : 'GM'; + }, + + // Renders a list of {label, value} pairs as styled rows, with the + // last one using CSS.rowLast automatically. + _renderRows: function(items) { + var out = ''; + for (var i = 0; i < items.length; i++) { + var style = (i === items.length - 1) ? CSS.rowLast : CSS.row; + out += '
' + items[i].label + '' + + '' + items[i].value + '
'; + } + return out; + }, + + // A single styled header bar with no body and no expand link - + // used for short, self-contained messages (errors, the "not + // locked" short-circuit) that have nothing further to show. + whisperHeaderOnly: function(playerid, headerText) { + var name = this._displayNameFor(playerid); + var html = '
' + headerText + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + whisperError: function(playerid, message) { + this.whisperHeaderOnly(playerid, message); + }, + + // Same container style as everything else, but no header row - + // just a single button linking to the help handout. + whisperHelpLink: function(playerid, handoutId) { + var name = this._displayNameFor(playerid); + var link = 'Open Locksmith Help Documentation'; + var html = '
' + link + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + // Keys/keyrings are deliberately private, but visible to both the + // sender and the GM - unlike everything else lock-related, which + // is either fully public or GM-only. "gm" is a literal whisper + // target Roll20 recognizes regardless of the GM's display name. + // Skips the duplicate GM copy if the sender already is the GM. + // headerCommand (optional) makes the header itself clickable, + // reissuing that command - only passed by handleKeyring's own + // calls, not the many other keyring-related messages that share + // this function (Use/Give/Drop confirmations, errors, etc.) - + // those stay plain text as before. + deliverKeyMessage: function(playerid, headerText, bodyHtml, footerText, headerCommand) { + var name = this._displayNameFor(playerid); + var headerHtml = headerCommand ? + '' + headerText + '' : + headerText; + var html = '
' + + '
' + headerHtml + '
' + + (bodyHtml ? '
' + bodyHtml + '
' : '') + + (footerText ? '
' + footerText + '
' : '') + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + if (!playerIsGM(playerid)) { + sendChat(SCRIPT_NAME, '/w gm ' + html); + } + }, + + // The standard report pattern: header only, as a clickable link + // that posts the cached full body when clicked. alwaysVisibleHtml + // (optional) renders immediately regardless of collapse state - + // used for the unlock button, which must never be hidden behind + // a click. + whisperCollapsed: function(playerid, headerText, bodyHtml, footerText, alwaysVisibleHtml) { + var name = this._displayNameFor(playerid); + var id = ReportCache.store(playerid, headerText, bodyHtml, footerText); + var headerLink = '' + headerText + ''; + var html = '
' + headerLink + '
' + + (alwaysVisibleHtml ? '
' + alwaysVisibleHtml + '
' : '') + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + // The full card (header + body + footer), used both for expanding + // a cached report and for reports that are never collapsed + // (Report DC). headerCommand (optional) makes the header itself + // clickable, reissuing that command - only passed by callers + // that actually want this; omitted, it's plain text as before. + whisperFull: function(playerid, headerText, bodyHtml, footerText, headerCommand) { + var name = this._displayNameFor(playerid); + var headerHtml = headerCommand ? + '' + headerText + '' : + headerText; + var html = '
' + + '
' + headerHtml + '
' + + '
' + bodyHtml + '
' + + (footerText ? '
' + footerText + '
' : '') + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + // ---- Public versions for lock-picking outcomes (GM commands + // above stay whispered). fromName posts as the rolling character. + postHeaderOnly: function(fromName, headerText) { + var html = '
' + headerText + '
'; + sendChat(fromName || SCRIPT_NAME, html); + }, + + postCollapsed: function(fromName, playerid, headerText, bodyHtml, footerText, alwaysVisibleHtml) { + var id = ReportCache.store(playerid, headerText, bodyHtml, footerText, true); + var headerLink = '' + headerText + ''; + var html = '
' + headerLink + '
' + + (alwaysVisibleHtml ? '
' + alwaysVisibleHtml + '
' : '') + + '
'; + sendChat(fromName || SCRIPT_NAME, html); + }, + + postFull: function(fromName, headerText, bodyHtml, footerText) { + var html = '
' + + '
' + headerText + '
' + + '
' + bodyHtml + '
' + + (footerText ? '
' + footerText + '
' : '') + + '
'; + sendChat(fromName || SCRIPT_NAME, html); + }, + + // ---- Knock spell outcome. No roll/total exists for this (it's an + // automatic effect, not a check), so there's no detail worth + // hiding behind an expand click - the button is just always there. + postKnockSuccess: function(charname, best) { + var unlockCmd = '!lock --unlock-target ' + best.type + ' ' + best.obj.id + ' ' + + encodeForCommand(charname || 'Someone'); + var label = (best.type === 'door') ? 'Unlock the door' : 'Unlock the window'; + var button = '' + label + ''; + this.postFull(charname, 'Knock!', button, null); + }, + + // ---- GM setup (!lock --set / --unpickable / --knock) errors only - + // success is silent, so this only ever renders 'error' entries + // (DC out of range, or a color write that didn't persist). + whisperSetupResults: function(playerid, headerText, results) { + var items = []; + for (var i = 0; i < results.length; i++) { + var r = results[i]; + var value = '' + r.message + ''; + items.push({ label: r.label, value: value }); + } + this.whisperCollapsed(playerid, headerText, this._renderRows(items), null); + }, + + // ---- Report DC - always shown in full immediately, never collapsed ---- + // Shared by Report DC and the menu's door/window list - guarantees + // the two look identical. One real + // with a dedicated column per piece (name, +Key, +Loot, status) + // so buttons line up cleanly across rows regardless of how long + // each door/window's name is - a floated label/value div can't + // do that once there's more than one button per row. + _statusValueHtml: function(r) { + if (r.state === 'set') return 'DC ' + r.dc + ''; + if (r.state === 'unset') return 'No DC set'; + if (r.state === 'unpickable') return 'Unpickable'; + if (r.state === 'magic_only') return 'Magic only (Knock)'; + if (r.state === 'unrecognized') return 'Unrecognized'; + return 'Not managed'; + }, + + _buildDoorStatusTable: function(results) { + var rows = ''; + for (var i = 0; i < results.length; i++) { + var r = results[i]; + var cellCss = (i === results.length - 1) ? CSS.lockListCellLastCss : CSS.lockListCellCss; + var cellStyle = 'style="' + cellCss + '"'; + var statusCellStyle = 'style="' + cellCss + 'text-align:right;"'; + var pingLink = '' + r.label + ''; + var addCmd = '!lock --key-add ' + r.objType + ' ' + r.objId + + ' @{target|Give ' + r.label + ' key to whom?|token_id}'; + var addBtn = '+Key'; + var lootCmd = '!lock --key-loot-macro ' + r.objType + ' ' + r.objId; + var lootBtn = '+Loot'; + + rows += '' + + '' + + '' + + '' + + '' + + ''; + } + return '
' + pingLink + '' + addBtn + '' + lootBtn + '' + this._statusValueHtml(r) + '
' + rows + '
'; + }, + + whisperReportDC: function(playerid, results, pageWide) { + var headerText = pageWide ? (results.length + ' locks on this page') : (results.length + ' selected'); + var footerText = pageWide ? 'No selection - showing every door/window on this page.' : null; + this.whisperFull(playerid, headerText, this._buildDoorStatusTable(results), footerText, '!lock --report-dc'); + }, + + // ---- Roll-driven outcomes ---- + _rollItems: function(rollData) { + var modeLabel = rollData.mode.charAt(0).toUpperCase() + rollData.mode.slice(1); + var natLabel = rollData.natural; + if (rollData.isCrit) natLabel += ' (crit)'; + if (rollData.isFumble) natLabel += ' (fumble)'; + + var items = []; + if (rollData.charname) items.push({ label: 'Character', value: rollData.charname }); + items.push({ label: 'Total', value: rollData.total }); + items.push({ label: 'Mode', value: modeLabel }); + if (rollData.otherTotal !== null) items.push({ label: 'Other die', value: rollData.otherTotal + ' (dropped)' }); + items.push({ label: 'Natural', value: natLabel }); + for (var i = 0; i < rollData.bonuses.length; i++) { + items.push({ label: rollData.bonuses[i].label, value: rollData.bonuses[i].value }); + } + return items; + }, + + whisperConfirmation: function(rollData) { + var c = rollData.confirmation || { r1: null, r2: null, extraRolls: [] }; + var reasonLabel = (rollData.mode === 'always') + ? 'Both dice rolled - mode decided verbally' + : 'Roll mode not recognized'; + + var items = []; + if (rollData.charname) items.push({ label: 'Character', value: rollData.charname }); + if (c.r1) items.push({ label: 'r1', value: c.r1.total + ' (natural ' + c.r1.natural + ')' }); + if (c.r2) items.push({ label: 'r2', value: c.r2.total + ' (natural ' + c.r2.natural + ')' }); + for (var i = 0; i < c.extraRolls.length; i++) { + var er = c.extraRolls[i]; + items.push({ label: er.label || 'Bonus die', value: er.total + ' (already included above)' }); + } + for (var j = 0; j < rollData.bonuses.length; j++) { + items.push({ label: rollData.bonuses[j].label, value: rollData.bonuses[j].value }); + } + + this.postCollapsed(rollData.charname, rollData.playerid, 'Thieves\' Tools Roll Needs Confirmation', + this._renderRows(items), reasonLabel + ' - confirm the result with the GM.'); + }, + + // The door/window isn't locked at all - nothing else is relevant. + // A single header-only message, nothing to expand. + whisperNotLocked: function(rollData) { + this.postHeaderOnly(rollData.charname, 'This Door is not locked'); + }, + + whisperNoLock: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'No lock within reach' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'No Lock In Range', this._renderRows(items), null); + }, + + whisperNoToken: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'No token found on this page' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'No Token Found', this._renderRows(items), null); + }, + + whisperUnset: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'No DC set - ask your GM' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'Lock Not Configured', this._renderRows(items), null); + }, + + // Result doesn't need to be repeated for unpickable/magic-only - + // the roll's outcome was never in question, so no total prefix. + whisperUnpickable: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'Cannot be picked with mundane tools' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'Unpickable', this._renderRows(items), null); + }, + + whisperMagicOnly: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'Requires magic (e.g. Knock)' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'Magically Sealed', this._renderRows(items), null); + }, + + // DC comparison happened - header is prefixed with the total. The + // unlock button is passed as alwaysVisibleHtml so it's never + // hidden behind the header click - only the detail rows collapse. + whisperSuccess: function(rollData, best, status) { + var items = this._rollItems(rollData); + items.push({ label: 'DC', value: status.dc }); + + var unlockCmd = '!lock --unlock-target ' + best.type + ' ' + best.obj.id + ' ' + + encodeForCommand(rollData.charname || 'Someone'); + var unlockLabel = (best.type === 'door') ? 'Unlock the door' : 'Unlock the window'; + var button = '' + unlockLabel + ''; + + this.postCollapsed(rollData.charname, rollData.playerid, rollData.total + ' - Lock Picked!', this._renderRows(items), null, button); + }, + + // Deliberately does NOT include status.dc anywhere. + whisperFailure: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'DC not beaten' }); + this.postCollapsed(rollData.charname, rollData.playerid, rollData.total + ' - Lock Holds', this._renderRows(items), null); + }, + + // Public, like every other lock-picking outcome - attributed to + // the character who picked it when a name is available. + whisperCoordsDebug: function(playerid, tokenInfo, doorInfo, distancePx, distanceSquares, gridInfo) { + var items = [ + { label: 'Token left/top', value: tokenInfo.left + ', ' + tokenInfo.top }, + { label: 'Door raw x/y', value: doorInfo.rawX + ', ' + doorInfo.rawY }, + { label: 'Door converted', value: doorInfo.left + ', ' + doorInfo.top }, + { label: 'Distance (px)', value: distancePx.toFixed(1) }, + { label: 'Distance (squares)', value: distanceSquares.toFixed(2) }, + { label: 'Grid type', value: gridInfo.gridType + (gridInfo.isSquare ? '' : ' - UNSUPPORTED, results below are unreliable') }, + { label: 'Feet per square', value: String(gridInfo.feetPerSquare) } + ]; + this.whisperCollapsed(playerid, 'Coordinate Debug', this._renderRows(items), null); + }, + + // ---- The Locksmith menu (bare !lock) ---- + // One row per door/window, matching Report DC's layout - no more + // multi-column table, since three buttons per row made that + // cramped. + // Computes the same {label, objType, objId, state, dc} shape + // Report DC uses, then reuses _buildDoorStatusTable - the two + // are now guaranteed identical, not just similar. + _buildLockRows: function(doors, windows) { + var all = doors.concat(windows); + var results = []; + for (var i = 0; i < all.length; i++) { + var lock = all[i]; + var type = lock.get('_type'); + var typeLabel = (type === 'door') ? 'Door' : 'Window'; + var status = Core.getLockStatus(type, lock.get('color')); + status.label = typeLabel + ' ' + lock.id.slice(-4); + status.objType = type; + status.objId = lock.id; + results.push(status); + } + return this._buildDoorStatusTable(results); + }, + + whisperMenu: function(msg) { + var doors = [], windows = []; + if (msg.selected) { + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + if (sel._type !== 'door' && sel._type !== 'window') continue; + var obj = getObj(sel._type, sel._id); + if (!obj) continue; + (sel._type === 'door' ? doors : windows).push(obj); + } + } + + var lockListSection = ''; + if (doors.length + windows.length > 0) { + lockListSection = '
' + + '
Selected Doors
' + + this._buildLockRows(doors, windows); + } + + var selectedButtons = + 'Lock' + + 'Unlock' + + 'Report'; + + var setButtons = + 'DC' + + 'Unpickable' + + 'Magic'; + + var menuTable = '' + + '' + + '' + + '
Selected' + selectedButtons + '
Set' + setButtons + '
'; + + var fullWidthButton = 'Show/Hide All Lock DCs'; + + var body = menuTable + fullWidthButton + lockListSection; + + var name = this._displayNameFor(msg.playerid); + var headerHtml = 'Locksmith' + + '?'; + var html = '
' + + '
' + headerHtml + '
' + + '
' + body + '
' + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + } + }; + // Commands + var Commands = { + + // Bare !lock - the Locksmith menu. GM-only. + handleMenu: function(msg) { + if (!playerIsGM(msg.playerid)) return; + Chat.whisperMenu(msg); + }, + + // GM-only: !lock --set / --unpickable / --knock + handleSetup: function(msg, args) { + if (!playerIsGM(msg.playerid)) { + Logger.warn('Non-GM player ' + msg.playerid + ' attempted setup command.'); + return; + } + + var doSet = false, doUnpickable = false, doKnock = false, setDC = null; + + for (var i = 0; i < args.length; i++) { + if (args[i] === '--unpickable') { + doUnpickable = true; + } else if (args[i] === '--knock') { + doKnock = true; + } else if (args[i] === '--set') { + var n = parseInt(args[i + 1], 10); + if (isNaN(n)) { + Chat.whisperError(msg.playerid, 'Usage: !lock --set # (e.g. !lock --set 15)'); + return; + } + doSet = true; + setDC = n; + i++; + } + } + + var modeCount = (doSet ? 1 : 0) + (doUnpickable ? 1 : 0) + (doKnock ? 1 : 0); + + if (modeCount > 1) { + Chat.whisperError(msg.playerid, 'Only one of --set, --unpickable, or --knock can be used at a time.'); + return; + } + if (modeCount === 0) { + Chat.whisperError(msg.playerid, 'Usage: !lock --set # | !lock --unpickable | !lock --knock'); + return; + } + if (!msg.selected || msg.selected.length === 0) { + Chat.whisperError(msg.playerid, 'No door or window selected.'); + return; + } + + var targets = []; + for (var j = 0; j < msg.selected.length; j++) { + var sel = msg.selected[j]; + if (sel._type === 'door' || sel._type === 'window') { + var obj = getObj(sel._type, sel._id); + if (obj) targets.push(obj); + } + } + if (targets.length === 0) { + Chat.whisperError(msg.playerid, 'Selection contains no doors or windows.'); + return; + } + + var problems = []; + + for (var k = 0; k < targets.length; k++) { + var target = targets[k]; + var type = target.get('_type'); + var typeLabel = (type === 'door') ? 'Door' : 'Window'; + var label = typeLabel + ' ' + target.id.slice(-4); + + // No scope check - explicit GM selection is unambiguous + // intent, color is rebuilt from scratch regardless of before. + var writeValue = null, rangeErrorMsg = null; + + if (doSet) { + writeValue = Core.dcToValue(setDC); + if (writeValue === null) { + rangeErrorMsg = 'DC ' + setDC + ' out of range (' + Core.MIN_DC + '-' + Core.MAX_DC + ')'; + } + } else if (doUnpickable) { + writeValue = Core.UNPICKABLE_VALUE; + } else if (doKnock) { + writeValue = Core.MAGIC_ONLY_VALUE; + } + + if (rangeErrorMsg) { + problems.push({ label: label, state: 'error', message: rangeErrorMsg }); + continue; + } + + var newColor = Core.buildColor(type, writeValue); + target.set('color', newColor); + var afterColor = target.get('color'); + var verifiedStatus = Core.getLockStatus(type, afterColor); + + Logger.info('Set ' + label + ' (' + target.id + ') attempted=[' + newColor + + '] readback=[' + afterColor + ']'); + + var wroteCorrectly = + (doSet && verifiedStatus.state === 'set' && verifiedStatus.dc === setDC) || + (doUnpickable && verifiedStatus.state === 'unpickable') || + (doKnock && verifiedStatus.state === 'magic_only'); + + // Success is intentionally silent - the color change (and, + // if labels are shown, the refreshed label) is the + // confirmation. Only failures need a GM's attention. + if (!wroteCorrectly) { + problems.push({ label: label, state: 'error', message: 'Write did not persist (readback: ' + afterColor + ')' }); + } + } + + if (problems.length > 0) { + Chat.whisperSetupResults(msg.playerid, 'Lock DC Set - Problems', problems); + } + + // Keep any shown GM-layer labels in sync with what just changed. + var pageId = PageUtils.getPageForPlayer(msg.playerid); + if (pageId) TextLabels.refreshIfShown(pageId); + }, + + // GM-only: !lock --lock-selected / --unlock-selected. Works on ANY + // selected door/window regardless of Locksmith scope, since + // isLocked is a native Roll20 property, not part of the DC encoding. + handleLockUnlockSelected: function(msg, isLock) { + if (!playerIsGM(msg.playerid)) return; + if (!msg.selected || msg.selected.length === 0) { + Chat.whisperError(msg.playerid, 'No door or window selected.'); + return; + } + + var count = 0; + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + if (sel._type !== 'door' && sel._type !== 'window') continue; + var obj = getObj(sel._type, sel._id); + if (!obj) continue; + obj.set('isLocked', isLock); + // Locking also closes it - if a door was left open when + // API-locked, later API-unlocking it causes Roll20 to + // auto-open it again, which isn't desired. + if (isLock) obj.set('isOpen', false); + count++; + } + + if (count === 0) { + Chat.whisperError(msg.playerid, 'Selection contains no doors or windows.'); + } + // Success is silent - the door/window's icon on the map already + // shows the new locked/unlocked state. + }, + + // GM-only: !lock --report-dc. Uses selection if any doors/windows + // are selected, otherwise every door/window on the GM's current + // page. Always shown in full immediately (never collapsed). + handleReportDC: function(msg) { + if (!playerIsGM(msg.playerid)) return; + + var targets = []; + if (msg.selected) { + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + if (sel._type === 'door' || sel._type === 'window') { + var obj = getObj(sel._type, sel._id); + if (obj) targets.push(obj); + } + } + } + + var pageWide = false; + if (targets.length === 0) { + pageWide = true; + var pageId = PageUtils.getPageForPlayer(msg.playerid); + targets = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + } + + var results = []; + for (var j = 0; j < targets.length; j++) { + var target = targets[j]; + var type = target.get('_type'); + var typeLabel = (type === 'door') ? 'Door' : 'Window'; + var color = target.get('color'); + var status = Core.getLockStatus(type, color); + status.label = typeLabel + ' ' + target.id.slice(-4); + status.objType = type; + status.objId = target.id; + results.push(status); + } + + Chat.whisperReportDC(msg.playerid, results, pageWide); + }, + + // GM-only: !lock --ping . Focus-pull ping, visible only + // visibility - sendPing's moveAll/visibleTo behavior for this + // is unverified against a live game. + handlePing: function(msg, args) { + if (!playerIsGM(msg.playerid)) return; + var type = args[0], id = args[1]; + if (type !== 'door' && type !== 'window') return; + var obj = getObj(type, id); + if (!obj) { + Chat.whisperError(msg.playerid, 'That object no longer exists.'); + return; + } + var point = Geometry.doorPixelPosition(obj); + var pageId = obj.get('_pageid'); + sendPing(point.left, point.top, pageId, msg.playerid, true, msg.playerid); + }, + + // Debug aid for the geometry assumption. GM-only; select exactly + // one token and one door/window. + handleCoordsDebug: function(msg) { + if (!playerIsGM(msg.playerid)) return; + if (!msg.selected || msg.selected.length !== 2) { + Chat.whisperError(msg.playerid, 'Select exactly one token and one door/window, then run !lock --coords.'); + return; + } + + var tokenObj = null, doorObj = null; + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + var obj = getObj(sel._type, sel._id); + if (!obj) continue; + if (sel._type === 'graphic') tokenObj = obj; + else if (sel._type === 'door' || sel._type === 'window') doorObj = obj; + } + if (!tokenObj || !doorObj) { + Chat.whisperError(msg.playerid, 'Selection must include exactly one token and one door/window.'); + return; + } + + var tokenPoint = Geometry.tokenPixelPosition(tokenObj); + var doorPoint = Geometry.doorPixelPosition(doorObj); + var dist = Geometry.distance(tokenPoint, doorPoint); + var pageId = doorObj.get('_pageid'); + var page = getObj('page', pageId); + + Chat.whisperCoordsDebug( + msg.playerid, + tokenPoint, + { rawX: doorObj.get('x'), rawY: doorObj.get('y'), left: doorPoint.left, top: doorPoint.top }, + dist, + dist / PIXELS_PER_SQUARE, + { + gridType: (page && page.get('grid_type')) || 'square', + isSquare: PageUtils.isSquareGrid(pageId), + feetPerSquare: PageUtils.getFeetPerSquare(pageId) + } + ); + }, + + // GM-only: !lock --toggle-dc-labels. Current-page scope. No state + // stored for this at all - detects existing labels live via + // TextLabels.findExisting, so there's nothing to keep in sync. + handleToggleLabels: function(msg) { + if (!playerIsGM(msg.playerid)) return; + + var pageId = PageUtils.getPageForPlayer(msg.playerid); + if (!pageId) { + Chat.whisperError(msg.playerid, 'Could not determine your current page.'); + return; + } + + var existing = TextLabels.findExisting(pageId); + if (existing.length > 0) { + TextLabels.deleteAll(pageId); + } else { + TextLabels.createAll(pageId); + } + // Success is silent - the labels appearing/disappearing on the + // map is the confirmation. + }, + + // !lock --help. Not GM-gated - the handout has sections for both + // GMs and players. Find-or-create, same pattern as Chronicle's + // help handout. + handleHelp: function(msg) { + var helpHandout = findObjs({ _type: 'handout', name: HELP_NAME })[0]; + + if (!helpHandout) { + var createProps = { name: HELP_NAME, inplayerjournals: 'all', archived: false }; + if (HELP_AVATAR) createProps.avatar = HELP_AVATAR; + helpHandout = createObj('handout', createProps); + helpHandout.set('notes', buildHelpText(helpHandout.id)); + Logger.info('Created help handout'); + } else { + helpHandout.set('notes', buildHelpText(helpHandout.id)); + if (HELP_AVATAR) helpHandout.set('avatar', HELP_AVATAR); + Logger.info('Updated help handout'); + } + + Chat.whisperHelpLink(msg.playerid, helpHandout.id); + }, + + // !lock --expand . Not GM-gated - checked against the report's + // original recipient instead, since many reports go to players. + handleExpand: function(msg, args) { + var id = args[0]; + var cached = ReportCache.get(id); + if (!cached) { + Chat.whisperError(msg.playerid, 'This report has expired or is no longer available.'); + return; + } + if (cached.isPublic) { + // Visible to everyone already - anyone can expand it. + Chat.postFull(null, cached.headerText, cached.bodyHtml, cached.footerText); + return; + } + if (cached.playerid !== msg.playerid) { + return; // private report - not this player's, silently ignore + } + Chat.whisperFull(msg.playerid, cached.headerText, cached.bodyHtml, cached.footerText); + }, + + // Only ever sent by the button Chat.whisperSuccess() generates. + handleUnlockTarget: function(msg, args) { + var type = args[0]; + var id = args[1]; + var charname = decodeFromCommand(args[2]); + + if (type !== 'door' && type !== 'window') return; + + var obj = getObj(type, id); + if (!obj) { + Chat.whisperError(msg.playerid, 'That lock no longer exists.'); + return; + } + + obj.set('isLocked', false); + + var label = (type === 'door' ? 'The door' : 'The window'); + // Success is silent - the door/window icon changing on the map + // is the confirmation. + + Logger.info(label + ' (' + id + ') unlocked by ' + charname + ' (player ' + msg.playerid + ')'); + }, + + // GM-only: clicked from an "Add" button next to a door/window + // name. Targets a token via @{target|...}, adds a key for that + // door to the target's associated character's keyring. + handleKeyAdd: function(msg, args) { + if (!playerIsGM(msg.playerid)) return; + var type = args[0], doorId = args[1], targetTokenId = args[2]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.deliverKeyMessage(msg.playerid, 'That lock no longer exists.'); + return; + } + + var targetToken = getObj('graphic', targetTokenId); + if (!targetToken) { + Chat.deliverKeyMessage(msg.playerid, 'No valid target was selected.'); + return; + } + + var characterId = targetToken.get('represents'); + if (!characterId) { + Chat.deliverKeyMessage(msg.playerid, 'That token has no associated character - key was not given.'); + return; + } + + var character = getObj('character', characterId); + var charname = character ? character.get('name') : 'that character'; + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + + var added = KeyringUtils.addKey(characterId, type, doorId); + if (added) { + Chat.deliverKeyMessage(msg.playerid, 'Gave ' + typeLabel + ' key to ' + charname + '.'); + } else { + Chat.deliverKeyMessage(msg.playerid, charname + ' already has a key for ' + typeLabel + '.'); + } + }, + + // Self-service version of Add - not GM-gated. Meant to be + // triggered from a macro pasted into a treasure horde or similar, + // using @{selected|token_id} rather than @{target|...} so it + // grants the key to whoever has their own token selected when + // they click, not a token the clicker picks afterward. + handleKeyLoot: function(msg, args) { + var type = args[0], doorId = args[1], targetTokenId = args[2]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.deliverKeyMessage(msg.playerid, 'That lock no longer exists.'); + return; + } + + var targetToken = getObj('graphic', targetTokenId); + if (!targetToken) { + Chat.deliverKeyMessage(msg.playerid, 'You must have your token selected to loot this.'); + return; + } + + var characterId = targetToken.get('represents'); + if (!characterId) { + Chat.deliverKeyMessage(msg.playerid, 'Your token has no associated character.'); + return; + } + + var character = getObj('character', characterId); + var charname = character ? character.get('name') : 'that character'; + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + + var added = KeyringUtils.addKey(characterId, type, doorId); + if (added) { + Chat.deliverKeyMessage(msg.playerid, charname + ' found a key for ' + typeLabel + '!'); + } else { + Chat.deliverKeyMessage(msg.playerid, charname + ' already has a key for ' + typeLabel + '.'); + } + }, + + // GM-only: clicked from a "+Loot" button. Whispers the raw macro + // text (not a clickable command) for the GM to copy/paste + // wherever they want - e.g. into a treasure handout's description. + handleKeyLootMacro: function(msg, args) { + if (!playerIsGM(msg.playerid)) return; + var type = args[0], doorId = args[1]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.whisperError(msg.playerid, 'That lock no longer exists.'); + return; + } + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + // Escaped @ - same bug as Add/Give, missed here originally. + // Safe for copy-paste: the browser renders/copies the decoded + // @ character regardless of whether it's inside a clickable + // link or plain text. + var macroText = '!lock --key-loot ' + type + ' ' + doorId + ' @{selected|token_id}'; + var body = 'Copy this macro and use it to give a specific key to a player:
' + + '' + macroText + ''; + + Chat.whisperFull(msg.playerid, 'Loot Macro - ' + typeLabel, body, null); + }, + + // !lock --keyring. Requires exactly one selected token - Roll20's + // own selection restrictions (players can only select tokens they + // control) are what actually enforce "only the controlling player + // or the GM" here, not any check in this script. + handleKeyring: function(msg) { + if (!msg.selected || msg.selected.length !== 1 || msg.selected[0]._type !== 'graphic') { + Chat.deliverKeyMessage(msg.playerid, 'Select exactly one token, then run !keyring.'); + return; + } + + var token = getObj('graphic', msg.selected[0]._id); + if (!token) { + Chat.deliverKeyMessage(msg.playerid, 'That token no longer exists.'); + return; + } + + var characterId = token.get('represents'); + if (!characterId) { + Chat.deliverKeyMessage(msg.playerid, 'That token has no associated character.'); + return; + } + + var character = getObj('character', characterId); + var charname = character ? character.get('name') : 'Unknown'; + + // Setup convenience, offered regardless of whether the + // character has any keys yet - a GM might want to hand this + // out before a player has found their first key. + var hasTokenAction = KeyringUtils.hasTokenAction(characterId); + var actionBtn = hasTokenAction ? '' : + 'Create "Keys." Token Action'; + + var keys = KeyringUtils.getKeys(characterId); + if (keys.length === 0) { + var emptyBody = actionBtn || null; + Chat.deliverKeyMessage(msg.playerid, charname + "'s Keyring - No keys.", emptyBody, null, '!keyring'); + return; + } + + var items = []; + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + var typeLabel = (k.type === 'door' ? 'Door' : 'Window') + ' ' + k.id.slice(-4); + var useCmd = '!lock --key-use ' + k.type + ' ' + k.id + ' ' + characterId; + var giveCmd = '!lock --key-give ' + k.type + ' ' + k.id + ' ' + characterId + + ' @{target|Give this key to whom?|token_id}'; + var dropCmd = '!lock --key-drop-confirm ' + k.type + ' ' + k.id + ' ' + characterId; + // Escaped @ - see the same note in whisperReportDC. + var buttons = 'Use ' + + 'Give ' + + 'Drop'; + // Plain text, not pingable - unlike everywhere else, a + // keyring listing shouldn't let a player locate the door. + items.push({ label: typeLabel, value: buttons }); + } + + var tryAllBtn = 'Try all Keys'; + // Create-action button, when shown, always comes last. + var body = Chat._renderRows(items) + tryAllBtn + actionBtn; + + Chat.deliverKeyMessage(msg.playerid, charname + "'s Keyring (" + keys.length + (keys.length === 1 ? ' key)' : ' keys)'), body, null, '!keyring'); + }, + + // Clicked from a keyring report's "Create \"Keys.\" Token Action" + // button. Not GM-gated - any player who can see the button + // (i.e. already passed the selection check in handleKeyring) can + // create it. The trailing period in the name is intentional - + // it's how TokenActionMaker/TokenActionBuilder flag an action as + // protected from their own auto-cleanup, not a typo. + handleKeyCreateAction: function(msg, args) { + var characterId = args[0]; + var character = getObj('character', characterId); + if (!character) { + Chat.deliverKeyMessage(msg.playerid, 'That character no longer exists.'); + return; + } + + if (KeyringUtils.hasTokenAction(characterId)) { + Chat.deliverKeyMessage(msg.playerid, 'That token action already exists.'); + return; + } + + createObj('ability', { + characterid: characterId, + name: KeyringUtils.TOKEN_ACTION_NAME, + action: '!keyring', + istokenaction: true + }); + + Chat.deliverKeyMessage(msg.playerid, 'Created the "Keys." token action for ' + character.get('name') + '.'); + }, + + // Clicked from a keyring report's "Use" button. Toggles the named + // door/window's locked state (bypassing DC/unpickable/magic + // entirely - a key doesn't care about any of that) as long as the + // key-holder's token is within pick range. Locking via key also + // closes it, same reasoning as the menu's Lock Selected - an + // API-locked door left open would auto-reopen when later + // API-unlocked. + handleKeyUse: function(msg, args) { + var type = args[0], doorId = args[1], characterId = args[2]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.deliverKeyMessage(msg.playerid, 'That lock no longer exists.'); + return; + } + + var pageId = PageUtils.getPageForPlayer(msg.playerid); + var tokens = TokenUtils.findTokensForCharacter(pageId, characterId); + if (tokens.length === 0) { + Chat.deliverKeyMessage(msg.playerid, 'No token found for that character on this page.'); + return; + } + + var doorPoint = Geometry.doorPixelPosition(doorObj); + var maxRange = Geometry.maxRangePixels(); + var inRange = false; + for (var i = 0; i < tokens.length; i++) { + var dist = Geometry.distance(Geometry.tokenPixelPosition(tokens[i]), doorPoint); + if (dist <= maxRange) { inRange = true; break; } + } + + if (!inRange) { + Chat.deliverKeyMessage(msg.playerid, 'Not close enough to use that key.'); + return; + } + + var willLock = !doorObj.get('isLocked'); + doorObj.set('isLocked', willLock); + if (willLock) doorObj.set('isOpen', false); + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + Chat.deliverKeyMessage(msg.playerid, typeLabel + (willLock ? ' locked with a key.' : ' unlocked with a key.')); + }, + + // Clicked from a keyring report's "Try all Keys" button. Toggles + // the locked state of the nearest door/window the character + // holds a key for, as long as it's within pick range - same + // range as a Thieves' Tools pick attempt, same toggle behavior + // as the individual Use button. + handleKeyTryAll: function(msg, args) { + var characterId = args[0]; + + var pageId = PageUtils.getPageForPlayer(msg.playerid); + var tokens = TokenUtils.findTokensForCharacter(pageId, characterId); + if (tokens.length === 0) { + Chat.deliverKeyMessage(msg.playerid, 'No token found for that character on this page.'); + return; + } + + var keys = KeyringUtils.getKeys(characterId); + if (keys.length === 0) { + Chat.deliverKeyMessage(msg.playerid, 'No keys to try.'); + return; + } + + var maxRange = Geometry.maxRangePixels(); + var best = null; + + for (var t = 0; t < tokens.length; t++) { + var tokenPoint = Geometry.tokenPixelPosition(tokens[t]); + for (var k = 0; k < keys.length; k++) { + var doorObj = getObj(keys[k].type, keys[k].id); + if (!doorObj) continue; + + var dist = Geometry.distance(tokenPoint, Geometry.doorPixelPosition(doorObj)); + if (dist > maxRange) continue; + + if (!best || dist < best.distance) { + best = { obj: doorObj, type: keys[k].type, distance: dist }; + } + } + } + + if (!best) { + Chat.deliverKeyMessage(msg.playerid, 'None of your keys fit a door within range.'); + return; + } + + var willLock = !best.obj.get('isLocked'); + best.obj.set('isLocked', willLock); + if (willLock) best.obj.set('isOpen', false); + var typeLabel = (best.type === 'door' ? 'Door' : 'Window') + ' ' + best.obj.id.slice(-4); + Chat.deliverKeyMessage(msg.playerid, 'Found the right key - ' + typeLabel + (willLock ? ' locked.' : ' unlocked.')); + }, + + // Clicked from a keyring report's "Give" button. Removes the key + // from the giver, targets a token via @{target|...}, adds it to + // the target's associated character. + handleKeyGive: function(msg, args) { + var type = args[0], doorId = args[1], giverCharacterId = args[2], targetTokenId = args[3]; + + var removed = KeyringUtils.removeKey(giverCharacterId, type, doorId); + if (!removed) { + Chat.deliverKeyMessage(msg.playerid, 'That key is no longer in your keyring.'); + return; + } + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + var targetToken = getObj('graphic', targetTokenId); + + if (!targetToken) { + KeyringUtils.addKey(giverCharacterId, type, doorId); // restore - give failed + Chat.deliverKeyMessage(msg.playerid, 'No valid target was selected - key was not given.'); + return; + } + + var recipientCharacterId = targetToken.get('represents'); + if (!recipientCharacterId) { + KeyringUtils.addKey(giverCharacterId, type, doorId); // restore - give failed + Chat.deliverKeyMessage(msg.playerid, 'That token has no associated character - key was not given.'); + return; + } + + KeyringUtils.addKey(recipientCharacterId, type, doorId); + var recipient = getObj('character', recipientCharacterId); + var recipientName = recipient ? recipient.get('name') : 'that character'; + var giver = getObj('character', giverCharacterId); + var giverName = giver ? giver.get('name') : 'Someone'; + Chat.deliverKeyMessage(msg.playerid, giverName + ' gave ' + typeLabel + ' key to ' + recipientName + '.'); + }, + + // Clicked from a keyring report's "Drop" button - a safety net, + // since dropping a key is instant and irreversible otherwise. + // Shows a confirmation card instead of deleting immediately; the + // actual deletion is handleKeyDrop below, triggered only by + // clicking the confirmation button. + handleKeyDropConfirm: function(msg, args) { + var type = args[0], doorId = args[1], characterId = args[2]; + + if (!KeyringUtils.hasKey(characterId, type, doorId)) { + Chat.deliverKeyMessage(msg.playerid, 'That key is no longer in your keyring.'); + return; + } + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + var confirmCmd = '!lock --key-drop ' + type + ' ' + doorId + ' ' + characterId; + var confirmBtn = 'Yes, Drop It'; + + Chat.deliverKeyMessage(msg.playerid, 'Drop ' + typeLabel + ' key?', + 'This cannot be undone.
' + confirmBtn, null); + }, + + // The actual deletion - only ever reached via the confirmation + // button above, never directly from the keyring report itself. + handleKeyDrop: function(msg, args) { + var type = args[0], doorId = args[1], characterId = args[2]; + + var removed = KeyringUtils.removeKey(characterId, type, doorId); + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + + if (!removed) { + Chat.deliverKeyMessage(msg.playerid, 'That key is no longer in your keyring.'); + return; + } + + Chat.deliverKeyMessage(msg.playerid, 'Dropped ' + typeLabel + ' key.'); + }, + + // Passive. Detects a Knock cast, finds the nearest locked, + // non-unpickable door within 60ft. Returns true if handled (so + // the caller skips handleRoll). + handleKnockCast: function(msg) { + var castData = RollParser.extractKnockCast(msg); + if (!castData) return false; + + var pageId = PageUtils.getPageForPlayer(castData.playerid); + if (!pageId) { + Chat.postHeaderOnly(castData.charname, 'Could not determine the caster\'s current page.'); + return true; + } + + var characterId = castData.characterId || TokenUtils.findCharacterIdByName(castData.charname); + if (!characterId) { + Chat.postHeaderOnly(castData.charname, 'Could not identify the casting character.'); + return true; + } + + var tokens = TokenUtils.findTokensForCharacter(pageId, characterId); + if (tokens.length === 0) { + Chat.postHeaderOnly(castData.charname, 'No token found for the caster on this page.'); + return true; + } + + var best = null; + for (var i = 0; i < tokens.length; i++) { + var casterPoint = Geometry.tokenPixelPosition(tokens[i]); + var candidate = DoorFinder.findNearestKnockableLock(pageId, casterPoint); + if (candidate && (!best || candidate.distance < best.distance)) best = candidate; + } + + if (!best) { + Chat.postHeaderOnly(castData.charname, 'Knock finds no valid lock within range.'); + return true; + } + + // /fx via sendChat() fails ("Unrecognized command") - it's + // client-only, unlike /w or /em. spawnFx() has no id variant, + // so this depends on the geometry assumption - verify with + // !lock --coords. + var doorPoint = Geometry.doorPixelPosition(best.obj); + spawnFx(doorPoint.left, doorPoint.top, 'glow-magic', pageId); + + Chat.postKnockSuccess(castData.charname, best); + return true; + }, + + // Passive: fires on every non-api chat message. Finds and compares + // a Thieves' Tools roll against the nearest managed lock in range. + handleRoll: function(msg) { + var rollData = RollParser.extractRollData(msg); + if (!rollData) return; + + if (rollData.needsConfirmation) { + Chat.whisperConfirmation(rollData); + return; + } + + var pageId = PageUtils.getPageForPlayer(rollData.playerid); + if (!pageId) { + Chat.whisperError(rollData.playerid, 'Could not determine your current page.'); + return; + } + + var tokens = TokenUtils.findTokensForCharacter(pageId, rollData.characterId); + if (tokens.length === 0) { + Chat.whisperNoToken(rollData); + return; + } + + var best = null; + for (var i = 0; i < tokens.length; i++) { + var tokenPoint = Geometry.tokenPixelPosition(tokens[i]); + var candidate = DoorFinder.findNearestInScopeLock(pageId, tokenPoint); + if (candidate && (!best || candidate.distance < best.distance)) best = candidate; + } + + if (!best) { + Chat.whisperNoLock(rollData); + return; + } + + // Public, non-pull ping marking the tested door (unlike the + // GM-only focus-pull ping elsewhere). visibleTo omitted on + // purpose - passing literal 'all' made the ping invisible to + // everyone; omitting it is the documented way to show all. + var pingPoint = Geometry.doorPixelPosition(best.obj); + sendPing(pingPoint.left, pingPoint.top, pageId, rollData.playerid, false); + + // Not-locked short-circuit: applies only here (a player's pick + // attempt), never to any GM-facing command. Takes priority over + // every other check - an unlocked door's DC/unpickable/magic + // state is irrelevant. + if (!best.obj.get('isLocked')) { + Chat.whisperNotLocked(rollData); + return; + } + + var status = Core.getLockStatus(best.type, best.color); + + if (status.state === 'unset') { Chat.whisperUnset(rollData); return; } + if (status.state === 'unpickable') { Chat.whisperUnpickable(rollData); return; } + if (status.state === 'magic_only') { Chat.whisperMagicOnly(rollData); return; } + if (status.state === 'unrecognized') { Chat.whisperError(rollData.playerid, 'This lock is in an unrecognized state.'); return; } + + if (rollData.total >= status.dc) { + Chat.whisperSuccess(rollData, best, status); + } else { + Chat.whisperFailure(rollData); + } + }, + + handleInput: function(msg) { + if (msg.type === 'api') { + var trimmed = msg.content.trim(); + var args = trimmed.split(/\s+/); + var cmd = args.shift(); + + // Alias: !keyring behaves exactly like !lock --keyring - + // shorter to type for players who use it often. + if (cmd === '!keyring') { + this.handleKeyring(msg); + return; + } + + if (cmd !== '!lock') return; + + if (args.length === 0) { this.handleMenu(msg); return; } + + var flag = args.shift(); + if (flag === '--set' || flag === '--unpickable' || flag === '--knock') { + this.handleSetup(msg, [flag].concat(args)); + } else if (flag === '--lock-selected') { + this.handleLockUnlockSelected(msg, true); + } else if (flag === '--unlock-selected') { + this.handleLockUnlockSelected(msg, false); + } else if (flag === '--report-dc') { + this.handleReportDC(msg); + } else if (flag === '--ping') { + this.handlePing(msg, args); + } else if (flag === '--coords') { + this.handleCoordsDebug(msg); + } else if (flag === '--toggle-dc-labels') { + this.handleToggleLabels(msg); + } else if (flag === '--help') { + this.handleHelp(msg); + } else if (flag === '--expand') { + this.handleExpand(msg, args); + } else if (flag === '--unlock-target') { + this.handleUnlockTarget(msg, args); + } else if (flag === '--key-add') { + this.handleKeyAdd(msg, args); + } else if (flag === '--key-loot') { + this.handleKeyLoot(msg, args); + } else if (flag === '--key-loot-macro') { + this.handleKeyLootMacro(msg, args); + } else if (flag === '--keyring') { + this.handleKeyring(msg); + } else if (flag === '--key-create-action') { + this.handleKeyCreateAction(msg, args); + } else if (flag === '--key-use') { + this.handleKeyUse(msg, args); + } else if (flag === '--key-try-all') { + this.handleKeyTryAll(msg, args); + } else if (flag === '--key-give') { + this.handleKeyGive(msg, args); + } else if (flag === '--key-drop-confirm') { + this.handleKeyDropConfirm(msg, args); + } else if (flag === '--key-drop') { + this.handleKeyDrop(msg, args); + } + return; + } + if (this.handleKnockCast(msg)) return; + this.handleRoll(msg); + } + }; + + return { + VERSION: VERSION, + Core: Core, + RollParser: RollParser, + Geometry: Geometry, + DoorFinder: DoorFinder, + TextLabels: TextLabels, + TokenUtils: TokenUtils, + KeyringUtils: KeyringUtils, + PageUtils: PageUtils, + ReportCache: ReportCache, + Commands: Commands, + dumpState: function() { + log('[' + SCRIPT_NAME + '] dumpState: v' + VERSION + '. DC data lives in door/window color (never ' + + 'ages out). state.Locksmith holds only the report cache (' + + (state.Locksmith ? state.Locksmith.order.length : 0) + '/' + MAX_CACHED_REPORTS + ' entries).'); + } + }; + +})(); + +on('ready', function() { + 'use strict'; + on('chat:message', function(msg) { + Locksmith.Commands.handleInput(msg); + }); + + // One-time check, not a live monitor - a page added/changed to a + // non-square grid type later won't be caught until the next script + // reload. Range math throughout this script assumes a square grid; + // hex pages are detected but not actually supported. + var nonSquarePages = (findObjs({ _type: 'page' }) || []).filter(function(p) { + return !Locksmith.PageUtils.isSquareGrid(p.id); + }); + if (nonSquarePages.length > 0) { + log('[Locksmith] WARNING: ' + nonSquarePages.length + ' page(s) are not square-grid ' + + '(' + nonSquarePages.map(function(p) { return p.get('name') + ': ' + p.get('grid_type'); }).join(', ') + '). ' + + 'Range calculations (pick attempts, Knock, --coords) assume a square grid and will be unreliable there.'); + } + + log('[Locksmith] Locksmith v' + Locksmith.VERSION + ' loaded.'); +}); diff --git a/Locksmith/Locksmith.js b/Locksmith/Locksmith.js new file mode 100644 index 000000000..f32813072 --- /dev/null +++ b/Locksmith/Locksmith.js @@ -0,0 +1,2350 @@ +// Script: Locksmith +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +var Locksmith = Locksmith || (function() { + 'use strict'; + + var VERSION = '1.0.0'; + //Changelog + // 1.0.0 Debut + var SCRIPT_NAME = 'Locksmith'; + + // Grid constants for the proximity check (pixels/square assumed; see + // Geometry below for the door/window axis-inversion assumption) + var PIXELS_PER_SQUARE = 70; + var RANGE_SQUARES = 1.5; // adjacent square plus a safety margin for edge cases/measurement + + // Report cache sizing - reports collapse to a header link, body + // cached here by short id, oldest evicted past this cap + var MAX_CACHED_REPORTS = 50; + // Help handout - find-or-create pattern, same as Chronicle's help + // handout. Reuses the same avatar image used across other scripts. + var HELP_NAME = 'Help: Locksmith'; + var HELP_AVATAR = 'https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147'; + + // Metal-panel background for every chat card - CSS.container below + // references this directly. + var BG_IMAGE_URL = 'https://files.d20.io/images/496924732/hbrOYO3vwmWFoP9KuD8a2w/original.webp?1786261303'; + + // A function, not a static string - the TOC links have to point at + // this handout's own URL (Roll20's internal link format is + // journal.roll20.net/handout//#, not a + // same-page anchor), so the text can't be built until the handout + // object exists and its id is known. + function buildHelpText(handoutId) { + var base = 'http://journal.roll20.net/handout/' + handoutId + '/'; + var tocGM = base + '#For%20GMs'; + var tocPlayers = base + '#For%20Players'; + // Floated, no border - text wraps around the image instead of + // the image breaking the paragraph flow. float (not flexbox) to + // match this project's usual handout/chat rendering approach. + function floatImg(url, alt, caption, side) { + var margin = (side === 'right') ? '4px 0 10px 14px' : '4px 14px 10px 0'; + return '
' + alt + '' + + '
' + caption + '
'; + } + var clear = '
'; + + return ` +
+

Locksmith

+

Locksmith manages locked doors and windows, and automatically resolves lock-picking attempts (Thieves' Tools checks) and the Knock spell against them.

+

Works with both versions of Roll20's D&D 5th Edition sheet - 5e (legacy) and 5.5e (Beacon).

+

Jump to: For GMs  |  For Players

+ +

For GMs

+ +

Setting Up a Lock

+

Command: !lock

+` + floatImg( + 'https://files.d20.io/images/497202465/3wfLwxnDmlSW93c12dIvUA/original.webp?1786471913', + 'The Locksmith menu with several doors selected', + 'The Locksmith menu with a few doors selected - each row shows a status badge plus +Key/+Loot buttons.', + 'right' +) + ` +

Select one or more doors/windows and run the bare command to open the Locksmith menu. The menu has two rows of buttons:

+
    +
  • Selected - Lock, Unlock, and Report act on whatever doors/windows are currently selected. Lock/Unlock toggle Roll20's own locked state. Report shows each selected item's current status (or, with nothing selected, every door/window on the page).
  • +
  • Set - DC, Unpickable, and Magic configure the selected door(s)/window(s). DC prompts for a difficulty (8-50). Unpickable marks it unbeatable by mundane lock-picking. Magic marks it as openable only by magic (e.g. the Knock spell), not mundane picking.
  • +
+

A door or window needs both a DC/Unpickable/Magic setting and Roll20's native locked state turned on before a pick attempt or Knock will do anything with it - a configured-but-unlocked door is treated as just an ordinary unlocked door.

+ +

Show/Hide All Lock DCs

+

Toggles a small text label above every door/window on the current page, showing its status at a glance (a difficulty number, Unpickable, Magic, or Unset). Handy while placing locks; click it again to remove the labels. A label turns yellow if a character with a token currently on that page holds a key for that door/window - a quick way to spot "someone here can already open this." This is only as current as the last time labels were shown or refreshed - giving away a key or moving a token afterward won't update the color until labels are toggled again.

+ +

Report DC

+

!lock --report-dc (also on the menu) lists every selected door/window's status, or every door/window on the page if nothing is selected. Each name is clickable and pings its location for you.

+` + clear + ` + +

How locks are stored

+

Locksmith works off Roll20's standard color property for doors and windows - it doesn't add any extra data to your game. Because of this, manually changing the color of a door or window Locksmith has configured can break its ability to track that lock. If you need to recolor something Locksmith manages, use the menu to reconfigure it afterward rather than hand-editing its color.

+ +

Keys and Keyrings

+

Anywhere a door or window's name is shown as a clickable ping (the menu's selected list, Report DC), there's a +Key button right after it. Clicking it prompts you to pick a token on the map - that token's character receives a key for that door/window, letting them bypass its lock (DC, Unpickable, or Magic) entirely with the Use/Try all Keys buttons on their own !lock --keyring report. Keys, once given, live entirely with the players - there's no further GM management needed unless you want to hand out more.

+

Next to +Key is +Loot, for handing out a key without picking a token yourself - useful for treasure. Clicking it whispers you a ready-to-use macro you can paste into a handout, treasure horde, or macro button. Whoever clicks that pasted macro with their own token selected gets the key automatically - no GM step needed at that point.

+ +

Troubleshooting

+

!lock --coords - select exactly one token and one door/window, and this reports their measured distance. Useful if pick attempts or Knock seem to be missing doors that should be in range.

+ +

For Players

+ +

Picking a Lock

+

There's no special command - just make a Thieves' Tools check as you normally would (from your character sheet) while your token is near the door or window in question. Range: roughly an adjacent square, plus a small safety margin for measurement. Locksmith automatically finds the nearest locked door/window within that range and compares your roll:

+
    +
  • If you beat the DC, you'll see a success message with an Unlock button - click it to actually unlock it.
  • +
  • If you don't beat the DC, you're told your attempt failed, but never the DC itself - only your GM knows how close you came.
  • +
  • If nothing is in range, or the door isn't actually locked, or it's unpickable/magically sealed, you'll be told plainly.
  • +
+ +

Knock

+

Casting Knock works the same way - no extra steps. Range: the spell's own range (60 feet) - Locksmith finds the nearest valid locked door/window within that range and offers the same kind of Unlock button. Knock can open magically-sealed doors that mundane lock-picking can't, but not ones marked Unpickable.

+ +

Keys

+` + floatImg( + 'https://files.d20.io/images/497202464/0QJEpMcZqgG47q1zBtw4rw/original.webp?1786471913', + 'A sample keyring report showing several keys', + 'A sample keyring - Use/Give/Drop per key, plus Try all Keys at the bottom.', + 'right' +) + ` +

If your GM has given your character a key, select your token and run !lock --keyring (or just !keyring) to see what you're holding - only you and your GM will see this. Each key has three buttons:

+
    +
  • Use toggles that specific door/window's locked state if you're close enough - bypassing whatever lock it has entirely.
  • +
  • Give lets you hand the key off to another character by targeting their token.
  • +
  • Drop removes the key from your keyring entirely - nobody receives it, and it'll ask you to confirm first since this can't be undone.
  • +
+

There's also a Try all Keys button that toggles the locked state of the nearest door/window within range that you hold a key for - handy for "I try my keys" moments without picking through your list one by one.

+` + clear + ` +` + floatImg( + 'https://files.d20.io/images/497202466/gLS4JX1BCSkLWPVAy9uF3Q/original.webp?1786471917', + 'The token action created by the Create Keys Token Action button', + 'The resulting "Keys." token action, ready to click from the token itself.', + 'right' +) + ` +

The first time you (or your GM) run !keyring for a character, you'll also see a Create "Keys." Token Action button - this adds a one-click token action so you can run !keyring straight from your token in the future without typing anything. It only shows up if that character doesn't already have one.

+` + clear + ` +
+`; + } + // Logger + var Logger = { + info: function(msg) { log('[' + SCRIPT_NAME + '] ' + msg); }, + warn: function(msg) { log('[' + SCRIPT_NAME + '][WARN] ' + msg); }, + error: function(msg) { log('[' + SCRIPT_NAME + '][ERROR] ' + msg); } + }; + // CSS - centralized style definitions for whispered chat cards + // (float-based layout only; Roll20 chat does not support flexbox) + // No emoji/icons anywhere in this system - text only. + var CSS = { + // Metal-panel background (B1 palette). #8c949b is the image's own + // average tone, kept as a solid fallback while the image loads or + // if it fails to load. The source image is a tileable texture, so + // it's repeated at native size rather than stretched with + // background-size:cover - cover was scaling a small (300x225) + // image up to fill the card, blurring out the fine texture detail + // that tiling preserves. + container: 'style="border:1px solid #17212c;border-radius:6px;' + + 'background-color:#8c949b;background-image:url(\'' + BG_IMAGE_URL + '\');' + + 'background-repeat:repeat;' + + 'padding:0;margin:4px 0;overflow:hidden;' + + 'font-family:proxima-nova,sans-serif;"', + // CONFIRMED live: background:linear-gradient(...) and box-shadow + // do not survive Roll20's chat rendering the way a flat + // background-color does - the whole declaration gets dropped, + // not just downgraded, which is why buttons fell back to Roll20's + // default pink and the header lost its panel entirely (revealing + // the container's image behind it). Flat colors only from here. + header: 'style="background-color:#223142;' + + 'color:#e4eaf0;padding:4px 8px;font-weight:bold;font-size:1.05em;overflow:hidden;"', + headerLink: 'style="display:block;color:#e4eaf0 !important;' + + 'background-color:transparent !important;border:none !important;' + + 'text-decoration:none;font-weight:bold;cursor:pointer;"', + // Same as headerLink, but inline (no display:block) - the menu's + // "Locksmith" title has to share its line with the floated "?" + // help link, and a block-level box there would push that float + // out of position. + headerTitleLink: 'style="color:#e4eaf0 !important;' + + 'background-color:transparent !important;border:none !important;' + + 'text-decoration:none;font-weight:bold;cursor:pointer;"', + // Lower opacity than before (was 0.82, nearly obscuring the + // texture entirely) - just enough tint to keep text legible + // while letting the tiled metal texture actually show through. + body: 'style="padding:4px 6px;background-color:rgba(140,148,155,0.35);"', + row: 'style="padding:3px 0;border-bottom:1px solid #6b7278;overflow:hidden;"', + rowLast: 'style="padding:3px 0;overflow:hidden;"', + label: 'style="float:left;color:#223142;font-weight:bold;"', + value: 'style="float:right;color:#1a1a1a;"', + // Ping links reuse the report's own label/header navy - no new color. + nameLink: 'style="color:#223142 !important;background-color:transparent !important;' + + 'border:none !important;text-decoration:underline;font-weight:bold;"', + // Filled "badge" style - a , not a link, so there's no + // button affordance at all (no href, no clickability); pill + // shape (much more rounded than a button's 4px corners) and no + // border reinforce the same thing visually. Deliberately compact + // (small font, tight padding) and never nowrap - the row already + // has a name and two buttons sharing space with this, so if + // something has to give under a narrow chat panel, it should be + // this text wrapping within its own cell, not the whole row + // forcing horizontal overflow. line-height is tightened since + // "Magic only (Knock)" wraps to two lines and default leading + // looked loose - safe to tighten since all-caps text has no + // ascenders/descenders to clip. + statusPickable: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#2e7d32;"', + statusUnpickable: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#8b0000;"', + statusMagic: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#4527a0;"', + statusUnset: 'style="display:inline-block;padding:2px 6px;border-radius:10px;' + + 'font-weight:bold;font-size:0.75em;line-height:1;color:#fff;text-transform:uppercase;' + + 'letter-spacing:0.5px;background-color:#777777;"', + footer: 'style="clear:both;padding:3px 8px;font-size:0.8em;' + + 'color:#3f4d5c;border-top:1px solid #6b7278;"', + // Unlock button: the one place a non-neutral color (green, already + // used for statusPickable) is deliberately kept distinct. + button: 'style="display:block;margin:4px 0;padding:6px 8px;' + + 'background-color:#2e7d32 !important;' + + 'color:#fff !important;border:1px solid #143d18 !important;text-align:center;border-radius:4px;' + + 'text-decoration:none;font-weight:bold;"', + // Every other button (Lock/Unlock Selected, Set DC, Report DC, + // Unpickable, Magically Locked) shares this single neutral style - + // the header's own navy, not a distinct color per action. + buttonNeutral: 'style="display:block;margin:4px 0;padding:6px 8px;' + + 'background-color:#223142 !important;' + + 'color:#dfe6ec !important;border:1px solid #17212c !important;text-align:center;border-radius:4px;' + + 'text-decoration:none;font-weight:bold;"', + // Same colors/treatment as buttonNeutral, but inline for the + // grouped two-column menu rows (several buttons side by side). + buttonNeutralInline: 'style="display:inline-block;margin:2px 3px 2px 0;padding:3px 6px;' + + 'background-color:#223142 !important;' + + 'color:#dfe6ec !important;border:1px solid #17212c !important;border-radius:4px;' + + 'text-decoration:none;font-weight:bold;font-size:0.85em;"', + menuGroupLabel: 'style="font-weight:bold;color:#223142;padding:4px 6px 4px 0;' + + 'vertical-align:middle;white-space:nowrap;"', + menuGroupCell: 'style="padding:4px 0;vertical-align:middle;"', + menuTable: 'style="width:100%;border-collapse:collapse;"', + // Raw CSS text (no style="" wrapper) - _buildDoorStatusTable + // combines these with an extra property for the status column, + // and a wrapped constant can't be safely merged with more CSS + // without producing two style="" attributes on one element. + lockListCellCss: 'padding:2px 3px 2px 0;border-bottom:1px solid #6b7278;', + lockListCellLastCss: 'padding:2px 3px 2px 0;', + menuDivider: 'style="border-top:1px solid #6b7278;margin:6px 0;"', + // No border/padding/background - just a plain "?" floated in the + // header bar, unlike every other button in this system. + headerHelpLink: 'style="float:right;color:#e4eaf0 !important;' + + 'background:transparent !important;border:none !important;' + + 'padding:0;margin:0;text-decoration:none;font-weight:bold;cursor:pointer;"', + sectionLabel: 'style="font-weight:bold;color:#223142;padding:4px 0 2px 0;"' + }; + // Core - door/window color/DC encoding logic + var Core = { + MIN_DC: 8, + MAX_DC: 50, + DC_VALUE_OFFSET: 5, // DC = value + 5 + + UNSET_VALUE: 0, + UNPICKABLE_VALUE: 1, + MAGIC_ONLY_VALUE: 2, + MIN_DC_VALUE: 3, // encodes MIN_DC + MAX_DC_VALUE: 45, // encodes MAX_DC + + DOOR_PREFIX: 'FF99', + WINDOW_SUFFIX: 'FFFF', + + // Normalizes a color string to a 6-char uppercase hex string, + // or null if it isn't a recognizable 6-digit hex color. + normalizeColor: function(colorStr) { + if (!colorStr) return null; + var hex = colorStr.replace('#', '').toUpperCase(); + if (/^[0-9A-F]{6}$/.test(hex)) return hex; + return null; + }, + + // Is this door/window color one this script manages? + isInScope: function(type, colorStr) { + var hex = this.normalizeColor(colorStr); + if (!hex) return false; + if (type === 'door') return hex.substring(0, 4) === this.DOOR_PREFIX; + if (type === 'window') return hex.substring(2, 6) === this.WINDOW_SUFFIX; + return false; + }, + + // Pulls the single encoded byte (0-255) out of an in-scope color. + // Caller must have already checked isInScope. + getEncodedByte: function(type, colorStr) { + var hex = this.normalizeColor(colorStr); + var byteHex = (type === 'door') ? hex.substring(4, 6) : hex.substring(0, 2); + return parseInt(byteHex, 16); + }, + + // Rebuilds the full 6-char color string with a new encoded byte. + buildColor: function(type, value) { + var byteHex = value.toString(16).toUpperCase(); + if (byteHex.length < 2) byteHex = '0' + byteHex; + if (type === 'door') return '#' + this.DOOR_PREFIX + byteHex; + return '#' + byteHex + this.WINDOW_SUFFIX; + }, + + valueToDC: function(value) { + return value + this.DC_VALUE_OFFSET; + }, + + // returns null if dc is outside the representable range + dcToValue: function(dc) { + if (dc < this.MIN_DC || dc > this.MAX_DC) return null; + return dc - this.DC_VALUE_OFFSET; + }, + + // -> { state: 'out_of_scope' } | { state: 'unset' } | + // { state: 'unpickable' } | { state: 'magic_only' } | + // { state: 'set', dc: N } | { state: 'unrecognized', raw: N } + getLockStatus: function(type, colorStr) { + if (!this.isInScope(type, colorStr)) return { state: 'out_of_scope' }; + var value = this.getEncodedByte(type, colorStr); + if (value === this.UNSET_VALUE) return { state: 'unset' }; + if (value === this.UNPICKABLE_VALUE) return { state: 'unpickable' }; + if (value === this.MAGIC_ONLY_VALUE) return { state: 'magic_only' }; + if (value >= this.MIN_DC_VALUE && value <= this.MAX_DC_VALUE) { + return { state: 'set', dc: this.valueToDC(value) }; + } + return { state: 'unrecognized', raw: value }; + } + }; + // RollParser - pulls a Thieves' Tools roll's total/mode out of chat + // messages, for either the Beacon or legacy character sheet. + var RollParser = { + + // Letters-only, lowercased - lets us compare "Thieves' Tools", + // "Thieves’ Tools", "Thieves Tools", etc. as equivalent. + normalize: function(str) { + return (str || '').replace(/[^a-z]/gi, '').toLowerCase(); + }, + + // Resolves totals that arrive as unresolved arithmetic + // strings (e.g. "14+2") instead of numbers. Whitelist-only, + // safer than eval. + resolveExpression: function(value) { + if (typeof value === 'number') return value; + if (typeof value !== 'string') return null; + var trimmed = value.trim(); + if (trimmed === '') return null; + var asNumber = Number(trimmed); + if (!isNaN(asNumber)) return asNumber; + if (!/^[\d+\-*/().\s]+$/.test(trimmed)) return null; + try { + /* eslint-disable no-new-func */ + var result = Function('"use strict"; return (' + trimmed + ');')(); + return (typeof result === 'number' && !isNaN(result)) ? result : null; + } catch (e) { + return null; + } + }, + + // Walks a structured `rolls` array for the first real die + // (dice > 0), ignoring flat "0d20+N" stub groups (e.g. + // Reliable Talent wrappers). + findFirstDie: function(rollsArray) { + if (!Array.isArray(rollsArray)) return null; + for (var i = 0; i < rollsArray.length; i++) { + var item = rollsArray[i]; + if (!item) continue; + if (item.type === 'R' && item.dice > 0 && item.results && item.results.length > 0) { + return item.results[0].v; + } + if (item.type === 'G' && Array.isArray(item.rolls)) { + for (var j = 0; j < item.rolls.length; j++) { + var found = this.findFirstDie(item.rolls[j]); + if (found !== null) return found; + } + } + } + return null; + }, + + // --- Beacon (D&D 2024) sheet ----------------------------- + // Extracts the visible roll title, e.g. "Thieves' Tools", from + // the rendered rolltemplate HTML. + extractBeaconTitle: function(content) { + if (!content) return null; + var m = /
\s*([^<]+?)\s*<\/div>/i.exec(content); + return m ? m[1] : null; + }, + + // Extracts the rolling character's name from the HTML meta + // block, e.g. "Callexis". + extractBeaconCharname: function(content) { + if (!content) return null; + var m = /
\s*([^<]+?)\s*<\/div>/i.exec(content); + return m ? m[1] : null; + }, + + // Per-source bonus breakdown only exists in Beacon's HTML, + // not the structured rolls data. + extractBeaconBonuses: function(content) { + var bonuses = []; + if (!content) return bonuses; + var re = /
\s*([^<]*)<\/span>\s*([^<]*)<\/span>\s*<\/div>/gi; + var m; + while ((m = re.exec(content))) { + bonuses.push({ label: m[1].trim(), value: m[2].trim() }); + } + return bonuses; + }, + + // NOTE: rolls.Base.results.total is pre-modifier - the real + // total comes from the HTML die__total spans. Structured data + // is still used for natural/crit/fumble. + extractBeaconRollData: function(msg) { + if (!msg || msg.type !== 'advancedroll' || !msg.content) return null; + + var title = this.extractBeaconTitle(msg.content); + if (this.normalize(title) !== 'thievestools') return null; + + var modeMatch = /dnd-2024__header--(Normal|Advantage|Disadvantage)/i.exec(msg.content); + var mode = modeMatch ? modeMatch[1].toLowerCase() : 'unknown'; + + // First die__total span is always the preferred/kept one; + // a second (if present) is the dropped advantage/disadvantage die. + var dieTotals = []; + var dieTotalRe = / r1Total) { total = r2Total; otherTotal = r1Total; natural = r2Natural; otherNatural = r1Natural; } + else { total = r1Total; otherTotal = r2Total; natural = r1Natural; otherNatural = r2Natural; } + } else if (mode === 'disadvantage') { + if (r2Total < r1Total) { total = r2Total; otherTotal = r1Total; natural = r2Natural; otherNatural = r1Natural; } + else { total = r1Total; otherTotal = r2Total; natural = r1Natural; otherNatural = r2Natural; } + } else { + // normal - r2, if present, is an unused stub + total = r1Total; natural = r1Natural; + } + } + + var modValue = this.extractLegacyModValue(msg.content); + var bonuses = (modValue !== null) + ? [{ label: 'Ability + PB', value: (modValue >= 0 ? '+' : '') + modValue }] + : []; + + var result = { + title: rname, + charname: charname, + mode: mode, + total: total, + otherTotal: otherTotal, + natural: natural, + otherNatural: otherNatural, + isCrit: (natural === 20) || null, + isFumble: (natural === 1) || null, + bonuses: bonuses, + sheet: 'legacy', + playerid: msg.playerid, + characterId: msg.rolledByCharacterId, + needsConfirmation: needsConfirmation + }; + + if (needsConfirmation) { + result.confirmation = { + r1: { total: r1Total, natural: r1Natural }, + r2: r2 ? { total: r2Total, natural: r2Natural } : null, + extraRolls: extraRolls + }; + } + + return result; + }, + + extractNaturalFromInlineRoll: function(inlineRoll) { + if (!inlineRoll || !inlineRoll.results || !Array.isArray(inlineRoll.results.rolls)) return null; + return this.findFirstDie(inlineRoll.results.rolls); + }, + + // Knock is a no-roll spell, detected by title only. + // Beacon: type 'advancedroll', characterId included. + // Legacy: type 'whisper' (sheet whispers to GM by default), + // rolltemplate 'spell', raw macros - NO characterId, only + // {{charname=...}}, resolved downstream by name. + extractKnockCast: function(msg) { + if (!msg || !msg.content) return null; + + if (msg.type === 'advancedroll') { + var title = this.extractBeaconTitle(msg.content); + if (this.normalize(title) !== 'knock') return null; + return { + charname: this.extractBeaconCharname(msg.content), + playerid: msg.playerid, + characterId: msg.characterId + }; + } + + if (msg.rolltemplate === 'spell') { + var nameMatch = /\{\{name=([^}]*)\}\}/i.exec(msg.content); + var spellName = nameMatch ? nameMatch[1].trim() : null; + if (this.normalize(spellName) !== 'knock') return null; + + var charnameMatch = /\{\{charname=([^}]*)\}\}/i.exec(msg.content); + return { + charname: charnameMatch ? charnameMatch[1].trim() : null, + playerid: msg.playerid, + characterId: null + }; + } + + return null; + }, + + // Tries Beacon then legacy; null if neither matches. + extractRollData: function(msg) { + return this.extractBeaconRollData(msg) || this.extractLegacyRollData(msg); + } + }; + // PageUtils + var PageUtils = { + // Used verbatim for all page lookups. + getPageForPlayer: function(playerid) { + var player = getObj('player', playerid); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + var psp = Campaign().get('playerspecificpages') || {}; + if (psp[playerid]) { + return psp[playerid]; + } + return Campaign().get('playerpageid'); + }, + + // Real-world distance per grid square, read from the page itself + // rather than assumed - a page can be set to anything (10ft + // squares, meters, etc), not just the 5ft D&D default. Falls back + // to 5 if the page can't be found or has no scale set. + getFeetPerSquare: function(pageId) { + var page = getObj('page', pageId); + var scale = page && page.get('scale_number'); + return (scale && scale > 0) ? scale : 5; + }, + + // The 70px/square pixel geometry this script assumes throughout + // is a Roll20 platform constant for square grids specifically, + // not something scale_number affects - but it does NOT hold for + // hex grids, which have entirely different cell geometry. This + // only detects the mismatch and lets callers warn; it doesn't + // attempt real hex-grid support. + isSquareGrid: function(pageId) { + var page = getObj('page', pageId); + var gridType = page && page.get('grid_type'); + return !gridType || gridType === 'square'; + } + }; + // Geometry + var Geometry = { + // ASSUMPTION (unverified for x): door/window x/y use an + // inverted axis vs a token's left/top - Roll20's docs only + // confirm this for y (top 100 = y -100). Verify with !lock --coords. + doorPixelPosition: function(doorOrWindowObj) { + return { + left: doorOrWindowObj.get('x'), + top: -doorOrWindowObj.get('y') + }; + }, + + tokenPixelPosition: function(tokenObj) { + return { + left: tokenObj.get('left'), + top: tokenObj.get('top') + }; + }, + + distance: function(pointA, pointB) { + var dx = pointA.left - pointB.left; + var dy = pointA.top - pointB.top; + return Math.sqrt(dx * dx + dy * dy); + }, + + maxRangePixels: function() { + return PIXELS_PER_SQUARE * RANGE_SQUARES; + } + }; + // TokenUtils + var TokenUtils = { + findTokensForCharacter: function(pageId, characterId) { + if (!pageId || !characterId) return []; + return findObjs({ _type: 'graphic', _pageid: pageId, represents: characterId }) || []; + }, + + // For messages with only a charname, no id (legacy Knock). + // First match wins if names collide. + findCharacterIdByName: function(charname) { + if (!charname) return null; + var matches = findObjs({ _type: 'character', name: charname }); + return (matches && matches.length > 0) ? matches[0].id : null; + } + }; + + // Keys and keyrings - a custom character attribute ("keyring") + // storing a comma-separated list of "type:id" tokens (e.g. + // "door:-Nabc123"), one per key held. Type is stored alongside + // the id rather than guessed later by trying both object types. + var KeyringUtils = { + ATTR_NAME: 'keyring', + TOKEN_ACTION_NAME: 'Keys.', + + // The trailing period is intentional - see the comment on + // handleKeyCreateAction for why. + hasTokenAction: function(characterId) { + return findObjs({ _type: 'ability', characterid: characterId, name: this.TOKEN_ACTION_NAME }).length > 0; + }, + + getAttr: function(characterId) { + return findObjs({ _type: 'attribute', _characterid: characterId, name: this.ATTR_NAME })[0] || null; + }, + + // -> [{ type, id }, ...] + getKeys: function(characterId) { + var attr = this.getAttr(characterId); + if (!attr) return []; + var raw = attr.get('current') || ''; + return raw.split(',') + .map(function(s) { return s.trim(); }) + .filter(function(s) { return s.length > 0; }) + .map(function(token) { + var parts = token.split(':'); + return { type: parts[0], id: parts[1] }; + }) + .filter(function(k) { return k.type && k.id; }); + }, + + hasKey: function(characterId, type, id) { + var keys = this.getKeys(characterId); + for (var i = 0; i < keys.length; i++) { + if (keys[i].type === type && keys[i].id === id) return true; + } + return false; + }, + + // returns false if the character already had this key + addKey: function(characterId, type, id) { + if (this.hasKey(characterId, type, id)) return false; + var attr = this.getAttr(characterId); + var tokens = this.getKeys(characterId).map(function(k) { return k.type + ':' + k.id; }); + tokens.push(type + ':' + id); + var newValue = tokens.join(','); + if (attr) { + attr.set('current', newValue); + } else { + createObj('attribute', { _characterid: characterId, name: this.ATTR_NAME, current: newValue }); + } + return true; + }, + + // returns false if the character didn't have this key + removeKey: function(characterId, type, id) { + var attr = this.getAttr(characterId); + if (!attr) return false; + var keys = this.getKeys(characterId); + var filtered = keys.filter(function(k) { return !(k.type === type && k.id === id); }); + if (filtered.length === keys.length) return false; + attr.set('current', filtered.map(function(k) { return k.type + ':' + k.id; }).join(',')); + return true; + } + }; + // DoorFinder - locates the nearest Locksmith-managed door/window + var DoorFinder = { + // Only considers doors/windows Core.isInScope recognizes - + // an unmanaged (non-FF99/FFFF) door is invisible to this feature, + // same as it is to any GM setup command. + findNearestInScopeLock: function(pageId, tokenPoint) { + var candidates = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + + var maxRange = Geometry.maxRangePixels(); + var best = null; + + for (var i = 0; i < candidates.length; i++) { + var obj = candidates[i]; + var type = obj.get('_type'); + var color = obj.get('color'); + if (!Core.isInScope(type, color)) continue; + + var doorPoint = Geometry.doorPixelPosition(obj); + var dist = Geometry.distance(tokenPoint, doorPoint); + if (dist > maxRange) continue; + + if (!best || dist < best.distance) { + best = { obj: obj, type: type, color: color, distance: dist }; + } + } + + return best; + }, + + // For the Knock spell: 60ft range (no melee safety-margin + // tolerance - that's specific to pick-attempt measurement + // error, not spell range), and only locked, non-unpickable targets - + // Knock explicitly can bypass magic locks (per the spell's own + // text about suppressing Arcane Lock), just not whatever + // "unpickable" represents in this system. + KNOCK_RANGE_FEET: 60, + + findNearestKnockableLock: function(pageId, casterPoint) { + var candidates = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + + var feetPerSquare = PageUtils.getFeetPerSquare(pageId); + var maxRange = PIXELS_PER_SQUARE * (this.KNOCK_RANGE_FEET / feetPerSquare); + var best = null; + + for (var i = 0; i < candidates.length; i++) { + var obj = candidates[i]; + var type = obj.get('_type'); + var color = obj.get('color'); + if (!Core.isInScope(type, color)) continue; + if (!obj.get('isLocked')) continue; + + var status = Core.getLockStatus(type, color); + if (status.state === 'unpickable') continue; + + var doorPoint = Geometry.doorPixelPosition(obj); + var dist = Geometry.distance(casterPoint, doorPoint); + if (dist > maxRange) continue; + + if (!best || dist < best.distance) { + best = { obj: obj, type: type, color: color, distance: dist, status: status }; + } + } + + return best; + } + }; + // "Show/Hide All Lock DCs": GM-layer text label above every + // door/window (id + status on two lines, one createObj call + // each). No stored state - existing labels found live via the + // distinctive FONT_SIZE. Fill/stroke are the same hex properties + // as paths. OFFSET_X_* tunable per label type, default 0 - text + // has no true centerpoint, tune in-game. + var TextLabels = { + FONT_SIZE: 14.01, // fractional on purpose - fingerprints script-made labels for toggle detection + FONT_FAMILY: 'Candal', + FILL_COLOR: '#FFFFFF', + // Whole label (both lines) uses this instead of FILL_COLOR + // when a character with a token on this page holds a key + // for that specific door/window. Both lines turn yellow, + // not just the status line - the id+status label is one + // merged text object (see the note above this block on why), + // and a Roll20 text object can't have mixed colors within + // itself. + KEY_PRESENT_COLOR: '#ffff00', + STROKE_COLOR: '#000000', + OFFSET_Y: -43, // 30px above the door/window, plus 8px descender clearance, plus 5px adjustment from live testing + + OFFSET_X_DC: 0, + OFFSET_X_UNSET: 0, + OFFSET_X_UNPICKABLE: 0, + OFFSET_X_MAGIC: 0, + + labelTextFor: function(status) { + if (status.state === 'set') return 'DC' + status.dc; + if (status.state === 'unpickable') return 'Unpickable'; + if (status.state === 'magic_only') return 'Magic'; + // unset, out_of_scope, and unrecognized all read the same + // to a GM glancing at the map - none of them are pickable + // in their current state. + return 'Unset'; + }, + + // The id line (always 4 chars) reads as off-center above a + // much wider status word below it - these leading spaces are + // a manual, approximate nudge toward center, not true + // centering (Candal isn't monospace, so exact math doesn't + // apply anyway). DC##/Unset are already close enough in + // width to the id line that they don't need this. + idPaddingFor: function(status) { + if (status.state === 'unpickable') return ' '; + if (status.state === 'magic_only') return ' '; + return ''; + }, + + offsetXFor: function(status) { + if (status.state === 'set') return this.OFFSET_X_DC; + if (status.state === 'unpickable') return this.OFFSET_X_UNPICKABLE; + if (status.state === 'magic_only') return this.OFFSET_X_MAGIC; + return this.OFFSET_X_UNSET; + }, + + findExisting: function(pageId) { + return findObjs({ _type: 'text', _pageid: pageId, layer: 'gmlayer', font_size: this.FONT_SIZE }) || []; + }, + + // Every "type:id" key held by any character with a token on + // this page, computed once per createAll() call rather than + // re-scanning all tokens for every door - a plain object used + // as a hash-set, matching this file's existing style rather + // than ES6 Set. Minimal/snapshot-at-refresh-time by design - + // this does NOT stay live if a key changes hands or a token + // moves after labels are shown; it's only as current as the + // last label refresh, same as everything else labels show. + _keysPresentOnPage: function(pageId) { + var present = {}; + var tokens = findObjs({ _type: 'graphic', _pageid: pageId }) || []; + for (var i = 0; i < tokens.length; i++) { + var characterId = tokens[i].get('represents'); + if (!characterId) continue; + var keys = KeyringUtils.getKeys(characterId); + for (var k = 0; k < keys.length; k++) { + present[keys[k].type + ':' + keys[k].id] = true; + } + } + return present; + }, + + createAll: function(pageId) { + var targets = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + + var keysPresent = this._keysPresentOnPage(pageId); + + var created = 0; + for (var i = 0; i < targets.length; i++) { + var obj = targets[i]; + var type = obj.get('_type'); + var status = Core.getLockStatus(type, obj.get('color')); + // One object, two lines (id, then status) - one + // createObj call per door/window instead of two, since + // that call is the actual cost driving how slow this + // is on a map with many doors/windows. + var text = this.idPaddingFor(status) + obj.id.slice(-4) + '\n' + this.labelTextFor(status); + var point = Geometry.doorPixelPosition(obj); + var fillColor = keysPresent[type + ':' + obj.id] ? this.KEY_PRESENT_COLOR : this.FILL_COLOR; + + var textObj = createObj('text', { + _pageid: pageId, + layer: 'gmlayer', + left: point.left + this.offsetXFor(status), + top: point.top + this.OFFSET_Y, + text: text, + font_size: this.FONT_SIZE, + font_family: this.FONT_FAMILY, + color: fillColor, + stroke: this.STROKE_COLOR + }); + + if (textObj) { + created++; + // One-time sanity check: if Roll20 rounds the + // fractional font_size, toggle detection silently + // stops working on the next click - surface that + // immediately rather than let it fail quietly. + if (created === 1 && textObj.get('font_size') !== this.FONT_SIZE) { + Logger.warn('font_size stored as ' + textObj.get('font_size') + + ' instead of ' + this.FONT_SIZE + ' - toggle detection may not work reliably.'); + } + } + } + return created; + }, + + deleteAll: function(pageId) { + var existing = this.findExisting(pageId); + for (var i = 0; i < existing.length; i++) existing[i].remove(); + return existing.length; + }, + + // Regenerates all shown labels after any DC/state change, + // rather than tracking/updating one specific label. + refreshIfShown: function(pageId) { + if (this.findExisting(pageId).length === 0) return; + this.deleteAll(pageId); + this.createAll(pageId); + } + }; + // Command-arg encoding for the unlock button (spaces would otherwise + // split a character name across multiple chat command args) + // Command-arg encoding (spaces would otherwise split a character + // name across multiple chat command args) + function encodeForCommand(str) { + return String(str || '').replace(/\s+/g, '_'); + } + function decodeFromCommand(str) { + return String(str || '').replace(/_/g, ' '); + } + // Backs the collapse/expand report pattern (except Report DC, always + // full). Capped, oldest evicted. Only persistent state this script + // keeps - DC data lives in door/window color, never ages out. + var ReportCache = { + _ensureState: function() { + if (!state.Locksmith) state.Locksmith = { reports: {}, order: [] }; + return state.Locksmith; + }, + + store: function(playerid, headerText, bodyHtml, footerText, isPublic) { + var s = this._ensureState(); + var id = Math.floor(Math.random() * 1679616).toString(36); // up to 4 base36 chars + + s.reports[id] = { + playerid: playerid, headerText: headerText, bodyHtml: bodyHtml, + footerText: footerText || null, isPublic: !!isPublic + }; + s.order.push(id); + + while (s.order.length > MAX_CACHED_REPORTS) { + var evictedId = s.order.shift(); + delete s.reports[evictedId]; + } + + return id; + }, + + get: function(id) { + var s = this._ensureState(); + return s.reports[id] || null; + } + }; + // Chat - all whispered/public output for the whole system. + // No emoji/icons anywhere - headers are plain text. + var Chat = { + _displayNameFor: function(playerid) { + var player = getObj('player', playerid); + return player ? player.get('_displayname') : 'GM'; + }, + + // Renders a list of {label, value} pairs as styled rows, with the + // last one using CSS.rowLast automatically. + _renderRows: function(items) { + var out = ''; + for (var i = 0; i < items.length; i++) { + var style = (i === items.length - 1) ? CSS.rowLast : CSS.row; + out += '
' + items[i].label + '' + + '' + items[i].value + '
'; + } + return out; + }, + + // A single styled header bar with no body and no expand link - + // used for short, self-contained messages (errors, the "not + // locked" short-circuit) that have nothing further to show. + whisperHeaderOnly: function(playerid, headerText) { + var name = this._displayNameFor(playerid); + var html = '
' + headerText + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + whisperError: function(playerid, message) { + this.whisperHeaderOnly(playerid, message); + }, + + // Same container style as everything else, but no header row - + // just a single button linking to the help handout. + whisperHelpLink: function(playerid, handoutId) { + var name = this._displayNameFor(playerid); + var link = 'Open Locksmith Help Documentation'; + var html = '
' + link + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + // Keys/keyrings are deliberately private, but visible to both the + // sender and the GM - unlike everything else lock-related, which + // is either fully public or GM-only. "gm" is a literal whisper + // target Roll20 recognizes regardless of the GM's display name. + // Skips the duplicate GM copy if the sender already is the GM. + // headerCommand (optional) makes the header itself clickable, + // reissuing that command - only passed by handleKeyring's own + // calls, not the many other keyring-related messages that share + // this function (Use/Give/Drop confirmations, errors, etc.) - + // those stay plain text as before. + deliverKeyMessage: function(playerid, headerText, bodyHtml, footerText, headerCommand) { + var name = this._displayNameFor(playerid); + var headerHtml = headerCommand ? + '' + headerText + '' : + headerText; + var html = '
' + + '
' + headerHtml + '
' + + (bodyHtml ? '
' + bodyHtml + '
' : '') + + (footerText ? '
' + footerText + '
' : '') + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + if (!playerIsGM(playerid)) { + sendChat(SCRIPT_NAME, '/w gm ' + html); + } + }, + + // The standard report pattern: header only, as a clickable link + // that posts the cached full body when clicked. alwaysVisibleHtml + // (optional) renders immediately regardless of collapse state - + // used for the unlock button, which must never be hidden behind + // a click. + whisperCollapsed: function(playerid, headerText, bodyHtml, footerText, alwaysVisibleHtml) { + var name = this._displayNameFor(playerid); + var id = ReportCache.store(playerid, headerText, bodyHtml, footerText); + var headerLink = '' + headerText + ''; + var html = '
' + headerLink + '
' + + (alwaysVisibleHtml ? '
' + alwaysVisibleHtml + '
' : '') + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + // The full card (header + body + footer), used both for expanding + // a cached report and for reports that are never collapsed + // (Report DC). headerCommand (optional) makes the header itself + // clickable, reissuing that command - only passed by callers + // that actually want this; omitted, it's plain text as before. + whisperFull: function(playerid, headerText, bodyHtml, footerText, headerCommand) { + var name = this._displayNameFor(playerid); + var headerHtml = headerCommand ? + '' + headerText + '' : + headerText; + var html = '
' + + '
' + headerHtml + '
' + + '
' + bodyHtml + '
' + + (footerText ? '
' + footerText + '
' : '') + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + }, + + // ---- Public versions for lock-picking outcomes (GM commands + // above stay whispered). fromName posts as the rolling character. + postHeaderOnly: function(fromName, headerText) { + var html = '
' + headerText + '
'; + sendChat(fromName || SCRIPT_NAME, html); + }, + + postCollapsed: function(fromName, playerid, headerText, bodyHtml, footerText, alwaysVisibleHtml) { + var id = ReportCache.store(playerid, headerText, bodyHtml, footerText, true); + var headerLink = '' + headerText + ''; + var html = '
' + headerLink + '
' + + (alwaysVisibleHtml ? '
' + alwaysVisibleHtml + '
' : '') + + '
'; + sendChat(fromName || SCRIPT_NAME, html); + }, + + postFull: function(fromName, headerText, bodyHtml, footerText) { + var html = '
' + + '
' + headerText + '
' + + '
' + bodyHtml + '
' + + (footerText ? '
' + footerText + '
' : '') + + '
'; + sendChat(fromName || SCRIPT_NAME, html); + }, + + // ---- Knock spell outcome. No roll/total exists for this (it's an + // automatic effect, not a check), so there's no detail worth + // hiding behind an expand click - the button is just always there. + postKnockSuccess: function(charname, best) { + var unlockCmd = '!lock --unlock-target ' + best.type + ' ' + best.obj.id + ' ' + + encodeForCommand(charname || 'Someone'); + var label = (best.type === 'door') ? 'Unlock the door' : 'Unlock the window'; + var button = '' + label + ''; + this.postFull(charname, 'Knock!', button, null); + }, + + // ---- GM setup (!lock --set / --unpickable / --knock) errors only - + // success is silent, so this only ever renders 'error' entries + // (DC out of range, or a color write that didn't persist). + whisperSetupResults: function(playerid, headerText, results) { + var items = []; + for (var i = 0; i < results.length; i++) { + var r = results[i]; + var value = '' + r.message + ''; + items.push({ label: r.label, value: value }); + } + this.whisperCollapsed(playerid, headerText, this._renderRows(items), null); + }, + + // ---- Report DC - always shown in full immediately, never collapsed ---- + // Shared by Report DC and the menu's door/window list - guarantees + // the two look identical. One real + // with a dedicated column per piece (name, +Key, +Loot, status) + // so buttons line up cleanly across rows regardless of how long + // each door/window's name is - a floated label/value div can't + // do that once there's more than one button per row. + _statusValueHtml: function(r) { + if (r.state === 'set') return 'DC ' + r.dc + ''; + if (r.state === 'unset') return 'No DC set'; + if (r.state === 'unpickable') return 'Unpickable'; + if (r.state === 'magic_only') return 'Magic only (Knock)'; + if (r.state === 'unrecognized') return 'Unrecognized'; + return 'Not managed'; + }, + + _buildDoorStatusTable: function(results) { + var rows = ''; + for (var i = 0; i < results.length; i++) { + var r = results[i]; + var cellCss = (i === results.length - 1) ? CSS.lockListCellLastCss : CSS.lockListCellCss; + var cellStyle = 'style="' + cellCss + '"'; + var statusCellStyle = 'style="' + cellCss + 'text-align:right;"'; + var pingLink = '' + r.label + ''; + var addCmd = '!lock --key-add ' + r.objType + ' ' + r.objId + + ' @{target|Give ' + r.label + ' key to whom?|token_id}'; + var addBtn = '+Key'; + var lootCmd = '!lock --key-loot-macro ' + r.objType + ' ' + r.objId; + var lootBtn = '+Loot'; + + rows += '' + + '' + + '' + + '' + + '' + + ''; + } + return '
' + pingLink + '' + addBtn + '' + lootBtn + '' + this._statusValueHtml(r) + '
' + rows + '
'; + }, + + whisperReportDC: function(playerid, results, pageWide) { + var headerText = pageWide ? (results.length + ' locks on this page') : (results.length + ' selected'); + var footerText = pageWide ? 'No selection - showing every door/window on this page.' : null; + this.whisperFull(playerid, headerText, this._buildDoorStatusTable(results), footerText, '!lock --report-dc'); + }, + + // ---- Roll-driven outcomes ---- + _rollItems: function(rollData) { + var modeLabel = rollData.mode.charAt(0).toUpperCase() + rollData.mode.slice(1); + var natLabel = rollData.natural; + if (rollData.isCrit) natLabel += ' (crit)'; + if (rollData.isFumble) natLabel += ' (fumble)'; + + var items = []; + if (rollData.charname) items.push({ label: 'Character', value: rollData.charname }); + items.push({ label: 'Total', value: rollData.total }); + items.push({ label: 'Mode', value: modeLabel }); + if (rollData.otherTotal !== null) items.push({ label: 'Other die', value: rollData.otherTotal + ' (dropped)' }); + items.push({ label: 'Natural', value: natLabel }); + for (var i = 0; i < rollData.bonuses.length; i++) { + items.push({ label: rollData.bonuses[i].label, value: rollData.bonuses[i].value }); + } + return items; + }, + + whisperConfirmation: function(rollData) { + var c = rollData.confirmation || { r1: null, r2: null, extraRolls: [] }; + var reasonLabel = (rollData.mode === 'always') + ? 'Both dice rolled - mode decided verbally' + : 'Roll mode not recognized'; + + var items = []; + if (rollData.charname) items.push({ label: 'Character', value: rollData.charname }); + if (c.r1) items.push({ label: 'r1', value: c.r1.total + ' (natural ' + c.r1.natural + ')' }); + if (c.r2) items.push({ label: 'r2', value: c.r2.total + ' (natural ' + c.r2.natural + ')' }); + for (var i = 0; i < c.extraRolls.length; i++) { + var er = c.extraRolls[i]; + items.push({ label: er.label || 'Bonus die', value: er.total + ' (already included above)' }); + } + for (var j = 0; j < rollData.bonuses.length; j++) { + items.push({ label: rollData.bonuses[j].label, value: rollData.bonuses[j].value }); + } + + this.postCollapsed(rollData.charname, rollData.playerid, 'Thieves\' Tools Roll Needs Confirmation', + this._renderRows(items), reasonLabel + ' - confirm the result with the GM.'); + }, + + // The door/window isn't locked at all - nothing else is relevant. + // A single header-only message, nothing to expand. + whisperNotLocked: function(rollData) { + this.postHeaderOnly(rollData.charname, 'This Door is not locked'); + }, + + whisperNoLock: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'No lock within reach' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'No Lock In Range', this._renderRows(items), null); + }, + + whisperNoToken: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'No token found on this page' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'No Token Found', this._renderRows(items), null); + }, + + whisperUnset: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'No DC set - ask your GM' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'Lock Not Configured', this._renderRows(items), null); + }, + + // Result doesn't need to be repeated for unpickable/magic-only - + // the roll's outcome was never in question, so no total prefix. + whisperUnpickable: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'Cannot be picked with mundane tools' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'Unpickable', this._renderRows(items), null); + }, + + whisperMagicOnly: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'Requires magic (e.g. Knock)' }); + this.postCollapsed(rollData.charname, rollData.playerid, 'Magically Sealed', this._renderRows(items), null); + }, + + // DC comparison happened - header is prefixed with the total. The + // unlock button is passed as alwaysVisibleHtml so it's never + // hidden behind the header click - only the detail rows collapse. + whisperSuccess: function(rollData, best, status) { + var items = this._rollItems(rollData); + items.push({ label: 'DC', value: status.dc }); + + var unlockCmd = '!lock --unlock-target ' + best.type + ' ' + best.obj.id + ' ' + + encodeForCommand(rollData.charname || 'Someone'); + var unlockLabel = (best.type === 'door') ? 'Unlock the door' : 'Unlock the window'; + var button = '' + unlockLabel + ''; + + this.postCollapsed(rollData.charname, rollData.playerid, rollData.total + ' - Lock Picked!', this._renderRows(items), null, button); + }, + + // Deliberately does NOT include status.dc anywhere. + whisperFailure: function(rollData) { + var items = this._rollItems(rollData); + items.push({ label: 'Result', value: 'DC not beaten' }); + this.postCollapsed(rollData.charname, rollData.playerid, rollData.total + ' - Lock Holds', this._renderRows(items), null); + }, + + // Public, like every other lock-picking outcome - attributed to + // the character who picked it when a name is available. + whisperCoordsDebug: function(playerid, tokenInfo, doorInfo, distancePx, distanceSquares, gridInfo) { + var items = [ + { label: 'Token left/top', value: tokenInfo.left + ', ' + tokenInfo.top }, + { label: 'Door raw x/y', value: doorInfo.rawX + ', ' + doorInfo.rawY }, + { label: 'Door converted', value: doorInfo.left + ', ' + doorInfo.top }, + { label: 'Distance (px)', value: distancePx.toFixed(1) }, + { label: 'Distance (squares)', value: distanceSquares.toFixed(2) }, + { label: 'Grid type', value: gridInfo.gridType + (gridInfo.isSquare ? '' : ' - UNSUPPORTED, results below are unreliable') }, + { label: 'Feet per square', value: String(gridInfo.feetPerSquare) } + ]; + this.whisperCollapsed(playerid, 'Coordinate Debug', this._renderRows(items), null); + }, + + // ---- The Locksmith menu (bare !lock) ---- + // One row per door/window, matching Report DC's layout - no more + // multi-column table, since three buttons per row made that + // cramped. + // Computes the same {label, objType, objId, state, dc} shape + // Report DC uses, then reuses _buildDoorStatusTable - the two + // are now guaranteed identical, not just similar. + _buildLockRows: function(doors, windows) { + var all = doors.concat(windows); + var results = []; + for (var i = 0; i < all.length; i++) { + var lock = all[i]; + var type = lock.get('_type'); + var typeLabel = (type === 'door') ? 'Door' : 'Window'; + var status = Core.getLockStatus(type, lock.get('color')); + status.label = typeLabel + ' ' + lock.id.slice(-4); + status.objType = type; + status.objId = lock.id; + results.push(status); + } + return this._buildDoorStatusTable(results); + }, + + whisperMenu: function(msg) { + var doors = [], windows = []; + if (msg.selected) { + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + if (sel._type !== 'door' && sel._type !== 'window') continue; + var obj = getObj(sel._type, sel._id); + if (!obj) continue; + (sel._type === 'door' ? doors : windows).push(obj); + } + } + + var lockListSection = ''; + if (doors.length + windows.length > 0) { + lockListSection = '
' + + '
Selected Doors
' + + this._buildLockRows(doors, windows); + } + + var selectedButtons = + 'Lock' + + 'Unlock' + + 'Report'; + + var setButtons = + 'DC' + + 'Unpickable' + + 'Magic'; + + var menuTable = '' + + '' + + '' + + '
Selected' + selectedButtons + '
Set' + setButtons + '
'; + + var fullWidthButton = 'Show/Hide All Lock DCs'; + + var body = menuTable + fullWidthButton + lockListSection; + + var name = this._displayNameFor(msg.playerid); + var headerHtml = 'Locksmith' + + '?'; + var html = '
' + + '
' + headerHtml + '
' + + '
' + body + '
' + + '
'; + sendChat(SCRIPT_NAME, '/w "' + name + '" ' + html); + } + }; + // Commands + var Commands = { + + // Bare !lock - the Locksmith menu. GM-only. + handleMenu: function(msg) { + if (!playerIsGM(msg.playerid)) return; + Chat.whisperMenu(msg); + }, + + // GM-only: !lock --set / --unpickable / --knock + handleSetup: function(msg, args) { + if (!playerIsGM(msg.playerid)) { + Logger.warn('Non-GM player ' + msg.playerid + ' attempted setup command.'); + return; + } + + var doSet = false, doUnpickable = false, doKnock = false, setDC = null; + + for (var i = 0; i < args.length; i++) { + if (args[i] === '--unpickable') { + doUnpickable = true; + } else if (args[i] === '--knock') { + doKnock = true; + } else if (args[i] === '--set') { + var n = parseInt(args[i + 1], 10); + if (isNaN(n)) { + Chat.whisperError(msg.playerid, 'Usage: !lock --set # (e.g. !lock --set 15)'); + return; + } + doSet = true; + setDC = n; + i++; + } + } + + var modeCount = (doSet ? 1 : 0) + (doUnpickable ? 1 : 0) + (doKnock ? 1 : 0); + + if (modeCount > 1) { + Chat.whisperError(msg.playerid, 'Only one of --set, --unpickable, or --knock can be used at a time.'); + return; + } + if (modeCount === 0) { + Chat.whisperError(msg.playerid, 'Usage: !lock --set # | !lock --unpickable | !lock --knock'); + return; + } + if (!msg.selected || msg.selected.length === 0) { + Chat.whisperError(msg.playerid, 'No door or window selected.'); + return; + } + + var targets = []; + for (var j = 0; j < msg.selected.length; j++) { + var sel = msg.selected[j]; + if (sel._type === 'door' || sel._type === 'window') { + var obj = getObj(sel._type, sel._id); + if (obj) targets.push(obj); + } + } + if (targets.length === 0) { + Chat.whisperError(msg.playerid, 'Selection contains no doors or windows.'); + return; + } + + var problems = []; + + for (var k = 0; k < targets.length; k++) { + var target = targets[k]; + var type = target.get('_type'); + var typeLabel = (type === 'door') ? 'Door' : 'Window'; + var label = typeLabel + ' ' + target.id.slice(-4); + + // No scope check - explicit GM selection is unambiguous + // intent, color is rebuilt from scratch regardless of before. + var writeValue = null, rangeErrorMsg = null; + + if (doSet) { + writeValue = Core.dcToValue(setDC); + if (writeValue === null) { + rangeErrorMsg = 'DC ' + setDC + ' out of range (' + Core.MIN_DC + '-' + Core.MAX_DC + ')'; + } + } else if (doUnpickable) { + writeValue = Core.UNPICKABLE_VALUE; + } else if (doKnock) { + writeValue = Core.MAGIC_ONLY_VALUE; + } + + if (rangeErrorMsg) { + problems.push({ label: label, state: 'error', message: rangeErrorMsg }); + continue; + } + + var newColor = Core.buildColor(type, writeValue); + target.set('color', newColor); + var afterColor = target.get('color'); + var verifiedStatus = Core.getLockStatus(type, afterColor); + + Logger.info('Set ' + label + ' (' + target.id + ') attempted=[' + newColor + + '] readback=[' + afterColor + ']'); + + var wroteCorrectly = + (doSet && verifiedStatus.state === 'set' && verifiedStatus.dc === setDC) || + (doUnpickable && verifiedStatus.state === 'unpickable') || + (doKnock && verifiedStatus.state === 'magic_only'); + + // Success is intentionally silent - the color change (and, + // if labels are shown, the refreshed label) is the + // confirmation. Only failures need a GM's attention. + if (!wroteCorrectly) { + problems.push({ label: label, state: 'error', message: 'Write did not persist (readback: ' + afterColor + ')' }); + } + } + + if (problems.length > 0) { + Chat.whisperSetupResults(msg.playerid, 'Lock DC Set - Problems', problems); + } + + // Keep any shown GM-layer labels in sync with what just changed. + var pageId = PageUtils.getPageForPlayer(msg.playerid); + if (pageId) TextLabels.refreshIfShown(pageId); + }, + + // GM-only: !lock --lock-selected / --unlock-selected. Works on ANY + // selected door/window regardless of Locksmith scope, since + // isLocked is a native Roll20 property, not part of the DC encoding. + handleLockUnlockSelected: function(msg, isLock) { + if (!playerIsGM(msg.playerid)) return; + if (!msg.selected || msg.selected.length === 0) { + Chat.whisperError(msg.playerid, 'No door or window selected.'); + return; + } + + var count = 0; + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + if (sel._type !== 'door' && sel._type !== 'window') continue; + var obj = getObj(sel._type, sel._id); + if (!obj) continue; + obj.set('isLocked', isLock); + // Locking also closes it - if a door was left open when + // API-locked, later API-unlocking it causes Roll20 to + // auto-open it again, which isn't desired. + if (isLock) obj.set('isOpen', false); + count++; + } + + if (count === 0) { + Chat.whisperError(msg.playerid, 'Selection contains no doors or windows.'); + } + // Success is silent - the door/window's icon on the map already + // shows the new locked/unlocked state. + }, + + // GM-only: !lock --report-dc. Uses selection if any doors/windows + // are selected, otherwise every door/window on the GM's current + // page. Always shown in full immediately (never collapsed). + handleReportDC: function(msg) { + if (!playerIsGM(msg.playerid)) return; + + var targets = []; + if (msg.selected) { + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + if (sel._type === 'door' || sel._type === 'window') { + var obj = getObj(sel._type, sel._id); + if (obj) targets.push(obj); + } + } + } + + var pageWide = false; + if (targets.length === 0) { + pageWide = true; + var pageId = PageUtils.getPageForPlayer(msg.playerid); + targets = [] + .concat(findObjs({ _type: 'door', _pageid: pageId }) || []) + .concat(findObjs({ _type: 'window', _pageid: pageId }) || []); + } + + var results = []; + for (var j = 0; j < targets.length; j++) { + var target = targets[j]; + var type = target.get('_type'); + var typeLabel = (type === 'door') ? 'Door' : 'Window'; + var color = target.get('color'); + var status = Core.getLockStatus(type, color); + status.label = typeLabel + ' ' + target.id.slice(-4); + status.objType = type; + status.objId = target.id; + results.push(status); + } + + Chat.whisperReportDC(msg.playerid, results, pageWide); + }, + + // GM-only: !lock --ping . Focus-pull ping, visible only + // visibility - sendPing's moveAll/visibleTo behavior for this + // is unverified against a live game. + handlePing: function(msg, args) { + if (!playerIsGM(msg.playerid)) return; + var type = args[0], id = args[1]; + if (type !== 'door' && type !== 'window') return; + var obj = getObj(type, id); + if (!obj) { + Chat.whisperError(msg.playerid, 'That object no longer exists.'); + return; + } + var point = Geometry.doorPixelPosition(obj); + var pageId = obj.get('_pageid'); + sendPing(point.left, point.top, pageId, msg.playerid, true, msg.playerid); + }, + + // Debug aid for the geometry assumption. GM-only; select exactly + // one token and one door/window. + handleCoordsDebug: function(msg) { + if (!playerIsGM(msg.playerid)) return; + if (!msg.selected || msg.selected.length !== 2) { + Chat.whisperError(msg.playerid, 'Select exactly one token and one door/window, then run !lock --coords.'); + return; + } + + var tokenObj = null, doorObj = null; + for (var i = 0; i < msg.selected.length; i++) { + var sel = msg.selected[i]; + var obj = getObj(sel._type, sel._id); + if (!obj) continue; + if (sel._type === 'graphic') tokenObj = obj; + else if (sel._type === 'door' || sel._type === 'window') doorObj = obj; + } + if (!tokenObj || !doorObj) { + Chat.whisperError(msg.playerid, 'Selection must include exactly one token and one door/window.'); + return; + } + + var tokenPoint = Geometry.tokenPixelPosition(tokenObj); + var doorPoint = Geometry.doorPixelPosition(doorObj); + var dist = Geometry.distance(tokenPoint, doorPoint); + var pageId = doorObj.get('_pageid'); + var page = getObj('page', pageId); + + Chat.whisperCoordsDebug( + msg.playerid, + tokenPoint, + { rawX: doorObj.get('x'), rawY: doorObj.get('y'), left: doorPoint.left, top: doorPoint.top }, + dist, + dist / PIXELS_PER_SQUARE, + { + gridType: (page && page.get('grid_type')) || 'square', + isSquare: PageUtils.isSquareGrid(pageId), + feetPerSquare: PageUtils.getFeetPerSquare(pageId) + } + ); + }, + + // GM-only: !lock --toggle-dc-labels. Current-page scope. No state + // stored for this at all - detects existing labels live via + // TextLabels.findExisting, so there's nothing to keep in sync. + handleToggleLabels: function(msg) { + if (!playerIsGM(msg.playerid)) return; + + var pageId = PageUtils.getPageForPlayer(msg.playerid); + if (!pageId) { + Chat.whisperError(msg.playerid, 'Could not determine your current page.'); + return; + } + + var existing = TextLabels.findExisting(pageId); + if (existing.length > 0) { + TextLabels.deleteAll(pageId); + } else { + TextLabels.createAll(pageId); + } + // Success is silent - the labels appearing/disappearing on the + // map is the confirmation. + }, + + // !lock --help. Not GM-gated - the handout has sections for both + // GMs and players. Find-or-create, same pattern as Chronicle's + // help handout. + handleHelp: function(msg) { + var helpHandout = findObjs({ _type: 'handout', name: HELP_NAME })[0]; + + if (!helpHandout) { + var createProps = { name: HELP_NAME, inplayerjournals: 'all', archived: false }; + if (HELP_AVATAR) createProps.avatar = HELP_AVATAR; + helpHandout = createObj('handout', createProps); + helpHandout.set('notes', buildHelpText(helpHandout.id)); + Logger.info('Created help handout'); + } else { + helpHandout.set('notes', buildHelpText(helpHandout.id)); + if (HELP_AVATAR) helpHandout.set('avatar', HELP_AVATAR); + Logger.info('Updated help handout'); + } + + Chat.whisperHelpLink(msg.playerid, helpHandout.id); + }, + + // !lock --expand . Not GM-gated - checked against the report's + // original recipient instead, since many reports go to players. + handleExpand: function(msg, args) { + var id = args[0]; + var cached = ReportCache.get(id); + if (!cached) { + Chat.whisperError(msg.playerid, 'This report has expired or is no longer available.'); + return; + } + if (cached.isPublic) { + // Visible to everyone already - anyone can expand it. + Chat.postFull(null, cached.headerText, cached.bodyHtml, cached.footerText); + return; + } + if (cached.playerid !== msg.playerid) { + return; // private report - not this player's, silently ignore + } + Chat.whisperFull(msg.playerid, cached.headerText, cached.bodyHtml, cached.footerText); + }, + + // Only ever sent by the button Chat.whisperSuccess() generates. + handleUnlockTarget: function(msg, args) { + var type = args[0]; + var id = args[1]; + var charname = decodeFromCommand(args[2]); + + if (type !== 'door' && type !== 'window') return; + + var obj = getObj(type, id); + if (!obj) { + Chat.whisperError(msg.playerid, 'That lock no longer exists.'); + return; + } + + obj.set('isLocked', false); + + var label = (type === 'door' ? 'The door' : 'The window'); + // Success is silent - the door/window icon changing on the map + // is the confirmation. + + Logger.info(label + ' (' + id + ') unlocked by ' + charname + ' (player ' + msg.playerid + ')'); + }, + + // GM-only: clicked from an "Add" button next to a door/window + // name. Targets a token via @{target|...}, adds a key for that + // door to the target's associated character's keyring. + handleKeyAdd: function(msg, args) { + if (!playerIsGM(msg.playerid)) return; + var type = args[0], doorId = args[1], targetTokenId = args[2]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.deliverKeyMessage(msg.playerid, 'That lock no longer exists.'); + return; + } + + var targetToken = getObj('graphic', targetTokenId); + if (!targetToken) { + Chat.deliverKeyMessage(msg.playerid, 'No valid target was selected.'); + return; + } + + var characterId = targetToken.get('represents'); + if (!characterId) { + Chat.deliverKeyMessage(msg.playerid, 'That token has no associated character - key was not given.'); + return; + } + + var character = getObj('character', characterId); + var charname = character ? character.get('name') : 'that character'; + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + + var added = KeyringUtils.addKey(characterId, type, doorId); + if (added) { + Chat.deliverKeyMessage(msg.playerid, 'Gave ' + typeLabel + ' key to ' + charname + '.'); + } else { + Chat.deliverKeyMessage(msg.playerid, charname + ' already has a key for ' + typeLabel + '.'); + } + }, + + // Self-service version of Add - not GM-gated. Meant to be + // triggered from a macro pasted into a treasure horde or similar, + // using @{selected|token_id} rather than @{target|...} so it + // grants the key to whoever has their own token selected when + // they click, not a token the clicker picks afterward. + handleKeyLoot: function(msg, args) { + var type = args[0], doorId = args[1], targetTokenId = args[2]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.deliverKeyMessage(msg.playerid, 'That lock no longer exists.'); + return; + } + + var targetToken = getObj('graphic', targetTokenId); + if (!targetToken) { + Chat.deliverKeyMessage(msg.playerid, 'You must have your token selected to loot this.'); + return; + } + + var characterId = targetToken.get('represents'); + if (!characterId) { + Chat.deliverKeyMessage(msg.playerid, 'Your token has no associated character.'); + return; + } + + var character = getObj('character', characterId); + var charname = character ? character.get('name') : 'that character'; + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + + var added = KeyringUtils.addKey(characterId, type, doorId); + if (added) { + Chat.deliverKeyMessage(msg.playerid, charname + ' found a key for ' + typeLabel + '!'); + } else { + Chat.deliverKeyMessage(msg.playerid, charname + ' already has a key for ' + typeLabel + '.'); + } + }, + + // GM-only: clicked from a "+Loot" button. Whispers the raw macro + // text (not a clickable command) for the GM to copy/paste + // wherever they want - e.g. into a treasure handout's description. + handleKeyLootMacro: function(msg, args) { + if (!playerIsGM(msg.playerid)) return; + var type = args[0], doorId = args[1]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.whisperError(msg.playerid, 'That lock no longer exists.'); + return; + } + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + // Escaped @ - same bug as Add/Give, missed here originally. + // Safe for copy-paste: the browser renders/copies the decoded + // @ character regardless of whether it's inside a clickable + // link or plain text. + var macroText = '!lock --key-loot ' + type + ' ' + doorId + ' @{selected|token_id}'; + var body = 'Copy this macro and use it to give a specific key to a player:
' + + '' + macroText + ''; + + Chat.whisperFull(msg.playerid, 'Loot Macro - ' + typeLabel, body, null); + }, + + // !lock --keyring. Requires exactly one selected token - Roll20's + // own selection restrictions (players can only select tokens they + // control) are what actually enforce "only the controlling player + // or the GM" here, not any check in this script. + handleKeyring: function(msg) { + if (!msg.selected || msg.selected.length !== 1 || msg.selected[0]._type !== 'graphic') { + Chat.deliverKeyMessage(msg.playerid, 'Select exactly one token, then run !keyring.'); + return; + } + + var token = getObj('graphic', msg.selected[0]._id); + if (!token) { + Chat.deliverKeyMessage(msg.playerid, 'That token no longer exists.'); + return; + } + + var characterId = token.get('represents'); + if (!characterId) { + Chat.deliverKeyMessage(msg.playerid, 'That token has no associated character.'); + return; + } + + var character = getObj('character', characterId); + var charname = character ? character.get('name') : 'Unknown'; + + // Setup convenience, offered regardless of whether the + // character has any keys yet - a GM might want to hand this + // out before a player has found their first key. + var hasTokenAction = KeyringUtils.hasTokenAction(characterId); + var actionBtn = hasTokenAction ? '' : + 'Create "Keys." Token Action'; + + var keys = KeyringUtils.getKeys(characterId); + if (keys.length === 0) { + var emptyBody = actionBtn || null; + Chat.deliverKeyMessage(msg.playerid, charname + "'s Keyring - No keys.", emptyBody, null, '!keyring'); + return; + } + + var items = []; + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + var typeLabel = (k.type === 'door' ? 'Door' : 'Window') + ' ' + k.id.slice(-4); + var useCmd = '!lock --key-use ' + k.type + ' ' + k.id + ' ' + characterId; + var giveCmd = '!lock --key-give ' + k.type + ' ' + k.id + ' ' + characterId + + ' @{target|Give this key to whom?|token_id}'; + var dropCmd = '!lock --key-drop-confirm ' + k.type + ' ' + k.id + ' ' + characterId; + // Escaped @ - see the same note in whisperReportDC. + var buttons = 'Use ' + + 'Give ' + + 'Drop'; + // Plain text, not pingable - unlike everywhere else, a + // keyring listing shouldn't let a player locate the door. + items.push({ label: typeLabel, value: buttons }); + } + + var tryAllBtn = 'Try all Keys'; + // Create-action button, when shown, always comes last. + var body = Chat._renderRows(items) + tryAllBtn + actionBtn; + + Chat.deliverKeyMessage(msg.playerid, charname + "'s Keyring (" + keys.length + (keys.length === 1 ? ' key)' : ' keys)'), body, null, '!keyring'); + }, + + // Clicked from a keyring report's "Create \"Keys.\" Token Action" + // button. Not GM-gated - any player who can see the button + // (i.e. already passed the selection check in handleKeyring) can + // create it. The trailing period in the name is intentional - + // it's how TokenActionMaker/TokenActionBuilder flag an action as + // protected from their own auto-cleanup, not a typo. + handleKeyCreateAction: function(msg, args) { + var characterId = args[0]; + var character = getObj('character', characterId); + if (!character) { + Chat.deliverKeyMessage(msg.playerid, 'That character no longer exists.'); + return; + } + + if (KeyringUtils.hasTokenAction(characterId)) { + Chat.deliverKeyMessage(msg.playerid, 'That token action already exists.'); + return; + } + + createObj('ability', { + characterid: characterId, + name: KeyringUtils.TOKEN_ACTION_NAME, + action: '!keyring', + istokenaction: true + }); + + Chat.deliverKeyMessage(msg.playerid, 'Created the "Keys." token action for ' + character.get('name') + '.'); + }, + + // Clicked from a keyring report's "Use" button. Toggles the named + // door/window's locked state (bypassing DC/unpickable/magic + // entirely - a key doesn't care about any of that) as long as the + // key-holder's token is within pick range. Locking via key also + // closes it, same reasoning as the menu's Lock Selected - an + // API-locked door left open would auto-reopen when later + // API-unlocked. + handleKeyUse: function(msg, args) { + var type = args[0], doorId = args[1], characterId = args[2]; + + var doorObj = getObj(type, doorId); + if (!doorObj) { + Chat.deliverKeyMessage(msg.playerid, 'That lock no longer exists.'); + return; + } + + var pageId = PageUtils.getPageForPlayer(msg.playerid); + var tokens = TokenUtils.findTokensForCharacter(pageId, characterId); + if (tokens.length === 0) { + Chat.deliverKeyMessage(msg.playerid, 'No token found for that character on this page.'); + return; + } + + var doorPoint = Geometry.doorPixelPosition(doorObj); + var maxRange = Geometry.maxRangePixels(); + var inRange = false; + for (var i = 0; i < tokens.length; i++) { + var dist = Geometry.distance(Geometry.tokenPixelPosition(tokens[i]), doorPoint); + if (dist <= maxRange) { inRange = true; break; } + } + + if (!inRange) { + Chat.deliverKeyMessage(msg.playerid, 'Not close enough to use that key.'); + return; + } + + var willLock = !doorObj.get('isLocked'); + doorObj.set('isLocked', willLock); + if (willLock) doorObj.set('isOpen', false); + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + Chat.deliverKeyMessage(msg.playerid, typeLabel + (willLock ? ' locked with a key.' : ' unlocked with a key.')); + }, + + // Clicked from a keyring report's "Try all Keys" button. Toggles + // the locked state of the nearest door/window the character + // holds a key for, as long as it's within pick range - same + // range as a Thieves' Tools pick attempt, same toggle behavior + // as the individual Use button. + handleKeyTryAll: function(msg, args) { + var characterId = args[0]; + + var pageId = PageUtils.getPageForPlayer(msg.playerid); + var tokens = TokenUtils.findTokensForCharacter(pageId, characterId); + if (tokens.length === 0) { + Chat.deliverKeyMessage(msg.playerid, 'No token found for that character on this page.'); + return; + } + + var keys = KeyringUtils.getKeys(characterId); + if (keys.length === 0) { + Chat.deliverKeyMessage(msg.playerid, 'No keys to try.'); + return; + } + + var maxRange = Geometry.maxRangePixels(); + var best = null; + + for (var t = 0; t < tokens.length; t++) { + var tokenPoint = Geometry.tokenPixelPosition(tokens[t]); + for (var k = 0; k < keys.length; k++) { + var doorObj = getObj(keys[k].type, keys[k].id); + if (!doorObj) continue; + + var dist = Geometry.distance(tokenPoint, Geometry.doorPixelPosition(doorObj)); + if (dist > maxRange) continue; + + if (!best || dist < best.distance) { + best = { obj: doorObj, type: keys[k].type, distance: dist }; + } + } + } + + if (!best) { + Chat.deliverKeyMessage(msg.playerid, 'None of your keys fit a door within range.'); + return; + } + + var willLock = !best.obj.get('isLocked'); + best.obj.set('isLocked', willLock); + if (willLock) best.obj.set('isOpen', false); + var typeLabel = (best.type === 'door' ? 'Door' : 'Window') + ' ' + best.obj.id.slice(-4); + Chat.deliverKeyMessage(msg.playerid, 'Found the right key - ' + typeLabel + (willLock ? ' locked.' : ' unlocked.')); + }, + + // Clicked from a keyring report's "Give" button. Removes the key + // from the giver, targets a token via @{target|...}, adds it to + // the target's associated character. + handleKeyGive: function(msg, args) { + var type = args[0], doorId = args[1], giverCharacterId = args[2], targetTokenId = args[3]; + + var removed = KeyringUtils.removeKey(giverCharacterId, type, doorId); + if (!removed) { + Chat.deliverKeyMessage(msg.playerid, 'That key is no longer in your keyring.'); + return; + } + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + var targetToken = getObj('graphic', targetTokenId); + + if (!targetToken) { + KeyringUtils.addKey(giverCharacterId, type, doorId); // restore - give failed + Chat.deliverKeyMessage(msg.playerid, 'No valid target was selected - key was not given.'); + return; + } + + var recipientCharacterId = targetToken.get('represents'); + if (!recipientCharacterId) { + KeyringUtils.addKey(giverCharacterId, type, doorId); // restore - give failed + Chat.deliverKeyMessage(msg.playerid, 'That token has no associated character - key was not given.'); + return; + } + + KeyringUtils.addKey(recipientCharacterId, type, doorId); + var recipient = getObj('character', recipientCharacterId); + var recipientName = recipient ? recipient.get('name') : 'that character'; + var giver = getObj('character', giverCharacterId); + var giverName = giver ? giver.get('name') : 'Someone'; + Chat.deliverKeyMessage(msg.playerid, giverName + ' gave ' + typeLabel + ' key to ' + recipientName + '.'); + }, + + // Clicked from a keyring report's "Drop" button - a safety net, + // since dropping a key is instant and irreversible otherwise. + // Shows a confirmation card instead of deleting immediately; the + // actual deletion is handleKeyDrop below, triggered only by + // clicking the confirmation button. + handleKeyDropConfirm: function(msg, args) { + var type = args[0], doorId = args[1], characterId = args[2]; + + if (!KeyringUtils.hasKey(characterId, type, doorId)) { + Chat.deliverKeyMessage(msg.playerid, 'That key is no longer in your keyring.'); + return; + } + + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + var confirmCmd = '!lock --key-drop ' + type + ' ' + doorId + ' ' + characterId; + var confirmBtn = 'Yes, Drop It'; + + Chat.deliverKeyMessage(msg.playerid, 'Drop ' + typeLabel + ' key?', + 'This cannot be undone.
' + confirmBtn, null); + }, + + // The actual deletion - only ever reached via the confirmation + // button above, never directly from the keyring report itself. + handleKeyDrop: function(msg, args) { + var type = args[0], doorId = args[1], characterId = args[2]; + + var removed = KeyringUtils.removeKey(characterId, type, doorId); + var typeLabel = (type === 'door' ? 'Door' : 'Window') + ' ' + doorId.slice(-4); + + if (!removed) { + Chat.deliverKeyMessage(msg.playerid, 'That key is no longer in your keyring.'); + return; + } + + Chat.deliverKeyMessage(msg.playerid, 'Dropped ' + typeLabel + ' key.'); + }, + + // Passive. Detects a Knock cast, finds the nearest locked, + // non-unpickable door within 60ft. Returns true if handled (so + // the caller skips handleRoll). + handleKnockCast: function(msg) { + var castData = RollParser.extractKnockCast(msg); + if (!castData) return false; + + var pageId = PageUtils.getPageForPlayer(castData.playerid); + if (!pageId) { + Chat.postHeaderOnly(castData.charname, 'Could not determine the caster\'s current page.'); + return true; + } + + var characterId = castData.characterId || TokenUtils.findCharacterIdByName(castData.charname); + if (!characterId) { + Chat.postHeaderOnly(castData.charname, 'Could not identify the casting character.'); + return true; + } + + var tokens = TokenUtils.findTokensForCharacter(pageId, characterId); + if (tokens.length === 0) { + Chat.postHeaderOnly(castData.charname, 'No token found for the caster on this page.'); + return true; + } + + var best = null; + for (var i = 0; i < tokens.length; i++) { + var casterPoint = Geometry.tokenPixelPosition(tokens[i]); + var candidate = DoorFinder.findNearestKnockableLock(pageId, casterPoint); + if (candidate && (!best || candidate.distance < best.distance)) best = candidate; + } + + if (!best) { + Chat.postHeaderOnly(castData.charname, 'Knock finds no valid lock within range.'); + return true; + } + + // /fx via sendChat() fails ("Unrecognized command") - it's + // client-only, unlike /w or /em. spawnFx() has no id variant, + // so this depends on the geometry assumption - verify with + // !lock --coords. + var doorPoint = Geometry.doorPixelPosition(best.obj); + spawnFx(doorPoint.left, doorPoint.top, 'glow-magic', pageId); + + Chat.postKnockSuccess(castData.charname, best); + return true; + }, + + // Passive: fires on every non-api chat message. Finds and compares + // a Thieves' Tools roll against the nearest managed lock in range. + handleRoll: function(msg) { + var rollData = RollParser.extractRollData(msg); + if (!rollData) return; + + if (rollData.needsConfirmation) { + Chat.whisperConfirmation(rollData); + return; + } + + var pageId = PageUtils.getPageForPlayer(rollData.playerid); + if (!pageId) { + Chat.whisperError(rollData.playerid, 'Could not determine your current page.'); + return; + } + + var tokens = TokenUtils.findTokensForCharacter(pageId, rollData.characterId); + if (tokens.length === 0) { + Chat.whisperNoToken(rollData); + return; + } + + var best = null; + for (var i = 0; i < tokens.length; i++) { + var tokenPoint = Geometry.tokenPixelPosition(tokens[i]); + var candidate = DoorFinder.findNearestInScopeLock(pageId, tokenPoint); + if (candidate && (!best || candidate.distance < best.distance)) best = candidate; + } + + if (!best) { + Chat.whisperNoLock(rollData); + return; + } + + // Public, non-pull ping marking the tested door (unlike the + // GM-only focus-pull ping elsewhere). visibleTo omitted on + // purpose - passing literal 'all' made the ping invisible to + // everyone; omitting it is the documented way to show all. + var pingPoint = Geometry.doorPixelPosition(best.obj); + sendPing(pingPoint.left, pingPoint.top, pageId, rollData.playerid, false); + + // Not-locked short-circuit: applies only here (a player's pick + // attempt), never to any GM-facing command. Takes priority over + // every other check - an unlocked door's DC/unpickable/magic + // state is irrelevant. + if (!best.obj.get('isLocked')) { + Chat.whisperNotLocked(rollData); + return; + } + + var status = Core.getLockStatus(best.type, best.color); + + if (status.state === 'unset') { Chat.whisperUnset(rollData); return; } + if (status.state === 'unpickable') { Chat.whisperUnpickable(rollData); return; } + if (status.state === 'magic_only') { Chat.whisperMagicOnly(rollData); return; } + if (status.state === 'unrecognized') { Chat.whisperError(rollData.playerid, 'This lock is in an unrecognized state.'); return; } + + if (rollData.total >= status.dc) { + Chat.whisperSuccess(rollData, best, status); + } else { + Chat.whisperFailure(rollData); + } + }, + + handleInput: function(msg) { + if (msg.type === 'api') { + var trimmed = msg.content.trim(); + var args = trimmed.split(/\s+/); + var cmd = args.shift(); + + // Alias: !keyring behaves exactly like !lock --keyring - + // shorter to type for players who use it often. + if (cmd === '!keyring') { + this.handleKeyring(msg); + return; + } + + if (cmd !== '!lock') return; + + if (args.length === 0) { this.handleMenu(msg); return; } + + var flag = args.shift(); + if (flag === '--set' || flag === '--unpickable' || flag === '--knock') { + this.handleSetup(msg, [flag].concat(args)); + } else if (flag === '--lock-selected') { + this.handleLockUnlockSelected(msg, true); + } else if (flag === '--unlock-selected') { + this.handleLockUnlockSelected(msg, false); + } else if (flag === '--report-dc') { + this.handleReportDC(msg); + } else if (flag === '--ping') { + this.handlePing(msg, args); + } else if (flag === '--coords') { + this.handleCoordsDebug(msg); + } else if (flag === '--toggle-dc-labels') { + this.handleToggleLabels(msg); + } else if (flag === '--help') { + this.handleHelp(msg); + } else if (flag === '--expand') { + this.handleExpand(msg, args); + } else if (flag === '--unlock-target') { + this.handleUnlockTarget(msg, args); + } else if (flag === '--key-add') { + this.handleKeyAdd(msg, args); + } else if (flag === '--key-loot') { + this.handleKeyLoot(msg, args); + } else if (flag === '--key-loot-macro') { + this.handleKeyLootMacro(msg, args); + } else if (flag === '--keyring') { + this.handleKeyring(msg); + } else if (flag === '--key-create-action') { + this.handleKeyCreateAction(msg, args); + } else if (flag === '--key-use') { + this.handleKeyUse(msg, args); + } else if (flag === '--key-try-all') { + this.handleKeyTryAll(msg, args); + } else if (flag === '--key-give') { + this.handleKeyGive(msg, args); + } else if (flag === '--key-drop-confirm') { + this.handleKeyDropConfirm(msg, args); + } else if (flag === '--key-drop') { + this.handleKeyDrop(msg, args); + } + return; + } + if (this.handleKnockCast(msg)) return; + this.handleRoll(msg); + } + }; + + return { + VERSION: VERSION, + Core: Core, + RollParser: RollParser, + Geometry: Geometry, + DoorFinder: DoorFinder, + TextLabels: TextLabels, + TokenUtils: TokenUtils, + KeyringUtils: KeyringUtils, + PageUtils: PageUtils, + ReportCache: ReportCache, + Commands: Commands, + dumpState: function() { + log('[' + SCRIPT_NAME + '] dumpState: v' + VERSION + '. DC data lives in door/window color (never ' + + 'ages out). state.Locksmith holds only the report cache (' + + (state.Locksmith ? state.Locksmith.order.length : 0) + '/' + MAX_CACHED_REPORTS + ' entries).'); + } + }; + +})(); + +on('ready', function() { + 'use strict'; + on('chat:message', function(msg) { + Locksmith.Commands.handleInput(msg); + }); + + // One-time check, not a live monitor - a page added/changed to a + // non-square grid type later won't be caught until the next script + // reload. Range math throughout this script assumes a square grid; + // hex pages are detected but not actually supported. + var nonSquarePages = (findObjs({ _type: 'page' }) || []).filter(function(p) { + return !Locksmith.PageUtils.isSquareGrid(p.id); + }); + if (nonSquarePages.length > 0) { + log('[Locksmith] WARNING: ' + nonSquarePages.length + ' page(s) are not square-grid ' + + '(' + nonSquarePages.map(function(p) { return p.get('name') + ': ' + p.get('grid_type'); }).join(', ') + '). ' + + 'Range calculations (pick attempts, Knock, --coords) assume a square grid and will be unreliable there.'); + } + + log('[Locksmith] Locksmith v' + Locksmith.VERSION + ' loaded.'); +}); diff --git a/Locksmith/README.md b/Locksmith/README.md new file mode 100644 index 000000000..f6d5e8c85 --- /dev/null +++ b/Locksmith/README.md @@ -0,0 +1,48 @@ +# Locksmith + +Automatic lock-picking and Knock spell resolution for D&D 5e in Roll20 - works with both the legacy (5e) and Beacon (5.5e) character sheets. + +Roll20 has no native trigger for "a player clicks a locked door," so lock-picking has always meant manual GM adjudication. Locksmith closes that gap: set a difficulty on a door or window once, and from then on a player's own Thieves' Tools roll - or a Knock spell - is automatically checked against the nearest locked door/window in range. No extra command on the player's side. + +## Features + +- **Automatic pick resolution** - Thieves' Tools rolls are detected and compared against the nearest locked door/window in range, on both the legacy and Beacon sheets. Success posts an Unlock button; failure never reveals the DC. +- **Knock spell support** - detected automatically, no roll involved, and can bypass Magic-only locks that mundane picking can't. +- **No custom data storage for lock state** - the difficulty is encoded directly into the door/window's own color property, so it survives page duplication, copy/paste, and everything else Roll20 already does to that property. +- **GM setup menu** (`!lock`) - set a DC, mark a lock Unpickable or Magic-only, and toggle on-map status labels (which turn yellow if a key-holder is already on the map). +- **Keys and keyrings** - hand a key to a token directly, or generate a self-service loot macro for treasure. Each character's `!keyring` report lets them Use (toggle), Give, or Drop a key, plus a "Try all Keys" button that finds the nearest match automatically. +- **One-click token action** - `!keyring` can be added as a token action, so players never type a command. +- **In-game help** - `!lock --help` builds a full handout (GM and player sections, screenshots, jump links) right in your game. + +## Installation + +Install via Roll20's One-Click API script installer (search "Locksmith"), or copy `Locksmith.js` into a new API script in your game's API Scripts page. + +## Getting Started + +1. Select a door or window on your map. +2. Run `!lock` to open the setup menu. +3. Use the **Set** row to give it a DC, or mark it Unpickable/Magic. + +From there, players just make Thieves' Tools checks or cast Knock as normal - Locksmith handles the rest. Run `!lock --help` at any time for the full documentation, including a player-facing section you can point your table to directly. + +## Commands + +| Command | Who | What it does | +|---|---|---| +| `!lock` | GM | Opens the setup menu (select doors/windows first for per-lock options) | +| `!lock --report-dc` | GM | Lists every selected door/window's status, or the whole page if nothing is selected | +| `!lock --help` | Anyone | Creates or refreshes the help handout | +| `!keyring` (or `!lock --keyring`) | Anyone | Reports the selected token's keys - requires a token to be selected | + +Everything else - setting a DC, marking a lock Unpickable/Magic, granting or looting a key, giving/dropping a key, toggling map labels, creating the `!keyring` token action - is reached through buttons in the menu and reports above, not additional typed commands. + +## Notes + +- A door or window needs *both* a DC/Unpickable/Magic setting *and* Roll20's own locked state turned on before a pick attempt or Knock will do anything with it. +- Manually changing the color of a door/window Locksmith manages can break its ability to track that lock - use the menu to reconfigure it instead. +- All key-related output (granting, looting, keyring reports, Use/Give/Drop) is private to the GM and whoever ran the command - it's never broadcast to the table, unlike pick-attempt and Knock outcomes, which might be public, depending on sheet settings. + +## Changelog + +**1.0.0** - Debut release. diff --git a/Locksmith/script.json b/Locksmith/script.json new file mode 100644 index 000000000..49a3d17c2 --- /dev/null +++ b/Locksmith/script.json @@ -0,0 +1,17 @@ +{ + "name": "Locksmith", + "script": "Locksmith.js", + "version": "1.0.0", + "description": "**Locksmith** automates lock-picking and the Knock spell for D&D 5e games, working with both the legacy (5e) and Beacon (5.5e) character sheets.\n\nRoll20 has no native trigger for \"a player interacts with a locked door,\" so lock-picking has always meant manual GM adjudication. Locksmith closes that gap: a GM sets a difficulty (or marks a lock Unpickable or Magic-only) directly on a door or window, and from then on, a player's own Thieves' Tools roll - or a Knock spell - is automatically compared against the nearest locked door/window in range, with no extra command needed on the player's side.\n\nNo custom data storage is used for lock state - the difficulty is encoded directly into the door/window's own color property, so it survives page duplication, copy/paste, and everything else Roll20 already does to that property.\n\n**Key features**\n- Automatic resolution of Thieves' Tools rolls and the Knock spell against nearby locked doors/windows, on both the legacy and Beacon sheets\n- A GM menu (`!lock`) for setting a DC, marking a lock Unpickable or Magic-only, and toggling on-map status labels\n- A full keys and keyrings system: hand out a key to bypass a lock entirely, loot a key from treasure via a pasted macro, and a per-character `!keyring` report with Use/Give/Drop and a one-click \"try all my keys\" option\n- A one-click token action for `!keyring`, so players never need to type the command\n- A GM/player help handout (`!lock --help`), including screenshots and a table of contents\n\n**Getting started**\nRun `!lock` with a door or window selected to open the setup menu. Full documentation - GM setup and the player-facing experience - is available in-game via `!lock --help`, which creates a handout with everything covered above in more depth.\n\n**Commands**\n- `!lock` - open the setup menu (select doors/windows first for per-lock options)\n- `!lock --report-dc` - list every selected door/window's status, or the whole page if nothing is selected\n- `!lock --help` - create or refresh the help handout\n- `!keyring` (or `!lock --keyring`) - report the selected token's keys; requires a token to be selected\n\nEverything else (setting a DC, marking Unpickable/Magic, granting or looting a key, toggling map labels) is reached through buttons in the menu and reports above, not typed commands.", + "authors": "Keith Curtis", + "roll20userid": "162065", + "dependencies": [], + "patreon": "https://www.patreon.com/c/KeithCurtis", + "modifies": { + "door": "read,write", + "window": "read,write", + "handout": "read,write" + }, + "conflicts": [], + "previousversions": ["1.0.1"] +} From 3ebf16742b7516ce1c0736d8d75afd20c6b6904d Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Wed, 12 Aug 2026 16:02:48 -0700 Subject: [PATCH 2/9] Update previous version from 1.0.1 to 1.0.0 --- Locksmith/script.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Locksmith/script.json b/Locksmith/script.json index 49a3d17c2..06bc18ac9 100644 --- a/Locksmith/script.json +++ b/Locksmith/script.json @@ -13,5 +13,5 @@ "handout": "read,write" }, "conflicts": [], - "previousversions": ["1.0.1"] + "previousversions": ["1.0.0"] } From 31a3837e7e23587686539dcf3190b6ef04b1aa5f Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Wed, 19 Aug 2026 09:48:55 -0700 Subject: [PATCH 3/9] Add files via upload --- Duration/1.0.0/trackDuration.js | 896 ++++++++++++++++++++++++++++++++ Duration/README.md | 132 +++++ Duration/script.json | 13 + Duration/trackDuration.js | 896 ++++++++++++++++++++++++++++++++ 4 files changed, 1937 insertions(+) create mode 100644 Duration/1.0.0/trackDuration.js create mode 100644 Duration/README.md create mode 100644 Duration/script.json create mode 100644 Duration/trackDuration.js diff --git a/Duration/1.0.0/trackDuration.js b/Duration/1.0.0/trackDuration.js new file mode 100644 index 000000000..12f93cc23 --- /dev/null +++ b/Duration/1.0.0/trackDuration.js @@ -0,0 +1,896 @@ +/ Script: Duration +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +// Base command: !duration + +var Duration = Duration || (function () { + 'use strict'; + + // ───────────────────────────────────────────── + // CONSTANTS + // ───────────────────────────────────────────── + + const SCRIPT_NAME = 'Track Duration'; + const VERSION = '1.0.0'; + const STATE_KEY = 'Duration'; + + const ALLOWED_EMOJIS = [ + '🔹', '🔸', '💠', '♦️', // diamonds + '▫️', '◽', '▪️', '◾', // squares + '🔻', '🔺', // triangles + '⭐', '✨', // stars/sparkles + ]; + + // ───────────────────────────────────────────── + // CSS + // Central style definitions. Edit values here to restyle all reports. + // All colours are mid-range to survive both light and dark VTT modes. + // ───────────────────────────────────────────── + + const CSS = { + // Outer card wrapping every report + card: 'box-sizing:border-box;width:90%;border:1px solid #777;' + + 'border-radius:5px;padding:8px 10px;' + + 'background:#333;color:#ddd;font-size:12px;', + + // Bold header line at the top of a card + header: 'font-size:14px;font-weight:bold;color:#ffbf00;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // Warning variant of the header (cleared message) + headerWarn: 'font-size:13px;font-weight:bold;color:#f0c060;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // One token block inside the card + tokenBlock: 'margin-bottom:8px;padding:5px 6px;' + + 'background:#4a4a4a;border-radius:4px;', + + // Row holding the token image + name + nameRow: 'display:table;width:100%;margin-bottom:4px;' + + 'font-size:14px;font-weight:bold;', + + // Cell for the token image + imgCell: 'display:table-cell;vertical-align:middle;' + + 'width:39px;padding-right:4px;border:none;', + + // Token thumbnail image + tokenImg: 'width:35px;height:35px;border-radius:3px;' + + 'border:none;object-fit:cover;', + + // Cell holding the character/token name + nameCell: 'display:table-cell;vertical-align:middle;' + + 'font-weight:bold;color:#e8e8e8;font-size:14px;', + + // Individual effect line + effectLine: 'margin:2px 0 2px 40px;color:#ccc;', + + // Effect name within an effect line + effectName: 'color:#e0e0e0;font-weight:bold;', + + // Round count badge + badge: 'display:inline-block;background:#444;color:#ccc;' + + 'border-radius:3px;padding:0 4px;font-size:11px;' + + 'border:1px solid #888;', + + // Generic action button (Add, Restore) + btn: 'display:inline-block;margin-top:5px;padding:3px 9px;' + + 'background:#3c525d;color:#eee;border-radius:3px;' + + 'text-decoration:none;font-size:11px;font-weight:bold;', + + // Divider between token blocks + divider: 'border:none;border-top:1px solid #666;margin:6px 0;', + + // Expiry whisper message + expiry: 'color:#f0c060;font-size:13px;font-weight:bold;', + + // Small inline button (edit) + btnSmall: 'display:inline-block;padding:0 5px;background:#4a6070;color:#ddd;' + + 'border-radius:3px;text-decoration:none;font-size:10px;' + + 'vertical-align:middle;', + + // Header icon buttons — float right, no chrome, styled to match header text + btnHeader: 'float:right;margin-left:8px;background:none;border:none;' + + 'text-decoration:none;font-size:14px;font-weight:bold;color:#ffbf00;' + + 'vertical-align:middle;', + }; + + // ───────────────────────────────────────────── + // LOGGER + // ───────────────────────────────────────────── + + const Logger = { + _prefix: `${SCRIPT_NAME} | `, + log(msg) { log(this._prefix + msg); }, + error(msg) { log(this._prefix + 'ERROR: ' + msg); } + }; + + + + const State = { + init() { + if (!state[STATE_KEY]) { + state[STATE_KEY] = { + effects: {}, + lastTopTokenId: null, + turnOrderLength: 0 + }; + } + if (!state[STATE_KEY].effects) + state[STATE_KEY].effects = {}; + if (state[STATE_KEY].lastTopTokenId === undefined) + state[STATE_KEY].lastTopTokenId = null; + if (state[STATE_KEY].turnOrderLength === undefined) + state[STATE_KEY].turnOrderLength = 0; + }, + + getEffects() { return state[STATE_KEY].effects; }, + getTokenEntry(tokenId) { return state[STATE_KEY].effects[tokenId] || null; }, + setTokenEntry(tokenId, entry) { state[STATE_KEY].effects[tokenId] = entry; }, + removeTokenEntry(tokenId) { delete state[STATE_KEY].effects[tokenId]; }, + getLastTopTokenId() { return state[STATE_KEY].lastTopTokenId; }, + setLastTopTokenId(id) { state[STATE_KEY].lastTopTokenId = id; }, + getTurnOrderLength() { return state[STATE_KEY].turnOrderLength; }, + setTurnOrderLength(n) { state[STATE_KEY].turnOrderLength = n; }, + hasAnyEffects() { return Object.keys(state[STATE_KEY].effects).length > 0; } + }; + + // ───────────────────────────────────────────── + // TOKEN UTILITIES + // ───────────────────────────────────────────── + + const TokenUtils = { + // Parse "Kaanan|🔴10|🟢3" → { base: "Kaanan", segments: [{emoji,count},...] } + parseName(rawName) { + // Find the first space followed immediately by a known effect emoji + const firstEffect = rawName.search(/ [\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728]/u); + if (firstEffect === -1) return { base: rawName, segments: [] }; + const base = rawName.substring(0, firstEffect); + const rest = rawName.substring(firstEffect); + const segments = []; + const re = / ([\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728])(\d+)/gu; + let match; + while ((match = re.exec(rest)) !== null) { + segments.push({ emoji: match[1], count: parseInt(match[2], 10) }); + } + return { base, segments }; + }, + + // Build "Kaanan|🔴10|🟢3" from base + effect list + buildName(base, effectList) { + if (!effectList || effectList.length === 0) return base; + return base + effectList.map(e => ` ${e.emoji}${e.current}`).join(''); + }, + + applyName(token, name) { token.set('name', name); }, + getToken(tokenId) { return getObj('graphic', tokenId) || null; }, + + // Retrieve the imgsrc from a token, stripping the size suffix Roll20 appends + // so it can be used in an tag at any size. + getImgSrc(token) { + const src = token.get('imgsrc') || ''; + // Roll20 appends e.g. "/med.png" — replace with "/thumb.png" for a small fetch + return src.replace(/\/[a-z]+(\.[a-z]+)$/, '/thumb$1'); + }, + + // Returns display names of all non-GM controllers of a character + getControllerWhisperTargets(characterId) { + if (!characterId) return []; + const char = getObj('character', characterId); + if (!char) return []; + const controllers = char.get('controlledby') || ''; + return controllers + .split(',') + .map(s => s.trim()) + .filter(id => id && id !== 'all') + .reduce((acc, id) => { + if (playerIsGM(id)) return acc; + const player = getObj('player', id); + if (player) acc.push(player.get('_displayname')); + return acc; + }, []); + } + }; + + // ───────────────────────────────────────────── + // PAGE UTILITIES + // ───────────────────────────────────────────── + + const PageUtils = { + getPageForPlayer(playerid) { + const player = getObj('player', playerid); + if (!player) return Campaign().get('playerpageid'); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + const psp = Campaign().get('playerspecificpages'); + if (psp && psp[playerid]) return psp[playerid]; + return Campaign().get('playerpageid'); + }, + + tokenIsOnPlayersPage(token, playerid) { + return token.get('_pageid') === this.getPageForPlayer(playerid); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER UTILITIES + // ───────────────────────────────────────────── + + const TurnUtils = { + parse(raw) { + if (!raw || raw === '' || raw === '[]') return []; + try { return JSON.parse(raw); } catch (e) { return []; } + }, + tokenEntries(turnOrder) { return turnOrder.filter(e => e.id && e.id !== '-1'); }, + topEntry(tokenEntries) { return tokenEntries.length > 0 ? tokenEntries[0] : null; }, + indexOfToken(tokenEntries, id) { return tokenEntries.findIndex(e => e.id === id); } + }; + + // ───────────────────────────────────────────── + // HTML BUILDER + // Produces styled card fragments consumed by Chat. + // ───────────────────────────────────────────── + + const HTML = { + // Render a single token block: image + name + effect lines (with edit buttons) + // tokenId is passed when edit buttons are needed; pass null to omit them (e.g. restore panel) + tokenBlock(displayName, imgSrc, effects, footer, tokenId) { + let h = `
`; + + // Name row with thumbnail + h += `
`; + h += `
`; + h += ``; + h += `
`; + h += `
${displayName}
`; + h += `
`; + + // Effect lines + effects.forEach(e => { + h += `
`; + h += `${e.emoji} ${e.name}`; + h += ` ${e.current} rd`; + + // Edit button — only when a tokenId is supplied + if (tokenId) { + const editCmd = `!duration --edit ${tokenId} ?{New duration for ${e.name}|${e.current}} ${e.name}`; + h += ` ${HTML.btnSmall('edit', editCmd)}`; + } + + h += `
`; + }); + + // Optional footer (e.g. Add or Restore button) + if (footer) h += footer; + + h += `
`; + return h; + }, + + // Render a full card with a header (including floating icon buttons) and body content. + // showHeaderBtns: if true, renders the ?, ↻, ∅ buttons in the header. + card(headerText, body, isWarning, showHeaderBtns) { + const hStyle = isWarning ? CSS.headerWarn : CSS.header; + const btns = showHeaderBtns + ? `` + + `?` + + `` + : ''; + return `
` + + `
${btns}${headerText}
` + + body + + `
`; + }, + + // Render a button-style anchor + button(label, command) { + return `${label}`; + }, + + // Render a small inline button (used for per-effect actions) + btnSmall(label, command) { + return `${label}`; + }, + + // Render a floating header icon button + btnHeader(label, command) { + return `${label}`; + }, + + // Render a horizontal rule divider + divider() { + return `
`; + } + }; + + // ───────────────────────────────────────────── + // CHAT UTILITIES + // ───────────────────────────────────────────── + + const Chat = { + whisperGM(msg) { + sendChat(SCRIPT_NAME, `/w gm ${msg}`, null, { noarchive: true }); + }, + + whisperPlayer(displayName, msg) { + sendChat(SCRIPT_NAME, `/w "${displayName}" ${msg}`, null, { noarchive: true }); + }, + + broadcast(msg) { + sendChat(SCRIPT_NAME, msg); + }, + + // Whisper expiry-only notification to GM + character controllers + sendExpiryMessage(tokenName, effectEmoji, effectName, characterId) { + const inner = `${effectEmoji} ${effectName} has expired on ${tokenName}.`; + const msg = `
${inner}
`; + this.whisperGM(msg); + TokenUtils.getControllerWhisperTargets(characterId) + .forEach(name => this.whisperPlayer(name, msg)); + }, + + // Broadcast the main duration list, with an Add button for the current top token. + sendStatusMessage(entries, topTokenName) { + let body = ''; + + if (entries.length === 0) { + body = `
No active effects.
`; + } else { + entries.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, null, entry.tokenId); + }); + } + + // Add button targets the top token in the turn order. + // Build the ?{} dropdown from ALLOWED_EMOJIS so it stays in sync automatically. + const emojiChoices = ALLOWED_EMOJIS.map(e => `${e},${e}`).join('|'); + const addCmd = topTokenName + ? `!duration --add ?{Color|${emojiChoices}} ?{Duration (rounds)|10} ?{Effect Name}` + : null; + + const buttonLabel = topTokenName + ? `+ Add Effect to Active Character` + : null; + + if (addCmd) body += HTML.button(buttonLabel, addCmd); + + this.broadcast(HTML.card('Duration Effects', body, false, true)); + }, + + // Broadcast cleared message with per-character restore buttons. + // snapshot: [ { displayName, imgSrc, characterId, characterName, effects } ] + sendClearedMessage(snapshot) { + if (snapshot.length === 0) return; + + let body = ''; + snapshot.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + + const payload = encodeURIComponent(JSON.stringify({ + characterId: entry.characterId, + characterName: entry.characterName, + effects: entry.effects + })); + + const restoreBtn = HTML.button('Restore', `!duration --restore ${payload}`); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, restoreBtn); + }); + + this.broadcast(HTML.card('Effects Cleared', body, true, false)); + } + }; + + // ───────────────────────────────────────────── + // CORE LOGIC + // ───────────────────────────────────────────── + + const Core = { + + // Bare !duration — show active effects + Add button + showStatus() { + const allEffects = State.getEffects(); + const entries = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const active = entry.effects.filter(e => e.current > 0); + if (active.length === 0) return; + + let displayName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) displayName = char.get('name'); + } + + entries.push({ + tokenId, + displayName, + imgSrc: entry.imgSrc || '', + effects: active + }); + }); + + // Determine the name of the current top token for the Add button label + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + let topTokenName = null; + + if (topEntry) { + const topToken = TokenUtils.getToken(topEntry.id); + if (topToken) { + const rawName = topToken.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + topTokenName = base; + } + } + + Chat.sendStatusMessage(entries, topTokenName); + }, + + // --add: attach an effect to the top token in the turn order + addEffect(emoji, rounds, effectName, msg) { + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + + if (!topEntry) { + Chat.whisperGM('No token at the top of the turn order.'); + return; + } + + const tokenId = topEntry.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM('Could not find the token at the top of the turn order.'); + return; + } + + // Page guard + if (!PageUtils.tokenIsOnPlayersPage(token, msg.playerid)) { + Chat.whisperGM( + 'That token is not on your current page. ' + + 'Navigate to the correct page before adding an effect.' + ); + return; + } + + const characterId = token.get('represents') || ''; + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + const effectObj = { emoji, name: effectName, current: rounds, max: rounds }; + const existingIdx = entry.effects.findIndex(e => e.name === effectName); + if (existingIdx !== -1) { + entry.effects[existingIdx] = effectObj; + } else { + entry.effects.push(effectObj); + } + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Tick a token's effects in the given direction. + // Forward: called when the token LEAVES the top (turn just ended) → decrement. + // Backward: called when the token ARRIVES at the top (rewinding) → increment. + tickToken(tokenId, direction) { + const entry = State.getTokenEntry(tokenId); + if (!entry) return; + + const token = TokenUtils.getToken(tokenId); + if (!token) return; + + const tokenBaseName = entry.baseTokenName; + const characterId = entry.characterId; + let changed = false; + const expired = []; + + entry.effects.forEach(effect => { + const before = effect.current; + if (direction === 'forward') { + effect.current = Math.max(0, effect.current - 1); + } else { + effect.current = Math.min(effect.max, effect.current + 1); + } + if (effect.current !== before) { + changed = true; + if (effect.current === 0) expired.push({ emoji: effect.emoji, name: effect.name }); + } + }); + + // Send expiry notifications (only) + expired.forEach(({ emoji, name }) => + Chat.sendExpiryMessage(tokenBaseName, emoji, name, characterId) + ); + + // Cull expired effects + entry.effects = entry.effects.filter(e => e.current > 0); + + if (entry.effects.length === 0) { + TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else if (changed) { + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + State.setTokenEntry(tokenId, entry); + } + }, + + // --edit: set a specific effect on a specific token to a new value + editEffect(tokenId, effectName, newValue) { + const entry = State.getTokenEntry(tokenId); + if (!entry) { + Chat.whisperGM('No tracked effects found for that token.'); + return; + } + + const idx = entry.effects.findIndex(e => e.name === effectName); + if (idx === -1) { + Chat.whisperGM(`Effect ${effectName} not found on that token.`); + return; + } + + const token = TokenUtils.getToken(tokenId); + + if (newValue <= 0) { + // Remove the effect entirely + entry.effects.splice(idx, 1); + if (entry.effects.length === 0) { + if (token) TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else { + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + State.setTokenEntry(tokenId, entry); + } + } else { + // Update current; also update max if the new value exceeds it + entry.effects[idx].current = newValue; + if (newValue > entry.effects[idx].max) entry.effects[idx].max = newValue; + State.setTokenEntry(tokenId, entry); + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + } + }, + restoreEffect(payload) { + let data; + try { + data = JSON.parse(decodeURIComponent(payload)); + } catch (e) { + Chat.whisperGM('Failed to parse restore payload.'); + return; + } + + const { characterId, characterName, effects } = data; + + if (!characterId || !effects || effects.length === 0) { + Chat.whisperGM('Restore payload is missing required data.'); + return; + } + + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + + const matchingEntries = tokenEntries.filter(e => { + const t = TokenUtils.getToken(e.id); + return t && t.get('represents') === characterId; + }); + + if (matchingEntries.length === 0) { + Chat.whisperGM( + `No token for ${characterName} found on the current turn order.` + ); + return; + } + + const best = matchingEntries.reduce((a, b) => + parseFloat(b.pr) > parseFloat(a.pr) ? b : a + ); + + const tokenId = best.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM(`Token for ${characterName} could not be retrieved.`); + return; + } + + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + effects.forEach(effect => { + const idx = entry.effects.findIndex(e => e.name === effect.name); + if (idx !== -1) { + entry.effects[idx] = { ...effect }; + } else { + entry.effects.push({ ...effect }); + } + }); + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Snapshot active effects, clear state, restore token names, broadcast restore panel + clearAllEffects() { + const allEffects = State.getEffects(); + const snapshot = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const activeEffects = entry.effects.filter(e => e.current > 0); + if (activeEffects.length === 0) return; + + let characterName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) characterName = char.get('name'); + } + + // Refresh imgSrc from live token if still present + let imgSrc = entry.imgSrc || ''; + const token = TokenUtils.getToken(tokenId); + if (token) { + imgSrc = TokenUtils.getImgSrc(token); + TokenUtils.applyName(token, entry.baseTokenName); + } + + snapshot.push({ + displayName: characterName || entry.baseTokenName, + imgSrc, + characterId: entry.characterId, + characterName, + effects: activeEffects + }); + + State.removeTokenEntry(tokenId); + }); + + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + + Chat.sendClearedMessage(snapshot); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER CHANGE HANDLER + // ───────────────────────────────────────────── + + const TurnHandler = { + handle(obj, prev) { + const newRaw = obj.get('turnorder') || ''; + const prevRaw = prev['turnorder'] || ''; + + // ── Detect full clear ────────────────────────────────────────── + const newIsEmpty = (newRaw === '' || newRaw === '[]'); + const prevIsEmpty = (prevRaw === '' || prevRaw === '[]'); + + if (newIsEmpty && !prevIsEmpty) { + if (State.hasAnyEffects()) Core.clearAllEffects(); + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + return; + } + + if (newIsEmpty) return; + + // ── Parse both orders ────────────────────────────────────────── + const newTokens = TurnUtils.tokenEntries(TurnUtils.parse(newRaw)); + const prevTokens = TurnUtils.tokenEntries(TurnUtils.parse(prevRaw)); + + const newTopEntry = TurnUtils.topEntry(newTokens); + const prevTopEntry = TurnUtils.topEntry(prevTokens); + + if (!prevTopEntry) { + if (newTopEntry) State.setLastTopTokenId(newTopEntry.id); + State.setTurnOrderLength(newTokens.length); + return; + } + + const prevTopId = prevTopEntry.id; + const newTopId = newTopEntry ? newTopEntry.id : null; + + // Top didn't change — nothing to tick + if (newTopId === prevTopId) { + State.setTurnOrderLength(newTokens.length); + return; + } + + // ── Determine direction and which token to tick ──────────────── + const prevTopNewIdx = TurnUtils.indexOfToken(newTokens, prevTopId); + const newTopPrevIdx = newTopId ? TurnUtils.indexOfToken(prevTokens, newTopId) : -1; + const n = newTokens.length; + const prevN = prevTokens.length; + + // Update bookkeeping before ticking + State.setLastTopTokenId(newTopId); + State.setTurnOrderLength(n); + + if (prevTopNewIdx === n - 1) { + Core.tickToken(prevTopId, 'forward'); + } else if (newTopPrevIdx === prevN - 1) { + Core.tickToken(newTopId, 'backward'); + } + // else: unrecognised change — no tick + } + }; + + // ───────────────────────────────────────────── + // COMMAND PARSER + // ───────────────────────────────────────────── + + const Commands = { + handle(msg) { + if (msg.type !== 'api') return; + const content = msg.content.trim(); + if (!content.startsWith('!duration')) return; + + const parts = content.split(/\s+/); + + // Bare "!duration" → status report + if (parts.length === 1) { + Core.showStatus(); + return; + } + + const sub = parts[1].toLowerCase(); + + switch (sub) { + case '--add': + this.handleAdd(parts, msg); + break; + + case '--edit': + this.handleEdit(parts); + break; + + case '--restore': { + const payload = content + .substring(content.indexOf('--restore') + '--restore'.length) + .trim(); + Core.restoreEffect(payload); + break; + } + + case '--clear': + if (parts[2] && parts[2].toLowerCase() === 'yes') { + Core.clearAllEffects(); + } + break; + + case '--help': + this.showHelp(); + break; + + default: + this.showHelp(); + break; + } + }, + + handleEdit(parts) { + // !duration --edit + if (parts.length < 5) { + Chat.whisperGM('Usage: !duration --edit <tokenId> <value> <effect name>'); + return; + } + + const tokenId = parts[2].trim(); + // parts[3] is the old value baked into the button — used only as the ?{} default. + // The player's answer arrives as parts[3] after Roll20 resolves the query. + const newValue = parseInt(parts[3].trim(), 10); + const effectName = parts.slice(4).join(' ').trim(); + + if (isNaN(newValue)) { + Chat.whisperGM('New duration must be a number.'); + return; + } + + Core.editEffect(tokenId, effectName, newValue); + }, + + handleAdd(parts, msg) { + // !duration --add + if (parts.length < 5) { + Chat.whisperGM( + 'Usage: !duration --add <emoji> <rounds> <effect name>' + ); + return; + } + + const emoji = parts[2].trim(); + const roundsRaw = parts[3].trim(); + const effectName = parts.slice(4).join(' ').trim(); + + if (!ALLOWED_EMOJIS.includes(emoji)) { + Chat.whisperGM(`Invalid emoji. Choose one of: ${ALLOWED_EMOJIS.join(' ')}`); + return; + } + + const rounds = parseInt(roundsRaw, 10); + if (isNaN(rounds) || rounds <= 0) { + Chat.whisperGM('Duration must be a positive whole number.'); + return; + } + + if (!effectName) { + Chat.whisperGM('Effect name cannot be empty.'); + return; + } + + Core.addEffect(emoji, rounds, effectName, msg); + }, + + showHelp() { + const row = (icon, label, desc) => + `
` + + `
${icon} ${label}
` + + `
${desc}
` + + `
`; + + const body = + `
Header Buttons
` + + row('∅', 'Clear Effects', + 'Clears all tracked effects after confirmation. Sends a restore panel to chat so effects can be recovered on a new map.') + + row('?', 'Help', + 'Shows this help card.') + + row('↻', 'Refresh', + 'Re-issues the !duration command to show a fresh copy of the effects list.') + + `
` + + `
Effect Lines
` + + row('edit', 'Edit Duration', + 'Opens a prompt pre-filled with the current round count. Enter a new number to adjust. Enter 0 or below to remove the effect entirely.') + + `
` + + `
Add Button
` + + row('+', 'Add Effect to Active Character', + 'Adds a new timed effect to the token at the top of the turn order. You will be prompted for a colour marker, a number of rounds, and a name for the effect.') + + `
` + + `
` + + `Effects tick down when a token's turn ends (forward), and tick up when rewinding to before its turn (backward). ` + + `An expiry notice is whispered to the GM and the character's controller when a duration reaches zero.` + + `
`; + + Chat.whisperGM(HTML.card(`Track Duration v${VERSION} — Help`, body, false, false)); + } + }; + + // ───────────────────────────────────────────── + // REGISTRATION + // ───────────────────────────────────────────── + + on('ready', () => { + State.init(); + on('chat:message', msg => Commands.handle(msg)); + on('change:campaign:turnorder', (obj, prev) => TurnHandler.handle(obj, prev)); + Logger.log(`v${VERSION} ready.`); + }); + + // ───────────────────────────────────────────── + // PUBLIC API + // ───────────────────────────────────────────── + + return { + version: VERSION, + STATE_KEY, + dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) + }; + +})(); \ No newline at end of file diff --git a/Duration/README.md b/Duration/README.md new file mode 100644 index 000000000..3f46a114d --- /dev/null +++ b/Duration/README.md @@ -0,0 +1,132 @@ +# Track Duration + +A Roll20 API script that keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances. + +--- + +## Overview + +When a character is affected by a spell, ability, or condition with a round-based duration, Track Duration lets you attach that effect to their token with a color marker, a name, and a round count. The count is appended directly to the token's name, visible on the board to everyone,and ticks down automatically each time that token's turn ends. + +When the count hits zero, the effect is removed and an expiry notice is whispered to the GM and the character's controller. + + +--- + +## Quick Start + +1. Set up your turn order as normal before or during combat. The tracker must have tokens in the turn order for the script to work. +2. Type `!duration` in chat to open the effects report card. +3. When a character gains a timed effect, ensure their token is at the top of the turn order, then click **+ Add Effect to Active Character**. +4. Choose a color marker, enter the number of rounds, and name the effect. +5. Advance the turn order normally using Roll20's forward button — durations count down automatically. + +--- + +## The Report Card + +Typing `!duration` broadcasts a styled card showing all tokens with active effects. Each entry displays: + +- The token's thumbnail image and character name +- Each active effect with its color marker, name, and remaining round count +- An **edit** button next to each effect + +The card header includes three icon buttons: + +| Button | Action | +|--------|--------| +| ∅ | Clear all effects (with confirmation prompt) | +| ? | Show the help card | +| ↻ | Refresh the report card | + +--- + +## Adding Effects + +Click **+ Add Effect to Active Character** at the bottom of the report card, or type: + +``` +!duration --add +``` + +You will be prompted for: +- **Color marker** — chosen from a dropdown of shape/colour emoji +- **Duration** — number of rounds (defaults to 10) +- **Effect name** — any text, including spaces (e.g. *Mage Armor*, *Concentration*) + +The effect is added to whichever token is currently **at the top of the turn order**. Multiple effects can be stacked on a single token. + +--- + +## Editing and Removing Effects + +Click the **edit** button next to any effect in the report card. A prompt appears pre-filled with the current round count. + +- Enter a new number to adjust the duration. +- Enter **0 or below** to remove the effect entirely. + +--- + +## How Ticking Works + +Track Duration responds to Roll20's turn order buttons: + +- **Forward button** — when a token's turn ends and it moves to the bottom of the order, all its effects decrement by 1. +- **Backward button** — when the turn order is rewound and a token moves back to the top, all its effects increment by 1 (up to their original maximum). + +This ensures that advancing and then immediately rewinding a turn is a net-zero operation — you cannot accidentally drain a duration by bouncing the turn order. + +Ticking is **not** triggered by manual reordering of the turn order list. + +--- + +## Expiry + +When an effect reaches zero: + +- It is removed from the token's name automatically. +- An expiry notice is whispered to the GM and to any player who controls that character. + +--- + +## Map Changes and the Restore Panel + +When the turn order is cleared — either by the GM manually or by using the ∅ button — Track Duration: + +1. Strips all effect markers from tracked token names. +2. Broadcasts a **restore panel** to all players, listing every effect that was active at the time of clearing, along with a **Restore** button per entry. + +To recover effects after moving to a new map: + +1. Set up the new turn order (the character's new token must be on the turn order). +2. Click **Restore** next to the character's entry in the restore panel. + +Track Duration will find the highest-initiative token for that character on the current turn order and re-attach all their effects. + +--- + +## Commands Reference + +The script is controlled through a Chat Menu, but here is a lost of the commands sent, in case anyone needs them for macro-building purposes. + +| Command | Description | +|---------|-------------| +| `!duration` | Show the active effects report card | +| `!duration --add ` | Add an effect to the top token | +| `!duration --edit ` | Set an effect to a new value (used by the edit button) | +| `!duration --clear yes` | Clear all effects and broadcast the restore panel | +| `!duration --help` | Show the in-chat help card | + +--- + +## Notes and Limitations + +- Only tokens present in the **turn order** are tracked. Tokens on the board but not in the turn order are unaffected. +- The script is designed for use with Roll20's standard forward/backward turn order buttons. It does not respond to manual drag-and-drop reordering. +- The restore workflow expects the character to be on the **new map's turn order** before Restore is clicked. + +--- + +## Author + +Keith Curtis — [Patreon](https://www.patreon.com/c/KeithCurtis) diff --git a/Duration/script.json b/Duration/script.json new file mode 100644 index 000000000..dbe9efa23 --- /dev/null +++ b/Duration/script.json @@ -0,0 +1,13 @@ +{ + "name": "trackDuration", + "script": "Duration.js", + "version": "1.0.0", + "description": "# trackDuration\n\trackDuration keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances — and counting them back up if you rewind.\n\n## What it does\n\n- **Attach effects to tokens** with a colour marker, a name, and a round count. The count is appended directly to the token's name (e.g. `Kaanan 🔹10`) so it is always visible on the board.\n- **Counts down automatically** when a token's turn ends (the GM presses the forward button). Counts back up when a turn is rewound (backward button).\n- **Expiry notices** are whispered to the GM and the character's controller when a duration reaches zero. The effect is removed from the token name automatically.\n- **Interactive report card** — type `!duration` to see all active effects with token thumbnails, round counts, and inline edit buttons. The card header includes one-click buttons to refresh, clear, or open help.\n- **Edit any duration on the fly** — the edit button next to each effect opens a pre-filled prompt. Set it to 0 or below to remove the effect immediately.\n- **Map-change recovery** — clearing the turn order (or using the ∅ button) broadcasts a restore panel to all players. Restore buttons re-attach effects to the matching character's token on the new turn order.\n\n## Getting started\n\n1. Install the script via One-Click or paste `Duration.js` into your campaign's API console.\n2. Build up your turn order as normal.\n3. When a character gains a timed effect, make sure their token is at the top of the turn order, then click **+ Add Effect to Active Character** in the `!duration` card (or call `!duration --add` directly).\n4. Choose a colour marker from the dropdown, enter the duration in rounds, and name the effect.\n5. Advance the turn order normally — durations count themselves down.\n\n## Commands\n\n- `!duration` — show the active effects report card.\n- `!duration --add ` — add a timed effect to the top token.\n- `!duration --edit ` — set a specific effect to a new value (generated by the edit button; not typically typed by hand).\n- `!duration --clear yes` — clear all effects and broadcast the restore panel.\n- `!duration --help` — show the in-chat help card.\n\n## Notes\n\n- Only tokens present in the turn order are tracked.\n- Ticking is driven by the GM's forward/backward turn order buttons. Manual reordering does not trigger a tick.\n- The restore workflow expects the character to already be on the new map's turn order before clicking Restore.", + "authors": "Keith Curtis", + "roll20userid": "162065", + "dependencies": [], + "patreon": "https://www.patreon.com/c/KeithCurtis", + "modifies": {}, + "conflicts": [], + "previousversions": [] +} diff --git a/Duration/trackDuration.js b/Duration/trackDuration.js new file mode 100644 index 000000000..a5eefa058 --- /dev/null +++ b/Duration/trackDuration.js @@ -0,0 +1,896 @@ +/ Script: trackDuration +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +// Base command: !duration + +var Duration = Duration || (function () { + 'use strict'; + + // ───────────────────────────────────────────── + // CONSTANTS + // ───────────────────────────────────────────── + + const SCRIPT_NAME = 'Track Duration'; + const VERSION = '1.0.0'; + const STATE_KEY = 'Duration'; + + const ALLOWED_EMOJIS = [ + '🔹', '🔸', '💠', '♦️', // diamonds + '▫️', '◽', '▪️', '◾', // squares + '🔻', '🔺', // triangles + '⭐', '✨', // stars/sparkles + ]; + + // ───────────────────────────────────────────── + // CSS + // Central style definitions. Edit values here to restyle all reports. + // All colours are mid-range to survive both light and dark VTT modes. + // ───────────────────────────────────────────── + + const CSS = { + // Outer card wrapping every report + card: 'box-sizing:border-box;width:90%;border:1px solid #777;' + + 'border-radius:5px;padding:8px 10px;' + + 'background:#333;color:#ddd;font-size:12px;', + + // Bold header line at the top of a card + header: 'font-size:14px;font-weight:bold;color:#ffbf00;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // Warning variant of the header (cleared message) + headerWarn: 'font-size:13px;font-weight:bold;color:#f0c060;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // One token block inside the card + tokenBlock: 'margin-bottom:8px;padding:5px 6px;' + + 'background:#4a4a4a;border-radius:4px;', + + // Row holding the token image + name + nameRow: 'display:table;width:100%;margin-bottom:4px;' + + 'font-size:14px;font-weight:bold;', + + // Cell for the token image + imgCell: 'display:table-cell;vertical-align:middle;' + + 'width:39px;padding-right:4px;border:none;', + + // Token thumbnail image + tokenImg: 'width:35px;height:35px;border-radius:3px;' + + 'border:none;object-fit:cover;', + + // Cell holding the character/token name + nameCell: 'display:table-cell;vertical-align:middle;' + + 'font-weight:bold;color:#e8e8e8;font-size:14px;', + + // Individual effect line + effectLine: 'margin:2px 0 2px 40px;color:#ccc;', + + // Effect name within an effect line + effectName: 'color:#e0e0e0;font-weight:bold;', + + // Round count badge + badge: 'display:inline-block;background:#444;color:#ccc;' + + 'border-radius:3px;padding:0 4px;font-size:11px;' + + 'border:1px solid #888;', + + // Generic action button (Add, Restore) + btn: 'display:inline-block;margin-top:5px;padding:3px 9px;' + + 'background:#3c525d;color:#eee;border-radius:3px;' + + 'text-decoration:none;font-size:11px;font-weight:bold;', + + // Divider between token blocks + divider: 'border:none;border-top:1px solid #666;margin:6px 0;', + + // Expiry whisper message + expiry: 'color:#f0c060;font-size:13px;font-weight:bold;', + + // Small inline button (edit) + btnSmall: 'display:inline-block;padding:0 5px;background:#4a6070;color:#ddd;' + + 'border-radius:3px;text-decoration:none;font-size:10px;' + + 'vertical-align:middle;', + + // Header icon buttons — float right, no chrome, styled to match header text + btnHeader: 'float:right;margin-left:8px;background:none;border:none;' + + 'text-decoration:none;font-size:14px;font-weight:bold;color:#ffbf00;' + + 'vertical-align:middle;', + }; + + // ───────────────────────────────────────────── + // LOGGER + // ───────────────────────────────────────────── + + const Logger = { + _prefix: `${SCRIPT_NAME} | `, + log(msg) { log(this._prefix + msg); }, + error(msg) { log(this._prefix + 'ERROR: ' + msg); } + }; + + + + const State = { + init() { + if (!state[STATE_KEY]) { + state[STATE_KEY] = { + effects: {}, + lastTopTokenId: null, + turnOrderLength: 0 + }; + } + if (!state[STATE_KEY].effects) + state[STATE_KEY].effects = {}; + if (state[STATE_KEY].lastTopTokenId === undefined) + state[STATE_KEY].lastTopTokenId = null; + if (state[STATE_KEY].turnOrderLength === undefined) + state[STATE_KEY].turnOrderLength = 0; + }, + + getEffects() { return state[STATE_KEY].effects; }, + getTokenEntry(tokenId) { return state[STATE_KEY].effects[tokenId] || null; }, + setTokenEntry(tokenId, entry) { state[STATE_KEY].effects[tokenId] = entry; }, + removeTokenEntry(tokenId) { delete state[STATE_KEY].effects[tokenId]; }, + getLastTopTokenId() { return state[STATE_KEY].lastTopTokenId; }, + setLastTopTokenId(id) { state[STATE_KEY].lastTopTokenId = id; }, + getTurnOrderLength() { return state[STATE_KEY].turnOrderLength; }, + setTurnOrderLength(n) { state[STATE_KEY].turnOrderLength = n; }, + hasAnyEffects() { return Object.keys(state[STATE_KEY].effects).length > 0; } + }; + + // ───────────────────────────────────────────── + // TOKEN UTILITIES + // ───────────────────────────────────────────── + + const TokenUtils = { + // Parse "Kaanan|🔴10|🟢3" → { base: "Kaanan", segments: [{emoji,count},...] } + parseName(rawName) { + // Find the first space followed immediately by a known effect emoji + const firstEffect = rawName.search(/ [\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728]/u); + if (firstEffect === -1) return { base: rawName, segments: [] }; + const base = rawName.substring(0, firstEffect); + const rest = rawName.substring(firstEffect); + const segments = []; + const re = / ([\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728])(\d+)/gu; + let match; + while ((match = re.exec(rest)) !== null) { + segments.push({ emoji: match[1], count: parseInt(match[2], 10) }); + } + return { base, segments }; + }, + + // Build "Kaanan|🔴10|🟢3" from base + effect list + buildName(base, effectList) { + if (!effectList || effectList.length === 0) return base; + return base + effectList.map(e => ` ${e.emoji}${e.current}`).join(''); + }, + + applyName(token, name) { token.set('name', name); }, + getToken(tokenId) { return getObj('graphic', tokenId) || null; }, + + // Retrieve the imgsrc from a token, stripping the size suffix Roll20 appends + // so it can be used in an tag at any size. + getImgSrc(token) { + const src = token.get('imgsrc') || ''; + // Roll20 appends e.g. "/med.png" — replace with "/thumb.png" for a small fetch + return src.replace(/\/[a-z]+(\.[a-z]+)$/, '/thumb$1'); + }, + + // Returns display names of all non-GM controllers of a character + getControllerWhisperTargets(characterId) { + if (!characterId) return []; + const char = getObj('character', characterId); + if (!char) return []; + const controllers = char.get('controlledby') || ''; + return controllers + .split(',') + .map(s => s.trim()) + .filter(id => id && id !== 'all') + .reduce((acc, id) => { + if (playerIsGM(id)) return acc; + const player = getObj('player', id); + if (player) acc.push(player.get('_displayname')); + return acc; + }, []); + } + }; + + // ───────────────────────────────────────────── + // PAGE UTILITIES + // ───────────────────────────────────────────── + + const PageUtils = { + getPageForPlayer(playerid) { + const player = getObj('player', playerid); + if (!player) return Campaign().get('playerpageid'); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + const psp = Campaign().get('playerspecificpages'); + if (psp && psp[playerid]) return psp[playerid]; + return Campaign().get('playerpageid'); + }, + + tokenIsOnPlayersPage(token, playerid) { + return token.get('_pageid') === this.getPageForPlayer(playerid); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER UTILITIES + // ───────────────────────────────────────────── + + const TurnUtils = { + parse(raw) { + if (!raw || raw === '' || raw === '[]') return []; + try { return JSON.parse(raw); } catch (e) { return []; } + }, + tokenEntries(turnOrder) { return turnOrder.filter(e => e.id && e.id !== '-1'); }, + topEntry(tokenEntries) { return tokenEntries.length > 0 ? tokenEntries[0] : null; }, + indexOfToken(tokenEntries, id) { return tokenEntries.findIndex(e => e.id === id); } + }; + + // ───────────────────────────────────────────── + // HTML BUILDER + // Produces styled card fragments consumed by Chat. + // ───────────────────────────────────────────── + + const HTML = { + // Render a single token block: image + name + effect lines (with edit buttons) + // tokenId is passed when edit buttons are needed; pass null to omit them (e.g. restore panel) + tokenBlock(displayName, imgSrc, effects, footer, tokenId) { + let h = `
`; + + // Name row with thumbnail + h += `
`; + h += `
`; + h += ``; + h += `
`; + h += `
${displayName}
`; + h += `
`; + + // Effect lines + effects.forEach(e => { + h += `
`; + h += `${e.emoji} ${e.name}`; + h += ` ${e.current} rd`; + + // Edit button — only when a tokenId is supplied + if (tokenId) { + const editCmd = `!duration --edit ${tokenId} ?{New duration for ${e.name}|${e.current}} ${e.name}`; + h += ` ${HTML.btnSmall('edit', editCmd)}`; + } + + h += `
`; + }); + + // Optional footer (e.g. Add or Restore button) + if (footer) h += footer; + + h += `
`; + return h; + }, + + // Render a full card with a header (including floating icon buttons) and body content. + // showHeaderBtns: if true, renders the ?, ↻, ∅ buttons in the header. + card(headerText, body, isWarning, showHeaderBtns) { + const hStyle = isWarning ? CSS.headerWarn : CSS.header; + const btns = showHeaderBtns + ? `` + + `?` + + `` + : ''; + return `
` + + `
${btns}${headerText}
` + + body + + `
`; + }, + + // Render a button-style anchor + button(label, command) { + return `${label}`; + }, + + // Render a small inline button (used for per-effect actions) + btnSmall(label, command) { + return `${label}`; + }, + + // Render a floating header icon button + btnHeader(label, command) { + return `${label}`; + }, + + // Render a horizontal rule divider + divider() { + return `
`; + } + }; + + // ───────────────────────────────────────────── + // CHAT UTILITIES + // ───────────────────────────────────────────── + + const Chat = { + whisperGM(msg) { + sendChat(SCRIPT_NAME, `/w gm ${msg}`, null, { noarchive: true }); + }, + + whisperPlayer(displayName, msg) { + sendChat(SCRIPT_NAME, `/w "${displayName}" ${msg}`, null, { noarchive: true }); + }, + + broadcast(msg) { + sendChat(SCRIPT_NAME, msg); + }, + + // Whisper expiry-only notification to GM + character controllers + sendExpiryMessage(tokenName, effectEmoji, effectName, characterId) { + const inner = `${effectEmoji} ${effectName} has expired on ${tokenName}.`; + const msg = `
${inner}
`; + this.whisperGM(msg); + TokenUtils.getControllerWhisperTargets(characterId) + .forEach(name => this.whisperPlayer(name, msg)); + }, + + // Broadcast the main duration list, with an Add button for the current top token. + sendStatusMessage(entries, topTokenName) { + let body = ''; + + if (entries.length === 0) { + body = `
No active effects.
`; + } else { + entries.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, null, entry.tokenId); + }); + } + + // Add button targets the top token in the turn order. + // Build the ?{} dropdown from ALLOWED_EMOJIS so it stays in sync automatically. + const emojiChoices = ALLOWED_EMOJIS.map(e => `${e},${e}`).join('|'); + const addCmd = topTokenName + ? `!duration --add ?{Color|${emojiChoices}} ?{Duration (rounds)|10} ?{Effect Name}` + : null; + + const buttonLabel = topTokenName + ? `+ Add Effect to Active Character` + : null; + + if (addCmd) body += HTML.button(buttonLabel, addCmd); + + this.broadcast(HTML.card('Duration Effects', body, false, true)); + }, + + // Broadcast cleared message with per-character restore buttons. + // snapshot: [ { displayName, imgSrc, characterId, characterName, effects } ] + sendClearedMessage(snapshot) { + if (snapshot.length === 0) return; + + let body = ''; + snapshot.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + + const payload = encodeURIComponent(JSON.stringify({ + characterId: entry.characterId, + characterName: entry.characterName, + effects: entry.effects + })); + + const restoreBtn = HTML.button('Restore', `!duration --restore ${payload}`); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, restoreBtn); + }); + + this.broadcast(HTML.card('Effects Cleared', body, true, false)); + } + }; + + // ───────────────────────────────────────────── + // CORE LOGIC + // ───────────────────────────────────────────── + + const Core = { + + // Bare !duration — show active effects + Add button + showStatus() { + const allEffects = State.getEffects(); + const entries = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const active = entry.effects.filter(e => e.current > 0); + if (active.length === 0) return; + + let displayName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) displayName = char.get('name'); + } + + entries.push({ + tokenId, + displayName, + imgSrc: entry.imgSrc || '', + effects: active + }); + }); + + // Determine the name of the current top token for the Add button label + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + let topTokenName = null; + + if (topEntry) { + const topToken = TokenUtils.getToken(topEntry.id); + if (topToken) { + const rawName = topToken.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + topTokenName = base; + } + } + + Chat.sendStatusMessage(entries, topTokenName); + }, + + // --add: attach an effect to the top token in the turn order + addEffect(emoji, rounds, effectName, msg) { + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + + if (!topEntry) { + Chat.whisperGM('No token at the top of the turn order.'); + return; + } + + const tokenId = topEntry.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM('Could not find the token at the top of the turn order.'); + return; + } + + // Page guard + if (!PageUtils.tokenIsOnPlayersPage(token, msg.playerid)) { + Chat.whisperGM( + 'That token is not on your current page. ' + + 'Navigate to the correct page before adding an effect.' + ); + return; + } + + const characterId = token.get('represents') || ''; + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + const effectObj = { emoji, name: effectName, current: rounds, max: rounds }; + const existingIdx = entry.effects.findIndex(e => e.name === effectName); + if (existingIdx !== -1) { + entry.effects[existingIdx] = effectObj; + } else { + entry.effects.push(effectObj); + } + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Tick a token's effects in the given direction. + // Forward: called when the token LEAVES the top (turn just ended) → decrement. + // Backward: called when the token ARRIVES at the top (rewinding) → increment. + tickToken(tokenId, direction) { + const entry = State.getTokenEntry(tokenId); + if (!entry) return; + + const token = TokenUtils.getToken(tokenId); + if (!token) return; + + const tokenBaseName = entry.baseTokenName; + const characterId = entry.characterId; + let changed = false; + const expired = []; + + entry.effects.forEach(effect => { + const before = effect.current; + if (direction === 'forward') { + effect.current = Math.max(0, effect.current - 1); + } else { + effect.current = Math.min(effect.max, effect.current + 1); + } + if (effect.current !== before) { + changed = true; + if (effect.current === 0) expired.push({ emoji: effect.emoji, name: effect.name }); + } + }); + + // Send expiry notifications (only) + expired.forEach(({ emoji, name }) => + Chat.sendExpiryMessage(tokenBaseName, emoji, name, characterId) + ); + + // Cull expired effects + entry.effects = entry.effects.filter(e => e.current > 0); + + if (entry.effects.length === 0) { + TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else if (changed) { + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + State.setTokenEntry(tokenId, entry); + } + }, + + // --edit: set a specific effect on a specific token to a new value + editEffect(tokenId, effectName, newValue) { + const entry = State.getTokenEntry(tokenId); + if (!entry) { + Chat.whisperGM('No tracked effects found for that token.'); + return; + } + + const idx = entry.effects.findIndex(e => e.name === effectName); + if (idx === -1) { + Chat.whisperGM(`Effect ${effectName} not found on that token.`); + return; + } + + const token = TokenUtils.getToken(tokenId); + + if (newValue <= 0) { + // Remove the effect entirely + entry.effects.splice(idx, 1); + if (entry.effects.length === 0) { + if (token) TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else { + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + State.setTokenEntry(tokenId, entry); + } + } else { + // Update current; also update max if the new value exceeds it + entry.effects[idx].current = newValue; + if (newValue > entry.effects[idx].max) entry.effects[idx].max = newValue; + State.setTokenEntry(tokenId, entry); + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + } + }, + restoreEffect(payload) { + let data; + try { + data = JSON.parse(decodeURIComponent(payload)); + } catch (e) { + Chat.whisperGM('Failed to parse restore payload.'); + return; + } + + const { characterId, characterName, effects } = data; + + if (!characterId || !effects || effects.length === 0) { + Chat.whisperGM('Restore payload is missing required data.'); + return; + } + + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + + const matchingEntries = tokenEntries.filter(e => { + const t = TokenUtils.getToken(e.id); + return t && t.get('represents') === characterId; + }); + + if (matchingEntries.length === 0) { + Chat.whisperGM( + `No token for ${characterName} found on the current turn order.` + ); + return; + } + + const best = matchingEntries.reduce((a, b) => + parseFloat(b.pr) > parseFloat(a.pr) ? b : a + ); + + const tokenId = best.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM(`Token for ${characterName} could not be retrieved.`); + return; + } + + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + effects.forEach(effect => { + const idx = entry.effects.findIndex(e => e.name === effect.name); + if (idx !== -1) { + entry.effects[idx] = { ...effect }; + } else { + entry.effects.push({ ...effect }); + } + }); + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Snapshot active effects, clear state, restore token names, broadcast restore panel + clearAllEffects() { + const allEffects = State.getEffects(); + const snapshot = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const activeEffects = entry.effects.filter(e => e.current > 0); + if (activeEffects.length === 0) return; + + let characterName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) characterName = char.get('name'); + } + + // Refresh imgSrc from live token if still present + let imgSrc = entry.imgSrc || ''; + const token = TokenUtils.getToken(tokenId); + if (token) { + imgSrc = TokenUtils.getImgSrc(token); + TokenUtils.applyName(token, entry.baseTokenName); + } + + snapshot.push({ + displayName: characterName || entry.baseTokenName, + imgSrc, + characterId: entry.characterId, + characterName, + effects: activeEffects + }); + + State.removeTokenEntry(tokenId); + }); + + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + + Chat.sendClearedMessage(snapshot); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER CHANGE HANDLER + // ───────────────────────────────────────────── + + const TurnHandler = { + handle(obj, prev) { + const newRaw = obj.get('turnorder') || ''; + const prevRaw = prev['turnorder'] || ''; + + // ── Detect full clear ────────────────────────────────────────── + const newIsEmpty = (newRaw === '' || newRaw === '[]'); + const prevIsEmpty = (prevRaw === '' || prevRaw === '[]'); + + if (newIsEmpty && !prevIsEmpty) { + if (State.hasAnyEffects()) Core.clearAllEffects(); + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + return; + } + + if (newIsEmpty) return; + + // ── Parse both orders ────────────────────────────────────────── + const newTokens = TurnUtils.tokenEntries(TurnUtils.parse(newRaw)); + const prevTokens = TurnUtils.tokenEntries(TurnUtils.parse(prevRaw)); + + const newTopEntry = TurnUtils.topEntry(newTokens); + const prevTopEntry = TurnUtils.topEntry(prevTokens); + + if (!prevTopEntry) { + if (newTopEntry) State.setLastTopTokenId(newTopEntry.id); + State.setTurnOrderLength(newTokens.length); + return; + } + + const prevTopId = prevTopEntry.id; + const newTopId = newTopEntry ? newTopEntry.id : null; + + // Top didn't change — nothing to tick + if (newTopId === prevTopId) { + State.setTurnOrderLength(newTokens.length); + return; + } + + // ── Determine direction and which token to tick ──────────────── + const prevTopNewIdx = TurnUtils.indexOfToken(newTokens, prevTopId); + const newTopPrevIdx = newTopId ? TurnUtils.indexOfToken(prevTokens, newTopId) : -1; + const n = newTokens.length; + const prevN = prevTokens.length; + + // Update bookkeeping before ticking + State.setLastTopTokenId(newTopId); + State.setTurnOrderLength(n); + + if (prevTopNewIdx === n - 1) { + Core.tickToken(prevTopId, 'forward'); + } else if (newTopPrevIdx === prevN - 1) { + Core.tickToken(newTopId, 'backward'); + } + // else: unrecognised change — no tick + } + }; + + // ───────────────────────────────────────────── + // COMMAND PARSER + // ───────────────────────────────────────────── + + const Commands = { + handle(msg) { + if (msg.type !== 'api') return; + const content = msg.content.trim(); + if (!content.startsWith('!duration')) return; + + const parts = content.split(/\s+/); + + // Bare "!duration" → status report + if (parts.length === 1) { + Core.showStatus(); + return; + } + + const sub = parts[1].toLowerCase(); + + switch (sub) { + case '--add': + this.handleAdd(parts, msg); + break; + + case '--edit': + this.handleEdit(parts); + break; + + case '--restore': { + const payload = content + .substring(content.indexOf('--restore') + '--restore'.length) + .trim(); + Core.restoreEffect(payload); + break; + } + + case '--clear': + if (parts[2] && parts[2].toLowerCase() === 'yes') { + Core.clearAllEffects(); + } + break; + + case '--help': + this.showHelp(); + break; + + default: + this.showHelp(); + break; + } + }, + + handleEdit(parts) { + // !duration --edit + if (parts.length < 5) { + Chat.whisperGM('Usage: !duration --edit <tokenId> <value> <effect name>'); + return; + } + + const tokenId = parts[2].trim(); + // parts[3] is the old value baked into the button — used only as the ?{} default. + // The player's answer arrives as parts[3] after Roll20 resolves the query. + const newValue = parseInt(parts[3].trim(), 10); + const effectName = parts.slice(4).join(' ').trim(); + + if (isNaN(newValue)) { + Chat.whisperGM('New duration must be a number.'); + return; + } + + Core.editEffect(tokenId, effectName, newValue); + }, + + handleAdd(parts, msg) { + // !duration --add + if (parts.length < 5) { + Chat.whisperGM( + 'Usage: !duration --add <emoji> <rounds> <effect name>' + ); + return; + } + + const emoji = parts[2].trim(); + const roundsRaw = parts[3].trim(); + const effectName = parts.slice(4).join(' ').trim(); + + if (!ALLOWED_EMOJIS.includes(emoji)) { + Chat.whisperGM(`Invalid emoji. Choose one of: ${ALLOWED_EMOJIS.join(' ')}`); + return; + } + + const rounds = parseInt(roundsRaw, 10); + if (isNaN(rounds) || rounds <= 0) { + Chat.whisperGM('Duration must be a positive whole number.'); + return; + } + + if (!effectName) { + Chat.whisperGM('Effect name cannot be empty.'); + return; + } + + Core.addEffect(emoji, rounds, effectName, msg); + }, + + showHelp() { + const row = (icon, label, desc) => + `
` + + `
${icon} ${label}
` + + `
${desc}
` + + `
`; + + const body = + `
Header Buttons
` + + row('∅', 'Clear Effects', + 'Clears all tracked effects after confirmation. Sends a restore panel to chat so effects can be recovered on a new map.') + + row('?', 'Help', + 'Shows this help card.') + + row('↻', 'Refresh', + 'Re-issues the !duration command to show a fresh copy of the effects list.') + + `
` + + `
Effect Lines
` + + row('edit', 'Edit Duration', + 'Opens a prompt pre-filled with the current round count. Enter a new number to adjust. Enter 0 or below to remove the effect entirely.') + + `
` + + `
Add Button
` + + row('+', 'Add Effect to Active Character', + 'Adds a new timed effect to the token at the top of the turn order. You will be prompted for a colour marker, a number of rounds, and a name for the effect.') + + `
` + + `
` + + `Effects tick down when a token's turn ends (forward), and tick up when rewinding to before its turn (backward). ` + + `An expiry notice is whispered to the GM and the character's controller when a duration reaches zero.` + + `
`; + + Chat.whisperGM(HTML.card(`Track Duration v${VERSION} — Help`, body, false, false)); + } + }; + + // ───────────────────────────────────────────── + // REGISTRATION + // ───────────────────────────────────────────── + + on('ready', () => { + State.init(); + on('chat:message', msg => Commands.handle(msg)); + on('change:campaign:turnorder', (obj, prev) => TurnHandler.handle(obj, prev)); + Logger.log(`v${VERSION} ready.`); + }); + + // ───────────────────────────────────────────── + // PUBLIC API + // ───────────────────────────────────────────── + + return { + version: VERSION, + STATE_KEY, + dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) + }; + +})(); \ No newline at end of file From e1e3b92ed83bc02a0df466358f5fdc9fdf2bc19e Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Thu, 20 Aug 2026 10:52:47 -0700 Subject: [PATCH 4/9] Fix script name formatting in trackDuration.js --- Duration/trackDuration.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Duration/trackDuration.js b/Duration/trackDuration.js index a5eefa058..0a5c49d32 100644 --- a/Duration/trackDuration.js +++ b/Duration/trackDuration.js @@ -1,4 +1,4 @@ -/ Script: trackDuration +// Script: TrackDuration // By: Keith Curtis // Contact: https://app.roll20.net/users/162065/keithcurtis // Base command: !duration @@ -10,7 +10,7 @@ var Duration = Duration || (function () { // CONSTANTS // ───────────────────────────────────────────── - const SCRIPT_NAME = 'Track Duration'; + const SCRIPT_NAME = 'TrackDuration'; const VERSION = '1.0.0'; const STATE_KEY = 'Duration'; @@ -893,4 +893,4 @@ var Duration = Duration || (function () { dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) }; -})(); \ No newline at end of file +})(); From 773c6d2eaa32913341c9fb543555a410a9f2b202 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Thu, 20 Aug 2026 10:53:06 -0700 Subject: [PATCH 5/9] Rename script from 'Track Duration' to 'TrackDuration' --- Duration/1.0.0/trackDuration.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Duration/1.0.0/trackDuration.js b/Duration/1.0.0/trackDuration.js index 12f93cc23..0a5c49d32 100644 --- a/Duration/1.0.0/trackDuration.js +++ b/Duration/1.0.0/trackDuration.js @@ -1,4 +1,4 @@ -/ Script: Duration +// Script: TrackDuration // By: Keith Curtis // Contact: https://app.roll20.net/users/162065/keithcurtis // Base command: !duration @@ -10,7 +10,7 @@ var Duration = Duration || (function () { // CONSTANTS // ───────────────────────────────────────────── - const SCRIPT_NAME = 'Track Duration'; + const SCRIPT_NAME = 'TrackDuration'; const VERSION = '1.0.0'; const STATE_KEY = 'Duration'; @@ -893,4 +893,4 @@ var Duration = Duration || (function () { dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) }; -})(); \ No newline at end of file +})(); From 2a8ec6e6bb20bac2171b72109817e89d8a15030c Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Thu, 20 Aug 2026 10:53:39 -0700 Subject: [PATCH 6/9] Rename trackDuration to TrackDuration in script.json --- Duration/script.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Duration/script.json b/Duration/script.json index dbe9efa23..118922a6f 100644 --- a/Duration/script.json +++ b/Duration/script.json @@ -1,8 +1,8 @@ { - "name": "trackDuration", + "name": "TrackDuration", "script": "Duration.js", "version": "1.0.0", - "description": "# trackDuration\n\trackDuration keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances — and counting them back up if you rewind.\n\n## What it does\n\n- **Attach effects to tokens** with a colour marker, a name, and a round count. The count is appended directly to the token's name (e.g. `Kaanan 🔹10`) so it is always visible on the board.\n- **Counts down automatically** when a token's turn ends (the GM presses the forward button). Counts back up when a turn is rewound (backward button).\n- **Expiry notices** are whispered to the GM and the character's controller when a duration reaches zero. The effect is removed from the token name automatically.\n- **Interactive report card** — type `!duration` to see all active effects with token thumbnails, round counts, and inline edit buttons. The card header includes one-click buttons to refresh, clear, or open help.\n- **Edit any duration on the fly** — the edit button next to each effect opens a pre-filled prompt. Set it to 0 or below to remove the effect immediately.\n- **Map-change recovery** — clearing the turn order (or using the ∅ button) broadcasts a restore panel to all players. Restore buttons re-attach effects to the matching character's token on the new turn order.\n\n## Getting started\n\n1. Install the script via One-Click or paste `Duration.js` into your campaign's API console.\n2. Build up your turn order as normal.\n3. When a character gains a timed effect, make sure their token is at the top of the turn order, then click **+ Add Effect to Active Character** in the `!duration` card (or call `!duration --add` directly).\n4. Choose a colour marker from the dropdown, enter the duration in rounds, and name the effect.\n5. Advance the turn order normally — durations count themselves down.\n\n## Commands\n\n- `!duration` — show the active effects report card.\n- `!duration --add ` — add a timed effect to the top token.\n- `!duration --edit ` — set a specific effect to a new value (generated by the edit button; not typically typed by hand).\n- `!duration --clear yes` — clear all effects and broadcast the restore panel.\n- `!duration --help` — show the in-chat help card.\n\n## Notes\n\n- Only tokens present in the turn order are tracked.\n- Ticking is driven by the GM's forward/backward turn order buttons. Manual reordering does not trigger a tick.\n- The restore workflow expects the character to already be on the new map's turn order before clicking Restore.", + "description": "# TrackDuration\n\TrackDuration keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances — and counting them back up if you rewind.\n\n## What it does\n\n- **Attach effects to tokens** with a colour marker, a name, and a round count. The count is appended directly to the token's name (e.g. `Kaanan 🔹10`) so it is always visible on the board.\n- **Counts down automatically** when a token's turn ends (the GM presses the forward button). Counts back up when a turn is rewound (backward button).\n- **Expiry notices** are whispered to the GM and the character's controller when a duration reaches zero. The effect is removed from the token name automatically.\n- **Interactive report card** — type `!duration` to see all active effects with token thumbnails, round counts, and inline edit buttons. The card header includes one-click buttons to refresh, clear, or open help.\n- **Edit any duration on the fly** — the edit button next to each effect opens a pre-filled prompt. Set it to 0 or below to remove the effect immediately.\n- **Map-change recovery** — clearing the turn order (or using the ∅ button) broadcasts a restore panel to all players. Restore buttons re-attach effects to the matching character's token on the new turn order.\n\n## Getting started\n\n1. Install the script via One-Click or paste `Duration.js` into your campaign's API console.\n2. Build up your turn order as normal.\n3. When a character gains a timed effect, make sure their token is at the top of the turn order, then click **+ Add Effect to Active Character** in the `!duration` card (or call `!duration --add` directly).\n4. Choose a colour marker from the dropdown, enter the duration in rounds, and name the effect.\n5. Advance the turn order normally — durations count themselves down.\n\n## Commands\n\n- `!duration` — show the active effects report card.\n- `!duration --add ` — add a timed effect to the top token.\n- `!duration --edit ` — set a specific effect to a new value (generated by the edit button; not typically typed by hand).\n- `!duration --clear yes` — clear all effects and broadcast the restore panel.\n- `!duration --help` — show the in-chat help card.\n\n## Notes\n\n- Only tokens present in the turn order are tracked.\n- Ticking is driven by the GM's forward/backward turn order buttons. Manual reordering does not trigger a tick.\n- The restore workflow expects the character to already be on the new map's turn order before clicking Restore.", "authors": "Keith Curtis", "roll20userid": "162065", "dependencies": [], From f0d5dd241b9375d36decc376efc8634099f75575 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Thu, 20 Aug 2026 12:18:14 -0700 Subject: [PATCH 7/9] Refactor description in script.json for TrackDuration Updated the description field to be more concise while retaining essential information about the TrackDuration script. --- Duration/script.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Duration/script.json b/Duration/script.json index 118922a6f..397508ff0 100644 --- a/Duration/script.json +++ b/Duration/script.json @@ -2,8 +2,7 @@ "name": "TrackDuration", "script": "Duration.js", "version": "1.0.0", - "description": "# TrackDuration\n\TrackDuration keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances — and counting them back up if you rewind.\n\n## What it does\n\n- **Attach effects to tokens** with a colour marker, a name, and a round count. The count is appended directly to the token's name (e.g. `Kaanan 🔹10`) so it is always visible on the board.\n- **Counts down automatically** when a token's turn ends (the GM presses the forward button). Counts back up when a turn is rewound (backward button).\n- **Expiry notices** are whispered to the GM and the character's controller when a duration reaches zero. The effect is removed from the token name automatically.\n- **Interactive report card** — type `!duration` to see all active effects with token thumbnails, round counts, and inline edit buttons. The card header includes one-click buttons to refresh, clear, or open help.\n- **Edit any duration on the fly** — the edit button next to each effect opens a pre-filled prompt. Set it to 0 or below to remove the effect immediately.\n- **Map-change recovery** — clearing the turn order (or using the ∅ button) broadcasts a restore panel to all players. Restore buttons re-attach effects to the matching character's token on the new turn order.\n\n## Getting started\n\n1. Install the script via One-Click or paste `Duration.js` into your campaign's API console.\n2. Build up your turn order as normal.\n3. When a character gains a timed effect, make sure their token is at the top of the turn order, then click **+ Add Effect to Active Character** in the `!duration` card (or call `!duration --add` directly).\n4. Choose a colour marker from the dropdown, enter the duration in rounds, and name the effect.\n5. Advance the turn order normally — durations count themselves down.\n\n## Commands\n\n- `!duration` — show the active effects report card.\n- `!duration --add ` — add a timed effect to the top token.\n- `!duration --edit ` — set a specific effect to a new value (generated by the edit button; not typically typed by hand).\n- `!duration --clear yes` — clear all effects and broadcast the restore panel.\n- `!duration --help` — show the in-chat help card.\n\n## Notes\n\n- Only tokens present in the turn order are tracked.\n- Ticking is driven by the GM's forward/backward turn order buttons. Manual reordering does not trigger a tick.\n- The restore workflow expects the character to already be on the new map's turn order before clicking Restore.", - "authors": "Keith Curtis", + "description": "# TrackDuration\n\nTrackDuration keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances — and counting them back up if you [...]" "authors": "Keith Curtis", "roll20userid": "162065", "dependencies": [], "patreon": "https://www.patreon.com/c/KeithCurtis", From 38490c6fc3aad39d99cc995ab0f949b67e1024b4 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Thu, 20 Aug 2026 12:24:26 -0700 Subject: [PATCH 8/9] Delete Duration directory --- Duration/1.0.0/trackDuration.js | 896 -------------------------------- Duration/README.md | 132 ----- Duration/script.json | 12 - Duration/trackDuration.js | 896 -------------------------------- 4 files changed, 1936 deletions(-) delete mode 100644 Duration/1.0.0/trackDuration.js delete mode 100644 Duration/README.md delete mode 100644 Duration/script.json delete mode 100644 Duration/trackDuration.js diff --git a/Duration/1.0.0/trackDuration.js b/Duration/1.0.0/trackDuration.js deleted file mode 100644 index 0a5c49d32..000000000 --- a/Duration/1.0.0/trackDuration.js +++ /dev/null @@ -1,896 +0,0 @@ -// Script: TrackDuration -// By: Keith Curtis -// Contact: https://app.roll20.net/users/162065/keithcurtis -// Base command: !duration - -var Duration = Duration || (function () { - 'use strict'; - - // ───────────────────────────────────────────── - // CONSTANTS - // ───────────────────────────────────────────── - - const SCRIPT_NAME = 'TrackDuration'; - const VERSION = '1.0.0'; - const STATE_KEY = 'Duration'; - - const ALLOWED_EMOJIS = [ - '🔹', '🔸', '💠', '♦️', // diamonds - '▫️', '◽', '▪️', '◾', // squares - '🔻', '🔺', // triangles - '⭐', '✨', // stars/sparkles - ]; - - // ───────────────────────────────────────────── - // CSS - // Central style definitions. Edit values here to restyle all reports. - // All colours are mid-range to survive both light and dark VTT modes. - // ───────────────────────────────────────────── - - const CSS = { - // Outer card wrapping every report - card: 'box-sizing:border-box;width:90%;border:1px solid #777;' - + 'border-radius:5px;padding:8px 10px;' - + 'background:#333;color:#ddd;font-size:12px;', - - // Bold header line at the top of a card - header: 'font-size:14px;font-weight:bold;color:#ffbf00;' - + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', - - // Warning variant of the header (cleared message) - headerWarn: 'font-size:13px;font-weight:bold;color:#f0c060;' - + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', - - // One token block inside the card - tokenBlock: 'margin-bottom:8px;padding:5px 6px;' - + 'background:#4a4a4a;border-radius:4px;', - - // Row holding the token image + name - nameRow: 'display:table;width:100%;margin-bottom:4px;' - + 'font-size:14px;font-weight:bold;', - - // Cell for the token image - imgCell: 'display:table-cell;vertical-align:middle;' - + 'width:39px;padding-right:4px;border:none;', - - // Token thumbnail image - tokenImg: 'width:35px;height:35px;border-radius:3px;' - + 'border:none;object-fit:cover;', - - // Cell holding the character/token name - nameCell: 'display:table-cell;vertical-align:middle;' - + 'font-weight:bold;color:#e8e8e8;font-size:14px;', - - // Individual effect line - effectLine: 'margin:2px 0 2px 40px;color:#ccc;', - - // Effect name within an effect line - effectName: 'color:#e0e0e0;font-weight:bold;', - - // Round count badge - badge: 'display:inline-block;background:#444;color:#ccc;' - + 'border-radius:3px;padding:0 4px;font-size:11px;' - + 'border:1px solid #888;', - - // Generic action button (Add, Restore) - btn: 'display:inline-block;margin-top:5px;padding:3px 9px;' - + 'background:#3c525d;color:#eee;border-radius:3px;' - + 'text-decoration:none;font-size:11px;font-weight:bold;', - - // Divider between token blocks - divider: 'border:none;border-top:1px solid #666;margin:6px 0;', - - // Expiry whisper message - expiry: 'color:#f0c060;font-size:13px;font-weight:bold;', - - // Small inline button (edit) - btnSmall: 'display:inline-block;padding:0 5px;background:#4a6070;color:#ddd;' - + 'border-radius:3px;text-decoration:none;font-size:10px;' - + 'vertical-align:middle;', - - // Header icon buttons — float right, no chrome, styled to match header text - btnHeader: 'float:right;margin-left:8px;background:none;border:none;' - + 'text-decoration:none;font-size:14px;font-weight:bold;color:#ffbf00;' - + 'vertical-align:middle;', - }; - - // ───────────────────────────────────────────── - // LOGGER - // ───────────────────────────────────────────── - - const Logger = { - _prefix: `${SCRIPT_NAME} | `, - log(msg) { log(this._prefix + msg); }, - error(msg) { log(this._prefix + 'ERROR: ' + msg); } - }; - - - - const State = { - init() { - if (!state[STATE_KEY]) { - state[STATE_KEY] = { - effects: {}, - lastTopTokenId: null, - turnOrderLength: 0 - }; - } - if (!state[STATE_KEY].effects) - state[STATE_KEY].effects = {}; - if (state[STATE_KEY].lastTopTokenId === undefined) - state[STATE_KEY].lastTopTokenId = null; - if (state[STATE_KEY].turnOrderLength === undefined) - state[STATE_KEY].turnOrderLength = 0; - }, - - getEffects() { return state[STATE_KEY].effects; }, - getTokenEntry(tokenId) { return state[STATE_KEY].effects[tokenId] || null; }, - setTokenEntry(tokenId, entry) { state[STATE_KEY].effects[tokenId] = entry; }, - removeTokenEntry(tokenId) { delete state[STATE_KEY].effects[tokenId]; }, - getLastTopTokenId() { return state[STATE_KEY].lastTopTokenId; }, - setLastTopTokenId(id) { state[STATE_KEY].lastTopTokenId = id; }, - getTurnOrderLength() { return state[STATE_KEY].turnOrderLength; }, - setTurnOrderLength(n) { state[STATE_KEY].turnOrderLength = n; }, - hasAnyEffects() { return Object.keys(state[STATE_KEY].effects).length > 0; } - }; - - // ───────────────────────────────────────────── - // TOKEN UTILITIES - // ───────────────────────────────────────────── - - const TokenUtils = { - // Parse "Kaanan|🔴10|🟢3" → { base: "Kaanan", segments: [{emoji,count},...] } - parseName(rawName) { - // Find the first space followed immediately by a known effect emoji - const firstEffect = rawName.search(/ [\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728]/u); - if (firstEffect === -1) return { base: rawName, segments: [] }; - const base = rawName.substring(0, firstEffect); - const rest = rawName.substring(firstEffect); - const segments = []; - const re = / ([\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728])(\d+)/gu; - let match; - while ((match = re.exec(rest)) !== null) { - segments.push({ emoji: match[1], count: parseInt(match[2], 10) }); - } - return { base, segments }; - }, - - // Build "Kaanan|🔴10|🟢3" from base + effect list - buildName(base, effectList) { - if (!effectList || effectList.length === 0) return base; - return base + effectList.map(e => ` ${e.emoji}${e.current}`).join(''); - }, - - applyName(token, name) { token.set('name', name); }, - getToken(tokenId) { return getObj('graphic', tokenId) || null; }, - - // Retrieve the imgsrc from a token, stripping the size suffix Roll20 appends - // so it can be used in an tag at any size. - getImgSrc(token) { - const src = token.get('imgsrc') || ''; - // Roll20 appends e.g. "/med.png" — replace with "/thumb.png" for a small fetch - return src.replace(/\/[a-z]+(\.[a-z]+)$/, '/thumb$1'); - }, - - // Returns display names of all non-GM controllers of a character - getControllerWhisperTargets(characterId) { - if (!characterId) return []; - const char = getObj('character', characterId); - if (!char) return []; - const controllers = char.get('controlledby') || ''; - return controllers - .split(',') - .map(s => s.trim()) - .filter(id => id && id !== 'all') - .reduce((acc, id) => { - if (playerIsGM(id)) return acc; - const player = getObj('player', id); - if (player) acc.push(player.get('_displayname')); - return acc; - }, []); - } - }; - - // ───────────────────────────────────────────── - // PAGE UTILITIES - // ───────────────────────────────────────────── - - const PageUtils = { - getPageForPlayer(playerid) { - const player = getObj('player', playerid); - if (!player) return Campaign().get('playerpageid'); - if (playerIsGM(playerid)) { - return player.get('lastpage') || Campaign().get('playerpageid'); - } - const psp = Campaign().get('playerspecificpages'); - if (psp && psp[playerid]) return psp[playerid]; - return Campaign().get('playerpageid'); - }, - - tokenIsOnPlayersPage(token, playerid) { - return token.get('_pageid') === this.getPageForPlayer(playerid); - } - }; - - // ───────────────────────────────────────────── - // TURN ORDER UTILITIES - // ───────────────────────────────────────────── - - const TurnUtils = { - parse(raw) { - if (!raw || raw === '' || raw === '[]') return []; - try { return JSON.parse(raw); } catch (e) { return []; } - }, - tokenEntries(turnOrder) { return turnOrder.filter(e => e.id && e.id !== '-1'); }, - topEntry(tokenEntries) { return tokenEntries.length > 0 ? tokenEntries[0] : null; }, - indexOfToken(tokenEntries, id) { return tokenEntries.findIndex(e => e.id === id); } - }; - - // ───────────────────────────────────────────── - // HTML BUILDER - // Produces styled card fragments consumed by Chat. - // ───────────────────────────────────────────── - - const HTML = { - // Render a single token block: image + name + effect lines (with edit buttons) - // tokenId is passed when edit buttons are needed; pass null to omit them (e.g. restore panel) - tokenBlock(displayName, imgSrc, effects, footer, tokenId) { - let h = `
`; - - // Name row with thumbnail - h += `
`; - h += `
`; - h += ``; - h += `
`; - h += `
${displayName}
`; - h += `
`; - - // Effect lines - effects.forEach(e => { - h += `
`; - h += `${e.emoji} ${e.name}`; - h += ` ${e.current} rd`; - - // Edit button — only when a tokenId is supplied - if (tokenId) { - const editCmd = `!duration --edit ${tokenId} ?{New duration for ${e.name}|${e.current}} ${e.name}`; - h += ` ${HTML.btnSmall('edit', editCmd)}`; - } - - h += `
`; - }); - - // Optional footer (e.g. Add or Restore button) - if (footer) h += footer; - - h += `
`; - return h; - }, - - // Render a full card with a header (including floating icon buttons) and body content. - // showHeaderBtns: if true, renders the ?, ↻, ∅ buttons in the header. - card(headerText, body, isWarning, showHeaderBtns) { - const hStyle = isWarning ? CSS.headerWarn : CSS.header; - const btns = showHeaderBtns - ? `` - + `?` - + `` - : ''; - return `
` - + `
${btns}${headerText}
` - + body - + `
`; - }, - - // Render a button-style anchor - button(label, command) { - return `${label}`; - }, - - // Render a small inline button (used for per-effect actions) - btnSmall(label, command) { - return `${label}`; - }, - - // Render a floating header icon button - btnHeader(label, command) { - return `${label}`; - }, - - // Render a horizontal rule divider - divider() { - return `
`; - } - }; - - // ───────────────────────────────────────────── - // CHAT UTILITIES - // ───────────────────────────────────────────── - - const Chat = { - whisperGM(msg) { - sendChat(SCRIPT_NAME, `/w gm ${msg}`, null, { noarchive: true }); - }, - - whisperPlayer(displayName, msg) { - sendChat(SCRIPT_NAME, `/w "${displayName}" ${msg}`, null, { noarchive: true }); - }, - - broadcast(msg) { - sendChat(SCRIPT_NAME, msg); - }, - - // Whisper expiry-only notification to GM + character controllers - sendExpiryMessage(tokenName, effectEmoji, effectName, characterId) { - const inner = `${effectEmoji} ${effectName} has expired on ${tokenName}.`; - const msg = `
${inner}
`; - this.whisperGM(msg); - TokenUtils.getControllerWhisperTargets(characterId) - .forEach(name => this.whisperPlayer(name, msg)); - }, - - // Broadcast the main duration list, with an Add button for the current top token. - sendStatusMessage(entries, topTokenName) { - let body = ''; - - if (entries.length === 0) { - body = `
No active effects.
`; - } else { - entries.forEach((entry, idx) => { - if (idx > 0) body += HTML.divider(); - body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, null, entry.tokenId); - }); - } - - // Add button targets the top token in the turn order. - // Build the ?{} dropdown from ALLOWED_EMOJIS so it stays in sync automatically. - const emojiChoices = ALLOWED_EMOJIS.map(e => `${e},${e}`).join('|'); - const addCmd = topTokenName - ? `!duration --add ?{Color|${emojiChoices}} ?{Duration (rounds)|10} ?{Effect Name}` - : null; - - const buttonLabel = topTokenName - ? `+ Add Effect to Active Character` - : null; - - if (addCmd) body += HTML.button(buttonLabel, addCmd); - - this.broadcast(HTML.card('Duration Effects', body, false, true)); - }, - - // Broadcast cleared message with per-character restore buttons. - // snapshot: [ { displayName, imgSrc, characterId, characterName, effects } ] - sendClearedMessage(snapshot) { - if (snapshot.length === 0) return; - - let body = ''; - snapshot.forEach((entry, idx) => { - if (idx > 0) body += HTML.divider(); - - const payload = encodeURIComponent(JSON.stringify({ - characterId: entry.characterId, - characterName: entry.characterName, - effects: entry.effects - })); - - const restoreBtn = HTML.button('Restore', `!duration --restore ${payload}`); - body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, restoreBtn); - }); - - this.broadcast(HTML.card('Effects Cleared', body, true, false)); - } - }; - - // ───────────────────────────────────────────── - // CORE LOGIC - // ───────────────────────────────────────────── - - const Core = { - - // Bare !duration — show active effects + Add button - showStatus() { - const allEffects = State.getEffects(); - const entries = []; - - Object.entries(allEffects).forEach(([tokenId, entry]) => { - const active = entry.effects.filter(e => e.current > 0); - if (active.length === 0) return; - - let displayName = entry.baseTokenName; - if (entry.characterId) { - const char = getObj('character', entry.characterId); - if (char) displayName = char.get('name'); - } - - entries.push({ - tokenId, - displayName, - imgSrc: entry.imgSrc || '', - effects: active - }); - }); - - // Determine the name of the current top token for the Add button label - const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); - const tokenEntries = TurnUtils.tokenEntries(turnOrder); - const topEntry = TurnUtils.topEntry(tokenEntries); - let topTokenName = null; - - if (topEntry) { - const topToken = TokenUtils.getToken(topEntry.id); - if (topToken) { - const rawName = topToken.get('name') || ''; - const { base } = TokenUtils.parseName(rawName); - topTokenName = base; - } - } - - Chat.sendStatusMessage(entries, topTokenName); - }, - - // --add: attach an effect to the top token in the turn order - addEffect(emoji, rounds, effectName, msg) { - const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); - const tokenEntries = TurnUtils.tokenEntries(turnOrder); - const topEntry = TurnUtils.topEntry(tokenEntries); - - if (!topEntry) { - Chat.whisperGM('No token at the top of the turn order.'); - return; - } - - const tokenId = topEntry.id; - const token = TokenUtils.getToken(tokenId); - if (!token) { - Chat.whisperGM('Could not find the token at the top of the turn order.'); - return; - } - - // Page guard - if (!PageUtils.tokenIsOnPlayersPage(token, msg.playerid)) { - Chat.whisperGM( - 'That token is not on your current page. ' + - 'Navigate to the correct page before adding an effect.' - ); - return; - } - - const characterId = token.get('represents') || ''; - const rawName = token.get('name') || ''; - const { base } = TokenUtils.parseName(rawName); - const imgSrc = TokenUtils.getImgSrc(token); - - let entry = State.getTokenEntry(tokenId); - if (!entry) { - entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; - } - - const effectObj = { emoji, name: effectName, current: rounds, max: rounds }; - const existingIdx = entry.effects.findIndex(e => e.name === effectName); - if (existingIdx !== -1) { - entry.effects[existingIdx] = effectObj; - } else { - entry.effects.push(effectObj); - } - - State.setTokenEntry(tokenId, entry); - - const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); - TokenUtils.applyName(token, newName); - }, - - // Tick a token's effects in the given direction. - // Forward: called when the token LEAVES the top (turn just ended) → decrement. - // Backward: called when the token ARRIVES at the top (rewinding) → increment. - tickToken(tokenId, direction) { - const entry = State.getTokenEntry(tokenId); - if (!entry) return; - - const token = TokenUtils.getToken(tokenId); - if (!token) return; - - const tokenBaseName = entry.baseTokenName; - const characterId = entry.characterId; - let changed = false; - const expired = []; - - entry.effects.forEach(effect => { - const before = effect.current; - if (direction === 'forward') { - effect.current = Math.max(0, effect.current - 1); - } else { - effect.current = Math.min(effect.max, effect.current + 1); - } - if (effect.current !== before) { - changed = true; - if (effect.current === 0) expired.push({ emoji: effect.emoji, name: effect.name }); - } - }); - - // Send expiry notifications (only) - expired.forEach(({ emoji, name }) => - Chat.sendExpiryMessage(tokenBaseName, emoji, name, characterId) - ); - - // Cull expired effects - entry.effects = entry.effects.filter(e => e.current > 0); - - if (entry.effects.length === 0) { - TokenUtils.applyName(token, entry.baseTokenName); - State.removeTokenEntry(tokenId); - } else if (changed) { - const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); - TokenUtils.applyName(token, newName); - State.setTokenEntry(tokenId, entry); - } - }, - - // --edit: set a specific effect on a specific token to a new value - editEffect(tokenId, effectName, newValue) { - const entry = State.getTokenEntry(tokenId); - if (!entry) { - Chat.whisperGM('No tracked effects found for that token.'); - return; - } - - const idx = entry.effects.findIndex(e => e.name === effectName); - if (idx === -1) { - Chat.whisperGM(`Effect ${effectName} not found on that token.`); - return; - } - - const token = TokenUtils.getToken(tokenId); - - if (newValue <= 0) { - // Remove the effect entirely - entry.effects.splice(idx, 1); - if (entry.effects.length === 0) { - if (token) TokenUtils.applyName(token, entry.baseTokenName); - State.removeTokenEntry(tokenId); - } else { - if (token) { - TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); - } - State.setTokenEntry(tokenId, entry); - } - } else { - // Update current; also update max if the new value exceeds it - entry.effects[idx].current = newValue; - if (newValue > entry.effects[idx].max) entry.effects[idx].max = newValue; - State.setTokenEntry(tokenId, entry); - if (token) { - TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); - } - } - }, - restoreEffect(payload) { - let data; - try { - data = JSON.parse(decodeURIComponent(payload)); - } catch (e) { - Chat.whisperGM('Failed to parse restore payload.'); - return; - } - - const { characterId, characterName, effects } = data; - - if (!characterId || !effects || effects.length === 0) { - Chat.whisperGM('Restore payload is missing required data.'); - return; - } - - const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); - const tokenEntries = TurnUtils.tokenEntries(turnOrder); - - const matchingEntries = tokenEntries.filter(e => { - const t = TokenUtils.getToken(e.id); - return t && t.get('represents') === characterId; - }); - - if (matchingEntries.length === 0) { - Chat.whisperGM( - `No token for ${characterName} found on the current turn order.` - ); - return; - } - - const best = matchingEntries.reduce((a, b) => - parseFloat(b.pr) > parseFloat(a.pr) ? b : a - ); - - const tokenId = best.id; - const token = TokenUtils.getToken(tokenId); - if (!token) { - Chat.whisperGM(`Token for ${characterName} could not be retrieved.`); - return; - } - - const rawName = token.get('name') || ''; - const { base } = TokenUtils.parseName(rawName); - const imgSrc = TokenUtils.getImgSrc(token); - - let entry = State.getTokenEntry(tokenId); - if (!entry) { - entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; - } - - effects.forEach(effect => { - const idx = entry.effects.findIndex(e => e.name === effect.name); - if (idx !== -1) { - entry.effects[idx] = { ...effect }; - } else { - entry.effects.push({ ...effect }); - } - }); - - State.setTokenEntry(tokenId, entry); - - const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); - TokenUtils.applyName(token, newName); - }, - - // Snapshot active effects, clear state, restore token names, broadcast restore panel - clearAllEffects() { - const allEffects = State.getEffects(); - const snapshot = []; - - Object.entries(allEffects).forEach(([tokenId, entry]) => { - const activeEffects = entry.effects.filter(e => e.current > 0); - if (activeEffects.length === 0) return; - - let characterName = entry.baseTokenName; - if (entry.characterId) { - const char = getObj('character', entry.characterId); - if (char) characterName = char.get('name'); - } - - // Refresh imgSrc from live token if still present - let imgSrc = entry.imgSrc || ''; - const token = TokenUtils.getToken(tokenId); - if (token) { - imgSrc = TokenUtils.getImgSrc(token); - TokenUtils.applyName(token, entry.baseTokenName); - } - - snapshot.push({ - displayName: characterName || entry.baseTokenName, - imgSrc, - characterId: entry.characterId, - characterName, - effects: activeEffects - }); - - State.removeTokenEntry(tokenId); - }); - - State.setLastTopTokenId(null); - State.setTurnOrderLength(0); - - Chat.sendClearedMessage(snapshot); - } - }; - - // ───────────────────────────────────────────── - // TURN ORDER CHANGE HANDLER - // ───────────────────────────────────────────── - - const TurnHandler = { - handle(obj, prev) { - const newRaw = obj.get('turnorder') || ''; - const prevRaw = prev['turnorder'] || ''; - - // ── Detect full clear ────────────────────────────────────────── - const newIsEmpty = (newRaw === '' || newRaw === '[]'); - const prevIsEmpty = (prevRaw === '' || prevRaw === '[]'); - - if (newIsEmpty && !prevIsEmpty) { - if (State.hasAnyEffects()) Core.clearAllEffects(); - State.setLastTopTokenId(null); - State.setTurnOrderLength(0); - return; - } - - if (newIsEmpty) return; - - // ── Parse both orders ────────────────────────────────────────── - const newTokens = TurnUtils.tokenEntries(TurnUtils.parse(newRaw)); - const prevTokens = TurnUtils.tokenEntries(TurnUtils.parse(prevRaw)); - - const newTopEntry = TurnUtils.topEntry(newTokens); - const prevTopEntry = TurnUtils.topEntry(prevTokens); - - if (!prevTopEntry) { - if (newTopEntry) State.setLastTopTokenId(newTopEntry.id); - State.setTurnOrderLength(newTokens.length); - return; - } - - const prevTopId = prevTopEntry.id; - const newTopId = newTopEntry ? newTopEntry.id : null; - - // Top didn't change — nothing to tick - if (newTopId === prevTopId) { - State.setTurnOrderLength(newTokens.length); - return; - } - - // ── Determine direction and which token to tick ──────────────── - const prevTopNewIdx = TurnUtils.indexOfToken(newTokens, prevTopId); - const newTopPrevIdx = newTopId ? TurnUtils.indexOfToken(prevTokens, newTopId) : -1; - const n = newTokens.length; - const prevN = prevTokens.length; - - // Update bookkeeping before ticking - State.setLastTopTokenId(newTopId); - State.setTurnOrderLength(n); - - if (prevTopNewIdx === n - 1) { - Core.tickToken(prevTopId, 'forward'); - } else if (newTopPrevIdx === prevN - 1) { - Core.tickToken(newTopId, 'backward'); - } - // else: unrecognised change — no tick - } - }; - - // ───────────────────────────────────────────── - // COMMAND PARSER - // ───────────────────────────────────────────── - - const Commands = { - handle(msg) { - if (msg.type !== 'api') return; - const content = msg.content.trim(); - if (!content.startsWith('!duration')) return; - - const parts = content.split(/\s+/); - - // Bare "!duration" → status report - if (parts.length === 1) { - Core.showStatus(); - return; - } - - const sub = parts[1].toLowerCase(); - - switch (sub) { - case '--add': - this.handleAdd(parts, msg); - break; - - case '--edit': - this.handleEdit(parts); - break; - - case '--restore': { - const payload = content - .substring(content.indexOf('--restore') + '--restore'.length) - .trim(); - Core.restoreEffect(payload); - break; - } - - case '--clear': - if (parts[2] && parts[2].toLowerCase() === 'yes') { - Core.clearAllEffects(); - } - break; - - case '--help': - this.showHelp(); - break; - - default: - this.showHelp(); - break; - } - }, - - handleEdit(parts) { - // !duration --edit - if (parts.length < 5) { - Chat.whisperGM('Usage: !duration --edit <tokenId> <value> <effect name>'); - return; - } - - const tokenId = parts[2].trim(); - // parts[3] is the old value baked into the button — used only as the ?{} default. - // The player's answer arrives as parts[3] after Roll20 resolves the query. - const newValue = parseInt(parts[3].trim(), 10); - const effectName = parts.slice(4).join(' ').trim(); - - if (isNaN(newValue)) { - Chat.whisperGM('New duration must be a number.'); - return; - } - - Core.editEffect(tokenId, effectName, newValue); - }, - - handleAdd(parts, msg) { - // !duration --add - if (parts.length < 5) { - Chat.whisperGM( - 'Usage: !duration --add <emoji> <rounds> <effect name>' - ); - return; - } - - const emoji = parts[2].trim(); - const roundsRaw = parts[3].trim(); - const effectName = parts.slice(4).join(' ').trim(); - - if (!ALLOWED_EMOJIS.includes(emoji)) { - Chat.whisperGM(`Invalid emoji. Choose one of: ${ALLOWED_EMOJIS.join(' ')}`); - return; - } - - const rounds = parseInt(roundsRaw, 10); - if (isNaN(rounds) || rounds <= 0) { - Chat.whisperGM('Duration must be a positive whole number.'); - return; - } - - if (!effectName) { - Chat.whisperGM('Effect name cannot be empty.'); - return; - } - - Core.addEffect(emoji, rounds, effectName, msg); - }, - - showHelp() { - const row = (icon, label, desc) => - `
` - + `
${icon} ${label}
` - + `
${desc}
` - + `
`; - - const body = - `
Header Buttons
` - + row('∅', 'Clear Effects', - 'Clears all tracked effects after confirmation. Sends a restore panel to chat so effects can be recovered on a new map.') - + row('?', 'Help', - 'Shows this help card.') - + row('↻', 'Refresh', - 'Re-issues the !duration command to show a fresh copy of the effects list.') - + `
` - + `
Effect Lines
` - + row('edit', 'Edit Duration', - 'Opens a prompt pre-filled with the current round count. Enter a new number to adjust. Enter 0 or below to remove the effect entirely.') - + `
` - + `
Add Button
` - + row('+', 'Add Effect to Active Character', - 'Adds a new timed effect to the token at the top of the turn order. You will be prompted for a colour marker, a number of rounds, and a name for the effect.') - + `
` - + `
` - + `Effects tick down when a token's turn ends (forward), and tick up when rewinding to before its turn (backward). ` - + `An expiry notice is whispered to the GM and the character's controller when a duration reaches zero.` - + `
`; - - Chat.whisperGM(HTML.card(`Track Duration v${VERSION} — Help`, body, false, false)); - } - }; - - // ───────────────────────────────────────────── - // REGISTRATION - // ───────────────────────────────────────────── - - on('ready', () => { - State.init(); - on('chat:message', msg => Commands.handle(msg)); - on('change:campaign:turnorder', (obj, prev) => TurnHandler.handle(obj, prev)); - Logger.log(`v${VERSION} ready.`); - }); - - // ───────────────────────────────────────────── - // PUBLIC API - // ───────────────────────────────────────────── - - return { - version: VERSION, - STATE_KEY, - dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) - }; - -})(); diff --git a/Duration/README.md b/Duration/README.md deleted file mode 100644 index 3f46a114d..000000000 --- a/Duration/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# Track Duration - -A Roll20 API script that keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances. - ---- - -## Overview - -When a character is affected by a spell, ability, or condition with a round-based duration, Track Duration lets you attach that effect to their token with a color marker, a name, and a round count. The count is appended directly to the token's name, visible on the board to everyone,and ticks down automatically each time that token's turn ends. - -When the count hits zero, the effect is removed and an expiry notice is whispered to the GM and the character's controller. - - ---- - -## Quick Start - -1. Set up your turn order as normal before or during combat. The tracker must have tokens in the turn order for the script to work. -2. Type `!duration` in chat to open the effects report card. -3. When a character gains a timed effect, ensure their token is at the top of the turn order, then click **+ Add Effect to Active Character**. -4. Choose a color marker, enter the number of rounds, and name the effect. -5. Advance the turn order normally using Roll20's forward button — durations count down automatically. - ---- - -## The Report Card - -Typing `!duration` broadcasts a styled card showing all tokens with active effects. Each entry displays: - -- The token's thumbnail image and character name -- Each active effect with its color marker, name, and remaining round count -- An **edit** button next to each effect - -The card header includes three icon buttons: - -| Button | Action | -|--------|--------| -| ∅ | Clear all effects (with confirmation prompt) | -| ? | Show the help card | -| ↻ | Refresh the report card | - ---- - -## Adding Effects - -Click **+ Add Effect to Active Character** at the bottom of the report card, or type: - -``` -!duration --add -``` - -You will be prompted for: -- **Color marker** — chosen from a dropdown of shape/colour emoji -- **Duration** — number of rounds (defaults to 10) -- **Effect name** — any text, including spaces (e.g. *Mage Armor*, *Concentration*) - -The effect is added to whichever token is currently **at the top of the turn order**. Multiple effects can be stacked on a single token. - ---- - -## Editing and Removing Effects - -Click the **edit** button next to any effect in the report card. A prompt appears pre-filled with the current round count. - -- Enter a new number to adjust the duration. -- Enter **0 or below** to remove the effect entirely. - ---- - -## How Ticking Works - -Track Duration responds to Roll20's turn order buttons: - -- **Forward button** — when a token's turn ends and it moves to the bottom of the order, all its effects decrement by 1. -- **Backward button** — when the turn order is rewound and a token moves back to the top, all its effects increment by 1 (up to their original maximum). - -This ensures that advancing and then immediately rewinding a turn is a net-zero operation — you cannot accidentally drain a duration by bouncing the turn order. - -Ticking is **not** triggered by manual reordering of the turn order list. - ---- - -## Expiry - -When an effect reaches zero: - -- It is removed from the token's name automatically. -- An expiry notice is whispered to the GM and to any player who controls that character. - ---- - -## Map Changes and the Restore Panel - -When the turn order is cleared — either by the GM manually or by using the ∅ button — Track Duration: - -1. Strips all effect markers from tracked token names. -2. Broadcasts a **restore panel** to all players, listing every effect that was active at the time of clearing, along with a **Restore** button per entry. - -To recover effects after moving to a new map: - -1. Set up the new turn order (the character's new token must be on the turn order). -2. Click **Restore** next to the character's entry in the restore panel. - -Track Duration will find the highest-initiative token for that character on the current turn order and re-attach all their effects. - ---- - -## Commands Reference - -The script is controlled through a Chat Menu, but here is a lost of the commands sent, in case anyone needs them for macro-building purposes. - -| Command | Description | -|---------|-------------| -| `!duration` | Show the active effects report card | -| `!duration --add ` | Add an effect to the top token | -| `!duration --edit ` | Set an effect to a new value (used by the edit button) | -| `!duration --clear yes` | Clear all effects and broadcast the restore panel | -| `!duration --help` | Show the in-chat help card | - ---- - -## Notes and Limitations - -- Only tokens present in the **turn order** are tracked. Tokens on the board but not in the turn order are unaffected. -- The script is designed for use with Roll20's standard forward/backward turn order buttons. It does not respond to manual drag-and-drop reordering. -- The restore workflow expects the character to be on the **new map's turn order** before Restore is clicked. - ---- - -## Author - -Keith Curtis — [Patreon](https://www.patreon.com/c/KeithCurtis) diff --git a/Duration/script.json b/Duration/script.json deleted file mode 100644 index 397508ff0..000000000 --- a/Duration/script.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "TrackDuration", - "script": "Duration.js", - "version": "1.0.0", - "description": "# TrackDuration\n\nTrackDuration keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances — and counting them back up if you [...]" "authors": "Keith Curtis", - "roll20userid": "162065", - "dependencies": [], - "patreon": "https://www.patreon.com/c/KeithCurtis", - "modifies": {}, - "conflicts": [], - "previousversions": [] -} diff --git a/Duration/trackDuration.js b/Duration/trackDuration.js deleted file mode 100644 index 0a5c49d32..000000000 --- a/Duration/trackDuration.js +++ /dev/null @@ -1,896 +0,0 @@ -// Script: TrackDuration -// By: Keith Curtis -// Contact: https://app.roll20.net/users/162065/keithcurtis -// Base command: !duration - -var Duration = Duration || (function () { - 'use strict'; - - // ───────────────────────────────────────────── - // CONSTANTS - // ───────────────────────────────────────────── - - const SCRIPT_NAME = 'TrackDuration'; - const VERSION = '1.0.0'; - const STATE_KEY = 'Duration'; - - const ALLOWED_EMOJIS = [ - '🔹', '🔸', '💠', '♦️', // diamonds - '▫️', '◽', '▪️', '◾', // squares - '🔻', '🔺', // triangles - '⭐', '✨', // stars/sparkles - ]; - - // ───────────────────────────────────────────── - // CSS - // Central style definitions. Edit values here to restyle all reports. - // All colours are mid-range to survive both light and dark VTT modes. - // ───────────────────────────────────────────── - - const CSS = { - // Outer card wrapping every report - card: 'box-sizing:border-box;width:90%;border:1px solid #777;' - + 'border-radius:5px;padding:8px 10px;' - + 'background:#333;color:#ddd;font-size:12px;', - - // Bold header line at the top of a card - header: 'font-size:14px;font-weight:bold;color:#ffbf00;' - + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', - - // Warning variant of the header (cleared message) - headerWarn: 'font-size:13px;font-weight:bold;color:#f0c060;' - + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', - - // One token block inside the card - tokenBlock: 'margin-bottom:8px;padding:5px 6px;' - + 'background:#4a4a4a;border-radius:4px;', - - // Row holding the token image + name - nameRow: 'display:table;width:100%;margin-bottom:4px;' - + 'font-size:14px;font-weight:bold;', - - // Cell for the token image - imgCell: 'display:table-cell;vertical-align:middle;' - + 'width:39px;padding-right:4px;border:none;', - - // Token thumbnail image - tokenImg: 'width:35px;height:35px;border-radius:3px;' - + 'border:none;object-fit:cover;', - - // Cell holding the character/token name - nameCell: 'display:table-cell;vertical-align:middle;' - + 'font-weight:bold;color:#e8e8e8;font-size:14px;', - - // Individual effect line - effectLine: 'margin:2px 0 2px 40px;color:#ccc;', - - // Effect name within an effect line - effectName: 'color:#e0e0e0;font-weight:bold;', - - // Round count badge - badge: 'display:inline-block;background:#444;color:#ccc;' - + 'border-radius:3px;padding:0 4px;font-size:11px;' - + 'border:1px solid #888;', - - // Generic action button (Add, Restore) - btn: 'display:inline-block;margin-top:5px;padding:3px 9px;' - + 'background:#3c525d;color:#eee;border-radius:3px;' - + 'text-decoration:none;font-size:11px;font-weight:bold;', - - // Divider between token blocks - divider: 'border:none;border-top:1px solid #666;margin:6px 0;', - - // Expiry whisper message - expiry: 'color:#f0c060;font-size:13px;font-weight:bold;', - - // Small inline button (edit) - btnSmall: 'display:inline-block;padding:0 5px;background:#4a6070;color:#ddd;' - + 'border-radius:3px;text-decoration:none;font-size:10px;' - + 'vertical-align:middle;', - - // Header icon buttons — float right, no chrome, styled to match header text - btnHeader: 'float:right;margin-left:8px;background:none;border:none;' - + 'text-decoration:none;font-size:14px;font-weight:bold;color:#ffbf00;' - + 'vertical-align:middle;', - }; - - // ───────────────────────────────────────────── - // LOGGER - // ───────────────────────────────────────────── - - const Logger = { - _prefix: `${SCRIPT_NAME} | `, - log(msg) { log(this._prefix + msg); }, - error(msg) { log(this._prefix + 'ERROR: ' + msg); } - }; - - - - const State = { - init() { - if (!state[STATE_KEY]) { - state[STATE_KEY] = { - effects: {}, - lastTopTokenId: null, - turnOrderLength: 0 - }; - } - if (!state[STATE_KEY].effects) - state[STATE_KEY].effects = {}; - if (state[STATE_KEY].lastTopTokenId === undefined) - state[STATE_KEY].lastTopTokenId = null; - if (state[STATE_KEY].turnOrderLength === undefined) - state[STATE_KEY].turnOrderLength = 0; - }, - - getEffects() { return state[STATE_KEY].effects; }, - getTokenEntry(tokenId) { return state[STATE_KEY].effects[tokenId] || null; }, - setTokenEntry(tokenId, entry) { state[STATE_KEY].effects[tokenId] = entry; }, - removeTokenEntry(tokenId) { delete state[STATE_KEY].effects[tokenId]; }, - getLastTopTokenId() { return state[STATE_KEY].lastTopTokenId; }, - setLastTopTokenId(id) { state[STATE_KEY].lastTopTokenId = id; }, - getTurnOrderLength() { return state[STATE_KEY].turnOrderLength; }, - setTurnOrderLength(n) { state[STATE_KEY].turnOrderLength = n; }, - hasAnyEffects() { return Object.keys(state[STATE_KEY].effects).length > 0; } - }; - - // ───────────────────────────────────────────── - // TOKEN UTILITIES - // ───────────────────────────────────────────── - - const TokenUtils = { - // Parse "Kaanan|🔴10|🟢3" → { base: "Kaanan", segments: [{emoji,count},...] } - parseName(rawName) { - // Find the first space followed immediately by a known effect emoji - const firstEffect = rawName.search(/ [\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728]/u); - if (firstEffect === -1) return { base: rawName, segments: [] }; - const base = rawName.substring(0, firstEffect); - const rest = rawName.substring(firstEffect); - const segments = []; - const re = / ([\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728])(\d+)/gu; - let match; - while ((match = re.exec(rest)) !== null) { - segments.push({ emoji: match[1], count: parseInt(match[2], 10) }); - } - return { base, segments }; - }, - - // Build "Kaanan|🔴10|🟢3" from base + effect list - buildName(base, effectList) { - if (!effectList || effectList.length === 0) return base; - return base + effectList.map(e => ` ${e.emoji}${e.current}`).join(''); - }, - - applyName(token, name) { token.set('name', name); }, - getToken(tokenId) { return getObj('graphic', tokenId) || null; }, - - // Retrieve the imgsrc from a token, stripping the size suffix Roll20 appends - // so it can be used in an tag at any size. - getImgSrc(token) { - const src = token.get('imgsrc') || ''; - // Roll20 appends e.g. "/med.png" — replace with "/thumb.png" for a small fetch - return src.replace(/\/[a-z]+(\.[a-z]+)$/, '/thumb$1'); - }, - - // Returns display names of all non-GM controllers of a character - getControllerWhisperTargets(characterId) { - if (!characterId) return []; - const char = getObj('character', characterId); - if (!char) return []; - const controllers = char.get('controlledby') || ''; - return controllers - .split(',') - .map(s => s.trim()) - .filter(id => id && id !== 'all') - .reduce((acc, id) => { - if (playerIsGM(id)) return acc; - const player = getObj('player', id); - if (player) acc.push(player.get('_displayname')); - return acc; - }, []); - } - }; - - // ───────────────────────────────────────────── - // PAGE UTILITIES - // ───────────────────────────────────────────── - - const PageUtils = { - getPageForPlayer(playerid) { - const player = getObj('player', playerid); - if (!player) return Campaign().get('playerpageid'); - if (playerIsGM(playerid)) { - return player.get('lastpage') || Campaign().get('playerpageid'); - } - const psp = Campaign().get('playerspecificpages'); - if (psp && psp[playerid]) return psp[playerid]; - return Campaign().get('playerpageid'); - }, - - tokenIsOnPlayersPage(token, playerid) { - return token.get('_pageid') === this.getPageForPlayer(playerid); - } - }; - - // ───────────────────────────────────────────── - // TURN ORDER UTILITIES - // ───────────────────────────────────────────── - - const TurnUtils = { - parse(raw) { - if (!raw || raw === '' || raw === '[]') return []; - try { return JSON.parse(raw); } catch (e) { return []; } - }, - tokenEntries(turnOrder) { return turnOrder.filter(e => e.id && e.id !== '-1'); }, - topEntry(tokenEntries) { return tokenEntries.length > 0 ? tokenEntries[0] : null; }, - indexOfToken(tokenEntries, id) { return tokenEntries.findIndex(e => e.id === id); } - }; - - // ───────────────────────────────────────────── - // HTML BUILDER - // Produces styled card fragments consumed by Chat. - // ───────────────────────────────────────────── - - const HTML = { - // Render a single token block: image + name + effect lines (with edit buttons) - // tokenId is passed when edit buttons are needed; pass null to omit them (e.g. restore panel) - tokenBlock(displayName, imgSrc, effects, footer, tokenId) { - let h = `
`; - - // Name row with thumbnail - h += `
`; - h += `
`; - h += ``; - h += `
`; - h += `
${displayName}
`; - h += `
`; - - // Effect lines - effects.forEach(e => { - h += `
`; - h += `${e.emoji} ${e.name}`; - h += ` ${e.current} rd`; - - // Edit button — only when a tokenId is supplied - if (tokenId) { - const editCmd = `!duration --edit ${tokenId} ?{New duration for ${e.name}|${e.current}} ${e.name}`; - h += ` ${HTML.btnSmall('edit', editCmd)}`; - } - - h += `
`; - }); - - // Optional footer (e.g. Add or Restore button) - if (footer) h += footer; - - h += `
`; - return h; - }, - - // Render a full card with a header (including floating icon buttons) and body content. - // showHeaderBtns: if true, renders the ?, ↻, ∅ buttons in the header. - card(headerText, body, isWarning, showHeaderBtns) { - const hStyle = isWarning ? CSS.headerWarn : CSS.header; - const btns = showHeaderBtns - ? `` - + `?` - + `` - : ''; - return `
` - + `
${btns}${headerText}
` - + body - + `
`; - }, - - // Render a button-style anchor - button(label, command) { - return `${label}`; - }, - - // Render a small inline button (used for per-effect actions) - btnSmall(label, command) { - return `${label}`; - }, - - // Render a floating header icon button - btnHeader(label, command) { - return `${label}`; - }, - - // Render a horizontal rule divider - divider() { - return `
`; - } - }; - - // ───────────────────────────────────────────── - // CHAT UTILITIES - // ───────────────────────────────────────────── - - const Chat = { - whisperGM(msg) { - sendChat(SCRIPT_NAME, `/w gm ${msg}`, null, { noarchive: true }); - }, - - whisperPlayer(displayName, msg) { - sendChat(SCRIPT_NAME, `/w "${displayName}" ${msg}`, null, { noarchive: true }); - }, - - broadcast(msg) { - sendChat(SCRIPT_NAME, msg); - }, - - // Whisper expiry-only notification to GM + character controllers - sendExpiryMessage(tokenName, effectEmoji, effectName, characterId) { - const inner = `${effectEmoji} ${effectName} has expired on ${tokenName}.`; - const msg = `
${inner}
`; - this.whisperGM(msg); - TokenUtils.getControllerWhisperTargets(characterId) - .forEach(name => this.whisperPlayer(name, msg)); - }, - - // Broadcast the main duration list, with an Add button for the current top token. - sendStatusMessage(entries, topTokenName) { - let body = ''; - - if (entries.length === 0) { - body = `
No active effects.
`; - } else { - entries.forEach((entry, idx) => { - if (idx > 0) body += HTML.divider(); - body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, null, entry.tokenId); - }); - } - - // Add button targets the top token in the turn order. - // Build the ?{} dropdown from ALLOWED_EMOJIS so it stays in sync automatically. - const emojiChoices = ALLOWED_EMOJIS.map(e => `${e},${e}`).join('|'); - const addCmd = topTokenName - ? `!duration --add ?{Color|${emojiChoices}} ?{Duration (rounds)|10} ?{Effect Name}` - : null; - - const buttonLabel = topTokenName - ? `+ Add Effect to Active Character` - : null; - - if (addCmd) body += HTML.button(buttonLabel, addCmd); - - this.broadcast(HTML.card('Duration Effects', body, false, true)); - }, - - // Broadcast cleared message with per-character restore buttons. - // snapshot: [ { displayName, imgSrc, characterId, characterName, effects } ] - sendClearedMessage(snapshot) { - if (snapshot.length === 0) return; - - let body = ''; - snapshot.forEach((entry, idx) => { - if (idx > 0) body += HTML.divider(); - - const payload = encodeURIComponent(JSON.stringify({ - characterId: entry.characterId, - characterName: entry.characterName, - effects: entry.effects - })); - - const restoreBtn = HTML.button('Restore', `!duration --restore ${payload}`); - body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, restoreBtn); - }); - - this.broadcast(HTML.card('Effects Cleared', body, true, false)); - } - }; - - // ───────────────────────────────────────────── - // CORE LOGIC - // ───────────────────────────────────────────── - - const Core = { - - // Bare !duration — show active effects + Add button - showStatus() { - const allEffects = State.getEffects(); - const entries = []; - - Object.entries(allEffects).forEach(([tokenId, entry]) => { - const active = entry.effects.filter(e => e.current > 0); - if (active.length === 0) return; - - let displayName = entry.baseTokenName; - if (entry.characterId) { - const char = getObj('character', entry.characterId); - if (char) displayName = char.get('name'); - } - - entries.push({ - tokenId, - displayName, - imgSrc: entry.imgSrc || '', - effects: active - }); - }); - - // Determine the name of the current top token for the Add button label - const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); - const tokenEntries = TurnUtils.tokenEntries(turnOrder); - const topEntry = TurnUtils.topEntry(tokenEntries); - let topTokenName = null; - - if (topEntry) { - const topToken = TokenUtils.getToken(topEntry.id); - if (topToken) { - const rawName = topToken.get('name') || ''; - const { base } = TokenUtils.parseName(rawName); - topTokenName = base; - } - } - - Chat.sendStatusMessage(entries, topTokenName); - }, - - // --add: attach an effect to the top token in the turn order - addEffect(emoji, rounds, effectName, msg) { - const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); - const tokenEntries = TurnUtils.tokenEntries(turnOrder); - const topEntry = TurnUtils.topEntry(tokenEntries); - - if (!topEntry) { - Chat.whisperGM('No token at the top of the turn order.'); - return; - } - - const tokenId = topEntry.id; - const token = TokenUtils.getToken(tokenId); - if (!token) { - Chat.whisperGM('Could not find the token at the top of the turn order.'); - return; - } - - // Page guard - if (!PageUtils.tokenIsOnPlayersPage(token, msg.playerid)) { - Chat.whisperGM( - 'That token is not on your current page. ' + - 'Navigate to the correct page before adding an effect.' - ); - return; - } - - const characterId = token.get('represents') || ''; - const rawName = token.get('name') || ''; - const { base } = TokenUtils.parseName(rawName); - const imgSrc = TokenUtils.getImgSrc(token); - - let entry = State.getTokenEntry(tokenId); - if (!entry) { - entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; - } - - const effectObj = { emoji, name: effectName, current: rounds, max: rounds }; - const existingIdx = entry.effects.findIndex(e => e.name === effectName); - if (existingIdx !== -1) { - entry.effects[existingIdx] = effectObj; - } else { - entry.effects.push(effectObj); - } - - State.setTokenEntry(tokenId, entry); - - const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); - TokenUtils.applyName(token, newName); - }, - - // Tick a token's effects in the given direction. - // Forward: called when the token LEAVES the top (turn just ended) → decrement. - // Backward: called when the token ARRIVES at the top (rewinding) → increment. - tickToken(tokenId, direction) { - const entry = State.getTokenEntry(tokenId); - if (!entry) return; - - const token = TokenUtils.getToken(tokenId); - if (!token) return; - - const tokenBaseName = entry.baseTokenName; - const characterId = entry.characterId; - let changed = false; - const expired = []; - - entry.effects.forEach(effect => { - const before = effect.current; - if (direction === 'forward') { - effect.current = Math.max(0, effect.current - 1); - } else { - effect.current = Math.min(effect.max, effect.current + 1); - } - if (effect.current !== before) { - changed = true; - if (effect.current === 0) expired.push({ emoji: effect.emoji, name: effect.name }); - } - }); - - // Send expiry notifications (only) - expired.forEach(({ emoji, name }) => - Chat.sendExpiryMessage(tokenBaseName, emoji, name, characterId) - ); - - // Cull expired effects - entry.effects = entry.effects.filter(e => e.current > 0); - - if (entry.effects.length === 0) { - TokenUtils.applyName(token, entry.baseTokenName); - State.removeTokenEntry(tokenId); - } else if (changed) { - const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); - TokenUtils.applyName(token, newName); - State.setTokenEntry(tokenId, entry); - } - }, - - // --edit: set a specific effect on a specific token to a new value - editEffect(tokenId, effectName, newValue) { - const entry = State.getTokenEntry(tokenId); - if (!entry) { - Chat.whisperGM('No tracked effects found for that token.'); - return; - } - - const idx = entry.effects.findIndex(e => e.name === effectName); - if (idx === -1) { - Chat.whisperGM(`Effect ${effectName} not found on that token.`); - return; - } - - const token = TokenUtils.getToken(tokenId); - - if (newValue <= 0) { - // Remove the effect entirely - entry.effects.splice(idx, 1); - if (entry.effects.length === 0) { - if (token) TokenUtils.applyName(token, entry.baseTokenName); - State.removeTokenEntry(tokenId); - } else { - if (token) { - TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); - } - State.setTokenEntry(tokenId, entry); - } - } else { - // Update current; also update max if the new value exceeds it - entry.effects[idx].current = newValue; - if (newValue > entry.effects[idx].max) entry.effects[idx].max = newValue; - State.setTokenEntry(tokenId, entry); - if (token) { - TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); - } - } - }, - restoreEffect(payload) { - let data; - try { - data = JSON.parse(decodeURIComponent(payload)); - } catch (e) { - Chat.whisperGM('Failed to parse restore payload.'); - return; - } - - const { characterId, characterName, effects } = data; - - if (!characterId || !effects || effects.length === 0) { - Chat.whisperGM('Restore payload is missing required data.'); - return; - } - - const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); - const tokenEntries = TurnUtils.tokenEntries(turnOrder); - - const matchingEntries = tokenEntries.filter(e => { - const t = TokenUtils.getToken(e.id); - return t && t.get('represents') === characterId; - }); - - if (matchingEntries.length === 0) { - Chat.whisperGM( - `No token for ${characterName} found on the current turn order.` - ); - return; - } - - const best = matchingEntries.reduce((a, b) => - parseFloat(b.pr) > parseFloat(a.pr) ? b : a - ); - - const tokenId = best.id; - const token = TokenUtils.getToken(tokenId); - if (!token) { - Chat.whisperGM(`Token for ${characterName} could not be retrieved.`); - return; - } - - const rawName = token.get('name') || ''; - const { base } = TokenUtils.parseName(rawName); - const imgSrc = TokenUtils.getImgSrc(token); - - let entry = State.getTokenEntry(tokenId); - if (!entry) { - entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; - } - - effects.forEach(effect => { - const idx = entry.effects.findIndex(e => e.name === effect.name); - if (idx !== -1) { - entry.effects[idx] = { ...effect }; - } else { - entry.effects.push({ ...effect }); - } - }); - - State.setTokenEntry(tokenId, entry); - - const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); - TokenUtils.applyName(token, newName); - }, - - // Snapshot active effects, clear state, restore token names, broadcast restore panel - clearAllEffects() { - const allEffects = State.getEffects(); - const snapshot = []; - - Object.entries(allEffects).forEach(([tokenId, entry]) => { - const activeEffects = entry.effects.filter(e => e.current > 0); - if (activeEffects.length === 0) return; - - let characterName = entry.baseTokenName; - if (entry.characterId) { - const char = getObj('character', entry.characterId); - if (char) characterName = char.get('name'); - } - - // Refresh imgSrc from live token if still present - let imgSrc = entry.imgSrc || ''; - const token = TokenUtils.getToken(tokenId); - if (token) { - imgSrc = TokenUtils.getImgSrc(token); - TokenUtils.applyName(token, entry.baseTokenName); - } - - snapshot.push({ - displayName: characterName || entry.baseTokenName, - imgSrc, - characterId: entry.characterId, - characterName, - effects: activeEffects - }); - - State.removeTokenEntry(tokenId); - }); - - State.setLastTopTokenId(null); - State.setTurnOrderLength(0); - - Chat.sendClearedMessage(snapshot); - } - }; - - // ───────────────────────────────────────────── - // TURN ORDER CHANGE HANDLER - // ───────────────────────────────────────────── - - const TurnHandler = { - handle(obj, prev) { - const newRaw = obj.get('turnorder') || ''; - const prevRaw = prev['turnorder'] || ''; - - // ── Detect full clear ────────────────────────────────────────── - const newIsEmpty = (newRaw === '' || newRaw === '[]'); - const prevIsEmpty = (prevRaw === '' || prevRaw === '[]'); - - if (newIsEmpty && !prevIsEmpty) { - if (State.hasAnyEffects()) Core.clearAllEffects(); - State.setLastTopTokenId(null); - State.setTurnOrderLength(0); - return; - } - - if (newIsEmpty) return; - - // ── Parse both orders ────────────────────────────────────────── - const newTokens = TurnUtils.tokenEntries(TurnUtils.parse(newRaw)); - const prevTokens = TurnUtils.tokenEntries(TurnUtils.parse(prevRaw)); - - const newTopEntry = TurnUtils.topEntry(newTokens); - const prevTopEntry = TurnUtils.topEntry(prevTokens); - - if (!prevTopEntry) { - if (newTopEntry) State.setLastTopTokenId(newTopEntry.id); - State.setTurnOrderLength(newTokens.length); - return; - } - - const prevTopId = prevTopEntry.id; - const newTopId = newTopEntry ? newTopEntry.id : null; - - // Top didn't change — nothing to tick - if (newTopId === prevTopId) { - State.setTurnOrderLength(newTokens.length); - return; - } - - // ── Determine direction and which token to tick ──────────────── - const prevTopNewIdx = TurnUtils.indexOfToken(newTokens, prevTopId); - const newTopPrevIdx = newTopId ? TurnUtils.indexOfToken(prevTokens, newTopId) : -1; - const n = newTokens.length; - const prevN = prevTokens.length; - - // Update bookkeeping before ticking - State.setLastTopTokenId(newTopId); - State.setTurnOrderLength(n); - - if (prevTopNewIdx === n - 1) { - Core.tickToken(prevTopId, 'forward'); - } else if (newTopPrevIdx === prevN - 1) { - Core.tickToken(newTopId, 'backward'); - } - // else: unrecognised change — no tick - } - }; - - // ───────────────────────────────────────────── - // COMMAND PARSER - // ───────────────────────────────────────────── - - const Commands = { - handle(msg) { - if (msg.type !== 'api') return; - const content = msg.content.trim(); - if (!content.startsWith('!duration')) return; - - const parts = content.split(/\s+/); - - // Bare "!duration" → status report - if (parts.length === 1) { - Core.showStatus(); - return; - } - - const sub = parts[1].toLowerCase(); - - switch (sub) { - case '--add': - this.handleAdd(parts, msg); - break; - - case '--edit': - this.handleEdit(parts); - break; - - case '--restore': { - const payload = content - .substring(content.indexOf('--restore') + '--restore'.length) - .trim(); - Core.restoreEffect(payload); - break; - } - - case '--clear': - if (parts[2] && parts[2].toLowerCase() === 'yes') { - Core.clearAllEffects(); - } - break; - - case '--help': - this.showHelp(); - break; - - default: - this.showHelp(); - break; - } - }, - - handleEdit(parts) { - // !duration --edit - if (parts.length < 5) { - Chat.whisperGM('Usage: !duration --edit <tokenId> <value> <effect name>'); - return; - } - - const tokenId = parts[2].trim(); - // parts[3] is the old value baked into the button — used only as the ?{} default. - // The player's answer arrives as parts[3] after Roll20 resolves the query. - const newValue = parseInt(parts[3].trim(), 10); - const effectName = parts.slice(4).join(' ').trim(); - - if (isNaN(newValue)) { - Chat.whisperGM('New duration must be a number.'); - return; - } - - Core.editEffect(tokenId, effectName, newValue); - }, - - handleAdd(parts, msg) { - // !duration --add - if (parts.length < 5) { - Chat.whisperGM( - 'Usage: !duration --add <emoji> <rounds> <effect name>' - ); - return; - } - - const emoji = parts[2].trim(); - const roundsRaw = parts[3].trim(); - const effectName = parts.slice(4).join(' ').trim(); - - if (!ALLOWED_EMOJIS.includes(emoji)) { - Chat.whisperGM(`Invalid emoji. Choose one of: ${ALLOWED_EMOJIS.join(' ')}`); - return; - } - - const rounds = parseInt(roundsRaw, 10); - if (isNaN(rounds) || rounds <= 0) { - Chat.whisperGM('Duration must be a positive whole number.'); - return; - } - - if (!effectName) { - Chat.whisperGM('Effect name cannot be empty.'); - return; - } - - Core.addEffect(emoji, rounds, effectName, msg); - }, - - showHelp() { - const row = (icon, label, desc) => - `
` - + `
${icon} ${label}
` - + `
${desc}
` - + `
`; - - const body = - `
Header Buttons
` - + row('∅', 'Clear Effects', - 'Clears all tracked effects after confirmation. Sends a restore panel to chat so effects can be recovered on a new map.') - + row('?', 'Help', - 'Shows this help card.') - + row('↻', 'Refresh', - 'Re-issues the !duration command to show a fresh copy of the effects list.') - + `
` - + `
Effect Lines
` - + row('edit', 'Edit Duration', - 'Opens a prompt pre-filled with the current round count. Enter a new number to adjust. Enter 0 or below to remove the effect entirely.') - + `
` - + `
Add Button
` - + row('+', 'Add Effect to Active Character', - 'Adds a new timed effect to the token at the top of the turn order. You will be prompted for a colour marker, a number of rounds, and a name for the effect.') - + `
` - + `
` - + `Effects tick down when a token's turn ends (forward), and tick up when rewinding to before its turn (backward). ` - + `An expiry notice is whispered to the GM and the character's controller when a duration reaches zero.` - + `
`; - - Chat.whisperGM(HTML.card(`Track Duration v${VERSION} — Help`, body, false, false)); - } - }; - - // ───────────────────────────────────────────── - // REGISTRATION - // ───────────────────────────────────────────── - - on('ready', () => { - State.init(); - on('chat:message', msg => Commands.handle(msg)); - on('change:campaign:turnorder', (obj, prev) => TurnHandler.handle(obj, prev)); - Logger.log(`v${VERSION} ready.`); - }); - - // ───────────────────────────────────────────── - // PUBLIC API - // ───────────────────────────────────────────── - - return { - version: VERSION, - STATE_KEY, - dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) - }; - -})(); From 4cb618aa9407213cb7d68ca80032eac6641eb3f8 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Thu, 20 Aug 2026 12:24:53 -0700 Subject: [PATCH 9/9] Add files via upload --- TrackDuration/1.0.0/TrackDuration.js | 896 +++++++++++++++++++++++++++ TrackDuration/README.md | 132 ++++ TrackDuration/TrackDuration.js | 896 +++++++++++++++++++++++++++ TrackDuration/script.json | 13 + 4 files changed, 1937 insertions(+) create mode 100644 TrackDuration/1.0.0/TrackDuration.js create mode 100644 TrackDuration/README.md create mode 100644 TrackDuration/TrackDuration.js create mode 100644 TrackDuration/script.json diff --git a/TrackDuration/1.0.0/TrackDuration.js b/TrackDuration/1.0.0/TrackDuration.js new file mode 100644 index 000000000..0a5c49d32 --- /dev/null +++ b/TrackDuration/1.0.0/TrackDuration.js @@ -0,0 +1,896 @@ +// Script: TrackDuration +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +// Base command: !duration + +var Duration = Duration || (function () { + 'use strict'; + + // ───────────────────────────────────────────── + // CONSTANTS + // ───────────────────────────────────────────── + + const SCRIPT_NAME = 'TrackDuration'; + const VERSION = '1.0.0'; + const STATE_KEY = 'Duration'; + + const ALLOWED_EMOJIS = [ + '🔹', '🔸', '💠', '♦️', // diamonds + '▫️', '◽', '▪️', '◾', // squares + '🔻', '🔺', // triangles + '⭐', '✨', // stars/sparkles + ]; + + // ───────────────────────────────────────────── + // CSS + // Central style definitions. Edit values here to restyle all reports. + // All colours are mid-range to survive both light and dark VTT modes. + // ───────────────────────────────────────────── + + const CSS = { + // Outer card wrapping every report + card: 'box-sizing:border-box;width:90%;border:1px solid #777;' + + 'border-radius:5px;padding:8px 10px;' + + 'background:#333;color:#ddd;font-size:12px;', + + // Bold header line at the top of a card + header: 'font-size:14px;font-weight:bold;color:#ffbf00;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // Warning variant of the header (cleared message) + headerWarn: 'font-size:13px;font-weight:bold;color:#f0c060;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // One token block inside the card + tokenBlock: 'margin-bottom:8px;padding:5px 6px;' + + 'background:#4a4a4a;border-radius:4px;', + + // Row holding the token image + name + nameRow: 'display:table;width:100%;margin-bottom:4px;' + + 'font-size:14px;font-weight:bold;', + + // Cell for the token image + imgCell: 'display:table-cell;vertical-align:middle;' + + 'width:39px;padding-right:4px;border:none;', + + // Token thumbnail image + tokenImg: 'width:35px;height:35px;border-radius:3px;' + + 'border:none;object-fit:cover;', + + // Cell holding the character/token name + nameCell: 'display:table-cell;vertical-align:middle;' + + 'font-weight:bold;color:#e8e8e8;font-size:14px;', + + // Individual effect line + effectLine: 'margin:2px 0 2px 40px;color:#ccc;', + + // Effect name within an effect line + effectName: 'color:#e0e0e0;font-weight:bold;', + + // Round count badge + badge: 'display:inline-block;background:#444;color:#ccc;' + + 'border-radius:3px;padding:0 4px;font-size:11px;' + + 'border:1px solid #888;', + + // Generic action button (Add, Restore) + btn: 'display:inline-block;margin-top:5px;padding:3px 9px;' + + 'background:#3c525d;color:#eee;border-radius:3px;' + + 'text-decoration:none;font-size:11px;font-weight:bold;', + + // Divider between token blocks + divider: 'border:none;border-top:1px solid #666;margin:6px 0;', + + // Expiry whisper message + expiry: 'color:#f0c060;font-size:13px;font-weight:bold;', + + // Small inline button (edit) + btnSmall: 'display:inline-block;padding:0 5px;background:#4a6070;color:#ddd;' + + 'border-radius:3px;text-decoration:none;font-size:10px;' + + 'vertical-align:middle;', + + // Header icon buttons — float right, no chrome, styled to match header text + btnHeader: 'float:right;margin-left:8px;background:none;border:none;' + + 'text-decoration:none;font-size:14px;font-weight:bold;color:#ffbf00;' + + 'vertical-align:middle;', + }; + + // ───────────────────────────────────────────── + // LOGGER + // ───────────────────────────────────────────── + + const Logger = { + _prefix: `${SCRIPT_NAME} | `, + log(msg) { log(this._prefix + msg); }, + error(msg) { log(this._prefix + 'ERROR: ' + msg); } + }; + + + + const State = { + init() { + if (!state[STATE_KEY]) { + state[STATE_KEY] = { + effects: {}, + lastTopTokenId: null, + turnOrderLength: 0 + }; + } + if (!state[STATE_KEY].effects) + state[STATE_KEY].effects = {}; + if (state[STATE_KEY].lastTopTokenId === undefined) + state[STATE_KEY].lastTopTokenId = null; + if (state[STATE_KEY].turnOrderLength === undefined) + state[STATE_KEY].turnOrderLength = 0; + }, + + getEffects() { return state[STATE_KEY].effects; }, + getTokenEntry(tokenId) { return state[STATE_KEY].effects[tokenId] || null; }, + setTokenEntry(tokenId, entry) { state[STATE_KEY].effects[tokenId] = entry; }, + removeTokenEntry(tokenId) { delete state[STATE_KEY].effects[tokenId]; }, + getLastTopTokenId() { return state[STATE_KEY].lastTopTokenId; }, + setLastTopTokenId(id) { state[STATE_KEY].lastTopTokenId = id; }, + getTurnOrderLength() { return state[STATE_KEY].turnOrderLength; }, + setTurnOrderLength(n) { state[STATE_KEY].turnOrderLength = n; }, + hasAnyEffects() { return Object.keys(state[STATE_KEY].effects).length > 0; } + }; + + // ───────────────────────────────────────────── + // TOKEN UTILITIES + // ───────────────────────────────────────────── + + const TokenUtils = { + // Parse "Kaanan|🔴10|🟢3" → { base: "Kaanan", segments: [{emoji,count},...] } + parseName(rawName) { + // Find the first space followed immediately by a known effect emoji + const firstEffect = rawName.search(/ [\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728]/u); + if (firstEffect === -1) return { base: rawName, segments: [] }; + const base = rawName.substring(0, firstEffect); + const rest = rawName.substring(firstEffect); + const segments = []; + const re = / ([\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728])(\d+)/gu; + let match; + while ((match = re.exec(rest)) !== null) { + segments.push({ emoji: match[1], count: parseInt(match[2], 10) }); + } + return { base, segments }; + }, + + // Build "Kaanan|🔴10|🟢3" from base + effect list + buildName(base, effectList) { + if (!effectList || effectList.length === 0) return base; + return base + effectList.map(e => ` ${e.emoji}${e.current}`).join(''); + }, + + applyName(token, name) { token.set('name', name); }, + getToken(tokenId) { return getObj('graphic', tokenId) || null; }, + + // Retrieve the imgsrc from a token, stripping the size suffix Roll20 appends + // so it can be used in an tag at any size. + getImgSrc(token) { + const src = token.get('imgsrc') || ''; + // Roll20 appends e.g. "/med.png" — replace with "/thumb.png" for a small fetch + return src.replace(/\/[a-z]+(\.[a-z]+)$/, '/thumb$1'); + }, + + // Returns display names of all non-GM controllers of a character + getControllerWhisperTargets(characterId) { + if (!characterId) return []; + const char = getObj('character', characterId); + if (!char) return []; + const controllers = char.get('controlledby') || ''; + return controllers + .split(',') + .map(s => s.trim()) + .filter(id => id && id !== 'all') + .reduce((acc, id) => { + if (playerIsGM(id)) return acc; + const player = getObj('player', id); + if (player) acc.push(player.get('_displayname')); + return acc; + }, []); + } + }; + + // ───────────────────────────────────────────── + // PAGE UTILITIES + // ───────────────────────────────────────────── + + const PageUtils = { + getPageForPlayer(playerid) { + const player = getObj('player', playerid); + if (!player) return Campaign().get('playerpageid'); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + const psp = Campaign().get('playerspecificpages'); + if (psp && psp[playerid]) return psp[playerid]; + return Campaign().get('playerpageid'); + }, + + tokenIsOnPlayersPage(token, playerid) { + return token.get('_pageid') === this.getPageForPlayer(playerid); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER UTILITIES + // ───────────────────────────────────────────── + + const TurnUtils = { + parse(raw) { + if (!raw || raw === '' || raw === '[]') return []; + try { return JSON.parse(raw); } catch (e) { return []; } + }, + tokenEntries(turnOrder) { return turnOrder.filter(e => e.id && e.id !== '-1'); }, + topEntry(tokenEntries) { return tokenEntries.length > 0 ? tokenEntries[0] : null; }, + indexOfToken(tokenEntries, id) { return tokenEntries.findIndex(e => e.id === id); } + }; + + // ───────────────────────────────────────────── + // HTML BUILDER + // Produces styled card fragments consumed by Chat. + // ───────────────────────────────────────────── + + const HTML = { + // Render a single token block: image + name + effect lines (with edit buttons) + // tokenId is passed when edit buttons are needed; pass null to omit them (e.g. restore panel) + tokenBlock(displayName, imgSrc, effects, footer, tokenId) { + let h = `
`; + + // Name row with thumbnail + h += `
`; + h += `
`; + h += ``; + h += `
`; + h += `
${displayName}
`; + h += `
`; + + // Effect lines + effects.forEach(e => { + h += `
`; + h += `${e.emoji} ${e.name}`; + h += ` ${e.current} rd`; + + // Edit button — only when a tokenId is supplied + if (tokenId) { + const editCmd = `!duration --edit ${tokenId} ?{New duration for ${e.name}|${e.current}} ${e.name}`; + h += ` ${HTML.btnSmall('edit', editCmd)}`; + } + + h += `
`; + }); + + // Optional footer (e.g. Add or Restore button) + if (footer) h += footer; + + h += `
`; + return h; + }, + + // Render a full card with a header (including floating icon buttons) and body content. + // showHeaderBtns: if true, renders the ?, ↻, ∅ buttons in the header. + card(headerText, body, isWarning, showHeaderBtns) { + const hStyle = isWarning ? CSS.headerWarn : CSS.header; + const btns = showHeaderBtns + ? `` + + `?` + + `` + : ''; + return `
` + + `
${btns}${headerText}
` + + body + + `
`; + }, + + // Render a button-style anchor + button(label, command) { + return `${label}`; + }, + + // Render a small inline button (used for per-effect actions) + btnSmall(label, command) { + return `${label}`; + }, + + // Render a floating header icon button + btnHeader(label, command) { + return `${label}`; + }, + + // Render a horizontal rule divider + divider() { + return `
`; + } + }; + + // ───────────────────────────────────────────── + // CHAT UTILITIES + // ───────────────────────────────────────────── + + const Chat = { + whisperGM(msg) { + sendChat(SCRIPT_NAME, `/w gm ${msg}`, null, { noarchive: true }); + }, + + whisperPlayer(displayName, msg) { + sendChat(SCRIPT_NAME, `/w "${displayName}" ${msg}`, null, { noarchive: true }); + }, + + broadcast(msg) { + sendChat(SCRIPT_NAME, msg); + }, + + // Whisper expiry-only notification to GM + character controllers + sendExpiryMessage(tokenName, effectEmoji, effectName, characterId) { + const inner = `${effectEmoji} ${effectName} has expired on ${tokenName}.`; + const msg = `
${inner}
`; + this.whisperGM(msg); + TokenUtils.getControllerWhisperTargets(characterId) + .forEach(name => this.whisperPlayer(name, msg)); + }, + + // Broadcast the main duration list, with an Add button for the current top token. + sendStatusMessage(entries, topTokenName) { + let body = ''; + + if (entries.length === 0) { + body = `
No active effects.
`; + } else { + entries.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, null, entry.tokenId); + }); + } + + // Add button targets the top token in the turn order. + // Build the ?{} dropdown from ALLOWED_EMOJIS so it stays in sync automatically. + const emojiChoices = ALLOWED_EMOJIS.map(e => `${e},${e}`).join('|'); + const addCmd = topTokenName + ? `!duration --add ?{Color|${emojiChoices}} ?{Duration (rounds)|10} ?{Effect Name}` + : null; + + const buttonLabel = topTokenName + ? `+ Add Effect to Active Character` + : null; + + if (addCmd) body += HTML.button(buttonLabel, addCmd); + + this.broadcast(HTML.card('Duration Effects', body, false, true)); + }, + + // Broadcast cleared message with per-character restore buttons. + // snapshot: [ { displayName, imgSrc, characterId, characterName, effects } ] + sendClearedMessage(snapshot) { + if (snapshot.length === 0) return; + + let body = ''; + snapshot.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + + const payload = encodeURIComponent(JSON.stringify({ + characterId: entry.characterId, + characterName: entry.characterName, + effects: entry.effects + })); + + const restoreBtn = HTML.button('Restore', `!duration --restore ${payload}`); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, restoreBtn); + }); + + this.broadcast(HTML.card('Effects Cleared', body, true, false)); + } + }; + + // ───────────────────────────────────────────── + // CORE LOGIC + // ───────────────────────────────────────────── + + const Core = { + + // Bare !duration — show active effects + Add button + showStatus() { + const allEffects = State.getEffects(); + const entries = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const active = entry.effects.filter(e => e.current > 0); + if (active.length === 0) return; + + let displayName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) displayName = char.get('name'); + } + + entries.push({ + tokenId, + displayName, + imgSrc: entry.imgSrc || '', + effects: active + }); + }); + + // Determine the name of the current top token for the Add button label + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + let topTokenName = null; + + if (topEntry) { + const topToken = TokenUtils.getToken(topEntry.id); + if (topToken) { + const rawName = topToken.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + topTokenName = base; + } + } + + Chat.sendStatusMessage(entries, topTokenName); + }, + + // --add: attach an effect to the top token in the turn order + addEffect(emoji, rounds, effectName, msg) { + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + + if (!topEntry) { + Chat.whisperGM('No token at the top of the turn order.'); + return; + } + + const tokenId = topEntry.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM('Could not find the token at the top of the turn order.'); + return; + } + + // Page guard + if (!PageUtils.tokenIsOnPlayersPage(token, msg.playerid)) { + Chat.whisperGM( + 'That token is not on your current page. ' + + 'Navigate to the correct page before adding an effect.' + ); + return; + } + + const characterId = token.get('represents') || ''; + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + const effectObj = { emoji, name: effectName, current: rounds, max: rounds }; + const existingIdx = entry.effects.findIndex(e => e.name === effectName); + if (existingIdx !== -1) { + entry.effects[existingIdx] = effectObj; + } else { + entry.effects.push(effectObj); + } + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Tick a token's effects in the given direction. + // Forward: called when the token LEAVES the top (turn just ended) → decrement. + // Backward: called when the token ARRIVES at the top (rewinding) → increment. + tickToken(tokenId, direction) { + const entry = State.getTokenEntry(tokenId); + if (!entry) return; + + const token = TokenUtils.getToken(tokenId); + if (!token) return; + + const tokenBaseName = entry.baseTokenName; + const characterId = entry.characterId; + let changed = false; + const expired = []; + + entry.effects.forEach(effect => { + const before = effect.current; + if (direction === 'forward') { + effect.current = Math.max(0, effect.current - 1); + } else { + effect.current = Math.min(effect.max, effect.current + 1); + } + if (effect.current !== before) { + changed = true; + if (effect.current === 0) expired.push({ emoji: effect.emoji, name: effect.name }); + } + }); + + // Send expiry notifications (only) + expired.forEach(({ emoji, name }) => + Chat.sendExpiryMessage(tokenBaseName, emoji, name, characterId) + ); + + // Cull expired effects + entry.effects = entry.effects.filter(e => e.current > 0); + + if (entry.effects.length === 0) { + TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else if (changed) { + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + State.setTokenEntry(tokenId, entry); + } + }, + + // --edit: set a specific effect on a specific token to a new value + editEffect(tokenId, effectName, newValue) { + const entry = State.getTokenEntry(tokenId); + if (!entry) { + Chat.whisperGM('No tracked effects found for that token.'); + return; + } + + const idx = entry.effects.findIndex(e => e.name === effectName); + if (idx === -1) { + Chat.whisperGM(`Effect ${effectName} not found on that token.`); + return; + } + + const token = TokenUtils.getToken(tokenId); + + if (newValue <= 0) { + // Remove the effect entirely + entry.effects.splice(idx, 1); + if (entry.effects.length === 0) { + if (token) TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else { + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + State.setTokenEntry(tokenId, entry); + } + } else { + // Update current; also update max if the new value exceeds it + entry.effects[idx].current = newValue; + if (newValue > entry.effects[idx].max) entry.effects[idx].max = newValue; + State.setTokenEntry(tokenId, entry); + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + } + }, + restoreEffect(payload) { + let data; + try { + data = JSON.parse(decodeURIComponent(payload)); + } catch (e) { + Chat.whisperGM('Failed to parse restore payload.'); + return; + } + + const { characterId, characterName, effects } = data; + + if (!characterId || !effects || effects.length === 0) { + Chat.whisperGM('Restore payload is missing required data.'); + return; + } + + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + + const matchingEntries = tokenEntries.filter(e => { + const t = TokenUtils.getToken(e.id); + return t && t.get('represents') === characterId; + }); + + if (matchingEntries.length === 0) { + Chat.whisperGM( + `No token for ${characterName} found on the current turn order.` + ); + return; + } + + const best = matchingEntries.reduce((a, b) => + parseFloat(b.pr) > parseFloat(a.pr) ? b : a + ); + + const tokenId = best.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM(`Token for ${characterName} could not be retrieved.`); + return; + } + + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + effects.forEach(effect => { + const idx = entry.effects.findIndex(e => e.name === effect.name); + if (idx !== -1) { + entry.effects[idx] = { ...effect }; + } else { + entry.effects.push({ ...effect }); + } + }); + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Snapshot active effects, clear state, restore token names, broadcast restore panel + clearAllEffects() { + const allEffects = State.getEffects(); + const snapshot = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const activeEffects = entry.effects.filter(e => e.current > 0); + if (activeEffects.length === 0) return; + + let characterName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) characterName = char.get('name'); + } + + // Refresh imgSrc from live token if still present + let imgSrc = entry.imgSrc || ''; + const token = TokenUtils.getToken(tokenId); + if (token) { + imgSrc = TokenUtils.getImgSrc(token); + TokenUtils.applyName(token, entry.baseTokenName); + } + + snapshot.push({ + displayName: characterName || entry.baseTokenName, + imgSrc, + characterId: entry.characterId, + characterName, + effects: activeEffects + }); + + State.removeTokenEntry(tokenId); + }); + + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + + Chat.sendClearedMessage(snapshot); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER CHANGE HANDLER + // ───────────────────────────────────────────── + + const TurnHandler = { + handle(obj, prev) { + const newRaw = obj.get('turnorder') || ''; + const prevRaw = prev['turnorder'] || ''; + + // ── Detect full clear ────────────────────────────────────────── + const newIsEmpty = (newRaw === '' || newRaw === '[]'); + const prevIsEmpty = (prevRaw === '' || prevRaw === '[]'); + + if (newIsEmpty && !prevIsEmpty) { + if (State.hasAnyEffects()) Core.clearAllEffects(); + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + return; + } + + if (newIsEmpty) return; + + // ── Parse both orders ────────────────────────────────────────── + const newTokens = TurnUtils.tokenEntries(TurnUtils.parse(newRaw)); + const prevTokens = TurnUtils.tokenEntries(TurnUtils.parse(prevRaw)); + + const newTopEntry = TurnUtils.topEntry(newTokens); + const prevTopEntry = TurnUtils.topEntry(prevTokens); + + if (!prevTopEntry) { + if (newTopEntry) State.setLastTopTokenId(newTopEntry.id); + State.setTurnOrderLength(newTokens.length); + return; + } + + const prevTopId = prevTopEntry.id; + const newTopId = newTopEntry ? newTopEntry.id : null; + + // Top didn't change — nothing to tick + if (newTopId === prevTopId) { + State.setTurnOrderLength(newTokens.length); + return; + } + + // ── Determine direction and which token to tick ──────────────── + const prevTopNewIdx = TurnUtils.indexOfToken(newTokens, prevTopId); + const newTopPrevIdx = newTopId ? TurnUtils.indexOfToken(prevTokens, newTopId) : -1; + const n = newTokens.length; + const prevN = prevTokens.length; + + // Update bookkeeping before ticking + State.setLastTopTokenId(newTopId); + State.setTurnOrderLength(n); + + if (prevTopNewIdx === n - 1) { + Core.tickToken(prevTopId, 'forward'); + } else if (newTopPrevIdx === prevN - 1) { + Core.tickToken(newTopId, 'backward'); + } + // else: unrecognised change — no tick + } + }; + + // ───────────────────────────────────────────── + // COMMAND PARSER + // ───────────────────────────────────────────── + + const Commands = { + handle(msg) { + if (msg.type !== 'api') return; + const content = msg.content.trim(); + if (!content.startsWith('!duration')) return; + + const parts = content.split(/\s+/); + + // Bare "!duration" → status report + if (parts.length === 1) { + Core.showStatus(); + return; + } + + const sub = parts[1].toLowerCase(); + + switch (sub) { + case '--add': + this.handleAdd(parts, msg); + break; + + case '--edit': + this.handleEdit(parts); + break; + + case '--restore': { + const payload = content + .substring(content.indexOf('--restore') + '--restore'.length) + .trim(); + Core.restoreEffect(payload); + break; + } + + case '--clear': + if (parts[2] && parts[2].toLowerCase() === 'yes') { + Core.clearAllEffects(); + } + break; + + case '--help': + this.showHelp(); + break; + + default: + this.showHelp(); + break; + } + }, + + handleEdit(parts) { + // !duration --edit + if (parts.length < 5) { + Chat.whisperGM('Usage: !duration --edit <tokenId> <value> <effect name>'); + return; + } + + const tokenId = parts[2].trim(); + // parts[3] is the old value baked into the button — used only as the ?{} default. + // The player's answer arrives as parts[3] after Roll20 resolves the query. + const newValue = parseInt(parts[3].trim(), 10); + const effectName = parts.slice(4).join(' ').trim(); + + if (isNaN(newValue)) { + Chat.whisperGM('New duration must be a number.'); + return; + } + + Core.editEffect(tokenId, effectName, newValue); + }, + + handleAdd(parts, msg) { + // !duration --add + if (parts.length < 5) { + Chat.whisperGM( + 'Usage: !duration --add <emoji> <rounds> <effect name>' + ); + return; + } + + const emoji = parts[2].trim(); + const roundsRaw = parts[3].trim(); + const effectName = parts.slice(4).join(' ').trim(); + + if (!ALLOWED_EMOJIS.includes(emoji)) { + Chat.whisperGM(`Invalid emoji. Choose one of: ${ALLOWED_EMOJIS.join(' ')}`); + return; + } + + const rounds = parseInt(roundsRaw, 10); + if (isNaN(rounds) || rounds <= 0) { + Chat.whisperGM('Duration must be a positive whole number.'); + return; + } + + if (!effectName) { + Chat.whisperGM('Effect name cannot be empty.'); + return; + } + + Core.addEffect(emoji, rounds, effectName, msg); + }, + + showHelp() { + const row = (icon, label, desc) => + `
` + + `
${icon} ${label}
` + + `
${desc}
` + + `
`; + + const body = + `
Header Buttons
` + + row('∅', 'Clear Effects', + 'Clears all tracked effects after confirmation. Sends a restore panel to chat so effects can be recovered on a new map.') + + row('?', 'Help', + 'Shows this help card.') + + row('↻', 'Refresh', + 'Re-issues the !duration command to show a fresh copy of the effects list.') + + `
` + + `
Effect Lines
` + + row('edit', 'Edit Duration', + 'Opens a prompt pre-filled with the current round count. Enter a new number to adjust. Enter 0 or below to remove the effect entirely.') + + `
` + + `
Add Button
` + + row('+', 'Add Effect to Active Character', + 'Adds a new timed effect to the token at the top of the turn order. You will be prompted for a colour marker, a number of rounds, and a name for the effect.') + + `
` + + `
` + + `Effects tick down when a token's turn ends (forward), and tick up when rewinding to before its turn (backward). ` + + `An expiry notice is whispered to the GM and the character's controller when a duration reaches zero.` + + `
`; + + Chat.whisperGM(HTML.card(`Track Duration v${VERSION} — Help`, body, false, false)); + } + }; + + // ───────────────────────────────────────────── + // REGISTRATION + // ───────────────────────────────────────────── + + on('ready', () => { + State.init(); + on('chat:message', msg => Commands.handle(msg)); + on('change:campaign:turnorder', (obj, prev) => TurnHandler.handle(obj, prev)); + Logger.log(`v${VERSION} ready.`); + }); + + // ───────────────────────────────────────────── + // PUBLIC API + // ───────────────────────────────────────────── + + return { + version: VERSION, + STATE_KEY, + dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) + }; + +})(); diff --git a/TrackDuration/README.md b/TrackDuration/README.md new file mode 100644 index 000000000..3f46a114d --- /dev/null +++ b/TrackDuration/README.md @@ -0,0 +1,132 @@ +# Track Duration + +A Roll20 API script that keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances. + +--- + +## Overview + +When a character is affected by a spell, ability, or condition with a round-based duration, Track Duration lets you attach that effect to their token with a color marker, a name, and a round count. The count is appended directly to the token's name, visible on the board to everyone,and ticks down automatically each time that token's turn ends. + +When the count hits zero, the effect is removed and an expiry notice is whispered to the GM and the character's controller. + + +--- + +## Quick Start + +1. Set up your turn order as normal before or during combat. The tracker must have tokens in the turn order for the script to work. +2. Type `!duration` in chat to open the effects report card. +3. When a character gains a timed effect, ensure their token is at the top of the turn order, then click **+ Add Effect to Active Character**. +4. Choose a color marker, enter the number of rounds, and name the effect. +5. Advance the turn order normally using Roll20's forward button — durations count down automatically. + +--- + +## The Report Card + +Typing `!duration` broadcasts a styled card showing all tokens with active effects. Each entry displays: + +- The token's thumbnail image and character name +- Each active effect with its color marker, name, and remaining round count +- An **edit** button next to each effect + +The card header includes three icon buttons: + +| Button | Action | +|--------|--------| +| ∅ | Clear all effects (with confirmation prompt) | +| ? | Show the help card | +| ↻ | Refresh the report card | + +--- + +## Adding Effects + +Click **+ Add Effect to Active Character** at the bottom of the report card, or type: + +``` +!duration --add +``` + +You will be prompted for: +- **Color marker** — chosen from a dropdown of shape/colour emoji +- **Duration** — number of rounds (defaults to 10) +- **Effect name** — any text, including spaces (e.g. *Mage Armor*, *Concentration*) + +The effect is added to whichever token is currently **at the top of the turn order**. Multiple effects can be stacked on a single token. + +--- + +## Editing and Removing Effects + +Click the **edit** button next to any effect in the report card. A prompt appears pre-filled with the current round count. + +- Enter a new number to adjust the duration. +- Enter **0 or below** to remove the effect entirely. + +--- + +## How Ticking Works + +Track Duration responds to Roll20's turn order buttons: + +- **Forward button** — when a token's turn ends and it moves to the bottom of the order, all its effects decrement by 1. +- **Backward button** — when the turn order is rewound and a token moves back to the top, all its effects increment by 1 (up to their original maximum). + +This ensures that advancing and then immediately rewinding a turn is a net-zero operation — you cannot accidentally drain a duration by bouncing the turn order. + +Ticking is **not** triggered by manual reordering of the turn order list. + +--- + +## Expiry + +When an effect reaches zero: + +- It is removed from the token's name automatically. +- An expiry notice is whispered to the GM and to any player who controls that character. + +--- + +## Map Changes and the Restore Panel + +When the turn order is cleared — either by the GM manually or by using the ∅ button — Track Duration: + +1. Strips all effect markers from tracked token names. +2. Broadcasts a **restore panel** to all players, listing every effect that was active at the time of clearing, along with a **Restore** button per entry. + +To recover effects after moving to a new map: + +1. Set up the new turn order (the character's new token must be on the turn order). +2. Click **Restore** next to the character's entry in the restore panel. + +Track Duration will find the highest-initiative token for that character on the current turn order and re-attach all their effects. + +--- + +## Commands Reference + +The script is controlled through a Chat Menu, but here is a lost of the commands sent, in case anyone needs them for macro-building purposes. + +| Command | Description | +|---------|-------------| +| `!duration` | Show the active effects report card | +| `!duration --add ` | Add an effect to the top token | +| `!duration --edit ` | Set an effect to a new value (used by the edit button) | +| `!duration --clear yes` | Clear all effects and broadcast the restore panel | +| `!duration --help` | Show the in-chat help card | + +--- + +## Notes and Limitations + +- Only tokens present in the **turn order** are tracked. Tokens on the board but not in the turn order are unaffected. +- The script is designed for use with Roll20's standard forward/backward turn order buttons. It does not respond to manual drag-and-drop reordering. +- The restore workflow expects the character to be on the **new map's turn order** before Restore is clicked. + +--- + +## Author + +Keith Curtis — [Patreon](https://www.patreon.com/c/KeithCurtis) diff --git a/TrackDuration/TrackDuration.js b/TrackDuration/TrackDuration.js new file mode 100644 index 000000000..0a5c49d32 --- /dev/null +++ b/TrackDuration/TrackDuration.js @@ -0,0 +1,896 @@ +// Script: TrackDuration +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +// Base command: !duration + +var Duration = Duration || (function () { + 'use strict'; + + // ───────────────────────────────────────────── + // CONSTANTS + // ───────────────────────────────────────────── + + const SCRIPT_NAME = 'TrackDuration'; + const VERSION = '1.0.0'; + const STATE_KEY = 'Duration'; + + const ALLOWED_EMOJIS = [ + '🔹', '🔸', '💠', '♦️', // diamonds + '▫️', '◽', '▪️', '◾', // squares + '🔻', '🔺', // triangles + '⭐', '✨', // stars/sparkles + ]; + + // ───────────────────────────────────────────── + // CSS + // Central style definitions. Edit values here to restyle all reports. + // All colours are mid-range to survive both light and dark VTT modes. + // ───────────────────────────────────────────── + + const CSS = { + // Outer card wrapping every report + card: 'box-sizing:border-box;width:90%;border:1px solid #777;' + + 'border-radius:5px;padding:8px 10px;' + + 'background:#333;color:#ddd;font-size:12px;', + + // Bold header line at the top of a card + header: 'font-size:14px;font-weight:bold;color:#ffbf00;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // Warning variant of the header (cleared message) + headerWarn: 'font-size:13px;font-weight:bold;color:#f0c060;' + + 'border-bottom:1px solid #777;padding-bottom:4px;margin-bottom:6px;', + + // One token block inside the card + tokenBlock: 'margin-bottom:8px;padding:5px 6px;' + + 'background:#4a4a4a;border-radius:4px;', + + // Row holding the token image + name + nameRow: 'display:table;width:100%;margin-bottom:4px;' + + 'font-size:14px;font-weight:bold;', + + // Cell for the token image + imgCell: 'display:table-cell;vertical-align:middle;' + + 'width:39px;padding-right:4px;border:none;', + + // Token thumbnail image + tokenImg: 'width:35px;height:35px;border-radius:3px;' + + 'border:none;object-fit:cover;', + + // Cell holding the character/token name + nameCell: 'display:table-cell;vertical-align:middle;' + + 'font-weight:bold;color:#e8e8e8;font-size:14px;', + + // Individual effect line + effectLine: 'margin:2px 0 2px 40px;color:#ccc;', + + // Effect name within an effect line + effectName: 'color:#e0e0e0;font-weight:bold;', + + // Round count badge + badge: 'display:inline-block;background:#444;color:#ccc;' + + 'border-radius:3px;padding:0 4px;font-size:11px;' + + 'border:1px solid #888;', + + // Generic action button (Add, Restore) + btn: 'display:inline-block;margin-top:5px;padding:3px 9px;' + + 'background:#3c525d;color:#eee;border-radius:3px;' + + 'text-decoration:none;font-size:11px;font-weight:bold;', + + // Divider between token blocks + divider: 'border:none;border-top:1px solid #666;margin:6px 0;', + + // Expiry whisper message + expiry: 'color:#f0c060;font-size:13px;font-weight:bold;', + + // Small inline button (edit) + btnSmall: 'display:inline-block;padding:0 5px;background:#4a6070;color:#ddd;' + + 'border-radius:3px;text-decoration:none;font-size:10px;' + + 'vertical-align:middle;', + + // Header icon buttons — float right, no chrome, styled to match header text + btnHeader: 'float:right;margin-left:8px;background:none;border:none;' + + 'text-decoration:none;font-size:14px;font-weight:bold;color:#ffbf00;' + + 'vertical-align:middle;', + }; + + // ───────────────────────────────────────────── + // LOGGER + // ───────────────────────────────────────────── + + const Logger = { + _prefix: `${SCRIPT_NAME} | `, + log(msg) { log(this._prefix + msg); }, + error(msg) { log(this._prefix + 'ERROR: ' + msg); } + }; + + + + const State = { + init() { + if (!state[STATE_KEY]) { + state[STATE_KEY] = { + effects: {}, + lastTopTokenId: null, + turnOrderLength: 0 + }; + } + if (!state[STATE_KEY].effects) + state[STATE_KEY].effects = {}; + if (state[STATE_KEY].lastTopTokenId === undefined) + state[STATE_KEY].lastTopTokenId = null; + if (state[STATE_KEY].turnOrderLength === undefined) + state[STATE_KEY].turnOrderLength = 0; + }, + + getEffects() { return state[STATE_KEY].effects; }, + getTokenEntry(tokenId) { return state[STATE_KEY].effects[tokenId] || null; }, + setTokenEntry(tokenId, entry) { state[STATE_KEY].effects[tokenId] = entry; }, + removeTokenEntry(tokenId) { delete state[STATE_KEY].effects[tokenId]; }, + getLastTopTokenId() { return state[STATE_KEY].lastTopTokenId; }, + setLastTopTokenId(id) { state[STATE_KEY].lastTopTokenId = id; }, + getTurnOrderLength() { return state[STATE_KEY].turnOrderLength; }, + setTurnOrderLength(n) { state[STATE_KEY].turnOrderLength = n; }, + hasAnyEffects() { return Object.keys(state[STATE_KEY].effects).length > 0; } + }; + + // ───────────────────────────────────────────── + // TOKEN UTILITIES + // ───────────────────────────────────────────── + + const TokenUtils = { + // Parse "Kaanan|🔴10|🟢3" → { base: "Kaanan", segments: [{emoji,count},...] } + parseName(rawName) { + // Find the first space followed immediately by a known effect emoji + const firstEffect = rawName.search(/ [\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728]/u); + if (firstEffect === -1) return { base: rawName, segments: [] }; + const base = rawName.substring(0, firstEffect); + const rest = rawName.substring(firstEffect); + const segments = []; + const re = / ([\u{1F539}\u{1F538}\u{1F4A0}\u{1F536}\u25AB\u25FB\u25AA\u25FE\u{1F53B}\u{1F53A}\u2B50\u2728])(\d+)/gu; + let match; + while ((match = re.exec(rest)) !== null) { + segments.push({ emoji: match[1], count: parseInt(match[2], 10) }); + } + return { base, segments }; + }, + + // Build "Kaanan|🔴10|🟢3" from base + effect list + buildName(base, effectList) { + if (!effectList || effectList.length === 0) return base; + return base + effectList.map(e => ` ${e.emoji}${e.current}`).join(''); + }, + + applyName(token, name) { token.set('name', name); }, + getToken(tokenId) { return getObj('graphic', tokenId) || null; }, + + // Retrieve the imgsrc from a token, stripping the size suffix Roll20 appends + // so it can be used in an tag at any size. + getImgSrc(token) { + const src = token.get('imgsrc') || ''; + // Roll20 appends e.g. "/med.png" — replace with "/thumb.png" for a small fetch + return src.replace(/\/[a-z]+(\.[a-z]+)$/, '/thumb$1'); + }, + + // Returns display names of all non-GM controllers of a character + getControllerWhisperTargets(characterId) { + if (!characterId) return []; + const char = getObj('character', characterId); + if (!char) return []; + const controllers = char.get('controlledby') || ''; + return controllers + .split(',') + .map(s => s.trim()) + .filter(id => id && id !== 'all') + .reduce((acc, id) => { + if (playerIsGM(id)) return acc; + const player = getObj('player', id); + if (player) acc.push(player.get('_displayname')); + return acc; + }, []); + } + }; + + // ───────────────────────────────────────────── + // PAGE UTILITIES + // ───────────────────────────────────────────── + + const PageUtils = { + getPageForPlayer(playerid) { + const player = getObj('player', playerid); + if (!player) return Campaign().get('playerpageid'); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + const psp = Campaign().get('playerspecificpages'); + if (psp && psp[playerid]) return psp[playerid]; + return Campaign().get('playerpageid'); + }, + + tokenIsOnPlayersPage(token, playerid) { + return token.get('_pageid') === this.getPageForPlayer(playerid); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER UTILITIES + // ───────────────────────────────────────────── + + const TurnUtils = { + parse(raw) { + if (!raw || raw === '' || raw === '[]') return []; + try { return JSON.parse(raw); } catch (e) { return []; } + }, + tokenEntries(turnOrder) { return turnOrder.filter(e => e.id && e.id !== '-1'); }, + topEntry(tokenEntries) { return tokenEntries.length > 0 ? tokenEntries[0] : null; }, + indexOfToken(tokenEntries, id) { return tokenEntries.findIndex(e => e.id === id); } + }; + + // ───────────────────────────────────────────── + // HTML BUILDER + // Produces styled card fragments consumed by Chat. + // ───────────────────────────────────────────── + + const HTML = { + // Render a single token block: image + name + effect lines (with edit buttons) + // tokenId is passed when edit buttons are needed; pass null to omit them (e.g. restore panel) + tokenBlock(displayName, imgSrc, effects, footer, tokenId) { + let h = `
`; + + // Name row with thumbnail + h += `
`; + h += `
`; + h += ``; + h += `
`; + h += `
${displayName}
`; + h += `
`; + + // Effect lines + effects.forEach(e => { + h += `
`; + h += `${e.emoji} ${e.name}`; + h += ` ${e.current} rd`; + + // Edit button — only when a tokenId is supplied + if (tokenId) { + const editCmd = `!duration --edit ${tokenId} ?{New duration for ${e.name}|${e.current}} ${e.name}`; + h += ` ${HTML.btnSmall('edit', editCmd)}`; + } + + h += `
`; + }); + + // Optional footer (e.g. Add or Restore button) + if (footer) h += footer; + + h += `
`; + return h; + }, + + // Render a full card with a header (including floating icon buttons) and body content. + // showHeaderBtns: if true, renders the ?, ↻, ∅ buttons in the header. + card(headerText, body, isWarning, showHeaderBtns) { + const hStyle = isWarning ? CSS.headerWarn : CSS.header; + const btns = showHeaderBtns + ? `` + + `?` + + `` + : ''; + return `
` + + `
${btns}${headerText}
` + + body + + `
`; + }, + + // Render a button-style anchor + button(label, command) { + return `${label}`; + }, + + // Render a small inline button (used for per-effect actions) + btnSmall(label, command) { + return `${label}`; + }, + + // Render a floating header icon button + btnHeader(label, command) { + return `${label}`; + }, + + // Render a horizontal rule divider + divider() { + return `
`; + } + }; + + // ───────────────────────────────────────────── + // CHAT UTILITIES + // ───────────────────────────────────────────── + + const Chat = { + whisperGM(msg) { + sendChat(SCRIPT_NAME, `/w gm ${msg}`, null, { noarchive: true }); + }, + + whisperPlayer(displayName, msg) { + sendChat(SCRIPT_NAME, `/w "${displayName}" ${msg}`, null, { noarchive: true }); + }, + + broadcast(msg) { + sendChat(SCRIPT_NAME, msg); + }, + + // Whisper expiry-only notification to GM + character controllers + sendExpiryMessage(tokenName, effectEmoji, effectName, characterId) { + const inner = `${effectEmoji} ${effectName} has expired on ${tokenName}.`; + const msg = `
${inner}
`; + this.whisperGM(msg); + TokenUtils.getControllerWhisperTargets(characterId) + .forEach(name => this.whisperPlayer(name, msg)); + }, + + // Broadcast the main duration list, with an Add button for the current top token. + sendStatusMessage(entries, topTokenName) { + let body = ''; + + if (entries.length === 0) { + body = `
No active effects.
`; + } else { + entries.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, null, entry.tokenId); + }); + } + + // Add button targets the top token in the turn order. + // Build the ?{} dropdown from ALLOWED_EMOJIS so it stays in sync automatically. + const emojiChoices = ALLOWED_EMOJIS.map(e => `${e},${e}`).join('|'); + const addCmd = topTokenName + ? `!duration --add ?{Color|${emojiChoices}} ?{Duration (rounds)|10} ?{Effect Name}` + : null; + + const buttonLabel = topTokenName + ? `+ Add Effect to Active Character` + : null; + + if (addCmd) body += HTML.button(buttonLabel, addCmd); + + this.broadcast(HTML.card('Duration Effects', body, false, true)); + }, + + // Broadcast cleared message with per-character restore buttons. + // snapshot: [ { displayName, imgSrc, characterId, characterName, effects } ] + sendClearedMessage(snapshot) { + if (snapshot.length === 0) return; + + let body = ''; + snapshot.forEach((entry, idx) => { + if (idx > 0) body += HTML.divider(); + + const payload = encodeURIComponent(JSON.stringify({ + characterId: entry.characterId, + characterName: entry.characterName, + effects: entry.effects + })); + + const restoreBtn = HTML.button('Restore', `!duration --restore ${payload}`); + body += HTML.tokenBlock(entry.displayName, entry.imgSrc, entry.effects, restoreBtn); + }); + + this.broadcast(HTML.card('Effects Cleared', body, true, false)); + } + }; + + // ───────────────────────────────────────────── + // CORE LOGIC + // ───────────────────────────────────────────── + + const Core = { + + // Bare !duration — show active effects + Add button + showStatus() { + const allEffects = State.getEffects(); + const entries = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const active = entry.effects.filter(e => e.current > 0); + if (active.length === 0) return; + + let displayName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) displayName = char.get('name'); + } + + entries.push({ + tokenId, + displayName, + imgSrc: entry.imgSrc || '', + effects: active + }); + }); + + // Determine the name of the current top token for the Add button label + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + let topTokenName = null; + + if (topEntry) { + const topToken = TokenUtils.getToken(topEntry.id); + if (topToken) { + const rawName = topToken.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + topTokenName = base; + } + } + + Chat.sendStatusMessage(entries, topTokenName); + }, + + // --add: attach an effect to the top token in the turn order + addEffect(emoji, rounds, effectName, msg) { + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + const topEntry = TurnUtils.topEntry(tokenEntries); + + if (!topEntry) { + Chat.whisperGM('No token at the top of the turn order.'); + return; + } + + const tokenId = topEntry.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM('Could not find the token at the top of the turn order.'); + return; + } + + // Page guard + if (!PageUtils.tokenIsOnPlayersPage(token, msg.playerid)) { + Chat.whisperGM( + 'That token is not on your current page. ' + + 'Navigate to the correct page before adding an effect.' + ); + return; + } + + const characterId = token.get('represents') || ''; + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + const effectObj = { emoji, name: effectName, current: rounds, max: rounds }; + const existingIdx = entry.effects.findIndex(e => e.name === effectName); + if (existingIdx !== -1) { + entry.effects[existingIdx] = effectObj; + } else { + entry.effects.push(effectObj); + } + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Tick a token's effects in the given direction. + // Forward: called when the token LEAVES the top (turn just ended) → decrement. + // Backward: called when the token ARRIVES at the top (rewinding) → increment. + tickToken(tokenId, direction) { + const entry = State.getTokenEntry(tokenId); + if (!entry) return; + + const token = TokenUtils.getToken(tokenId); + if (!token) return; + + const tokenBaseName = entry.baseTokenName; + const characterId = entry.characterId; + let changed = false; + const expired = []; + + entry.effects.forEach(effect => { + const before = effect.current; + if (direction === 'forward') { + effect.current = Math.max(0, effect.current - 1); + } else { + effect.current = Math.min(effect.max, effect.current + 1); + } + if (effect.current !== before) { + changed = true; + if (effect.current === 0) expired.push({ emoji: effect.emoji, name: effect.name }); + } + }); + + // Send expiry notifications (only) + expired.forEach(({ emoji, name }) => + Chat.sendExpiryMessage(tokenBaseName, emoji, name, characterId) + ); + + // Cull expired effects + entry.effects = entry.effects.filter(e => e.current > 0); + + if (entry.effects.length === 0) { + TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else if (changed) { + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + State.setTokenEntry(tokenId, entry); + } + }, + + // --edit: set a specific effect on a specific token to a new value + editEffect(tokenId, effectName, newValue) { + const entry = State.getTokenEntry(tokenId); + if (!entry) { + Chat.whisperGM('No tracked effects found for that token.'); + return; + } + + const idx = entry.effects.findIndex(e => e.name === effectName); + if (idx === -1) { + Chat.whisperGM(`Effect ${effectName} not found on that token.`); + return; + } + + const token = TokenUtils.getToken(tokenId); + + if (newValue <= 0) { + // Remove the effect entirely + entry.effects.splice(idx, 1); + if (entry.effects.length === 0) { + if (token) TokenUtils.applyName(token, entry.baseTokenName); + State.removeTokenEntry(tokenId); + } else { + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + State.setTokenEntry(tokenId, entry); + } + } else { + // Update current; also update max if the new value exceeds it + entry.effects[idx].current = newValue; + if (newValue > entry.effects[idx].max) entry.effects[idx].max = newValue; + State.setTokenEntry(tokenId, entry); + if (token) { + TokenUtils.applyName(token, TokenUtils.buildName(entry.baseTokenName, entry.effects)); + } + } + }, + restoreEffect(payload) { + let data; + try { + data = JSON.parse(decodeURIComponent(payload)); + } catch (e) { + Chat.whisperGM('Failed to parse restore payload.'); + return; + } + + const { characterId, characterName, effects } = data; + + if (!characterId || !effects || effects.length === 0) { + Chat.whisperGM('Restore payload is missing required data.'); + return; + } + + const turnOrder = TurnUtils.parse(Campaign().get('turnorder')); + const tokenEntries = TurnUtils.tokenEntries(turnOrder); + + const matchingEntries = tokenEntries.filter(e => { + const t = TokenUtils.getToken(e.id); + return t && t.get('represents') === characterId; + }); + + if (matchingEntries.length === 0) { + Chat.whisperGM( + `No token for ${characterName} found on the current turn order.` + ); + return; + } + + const best = matchingEntries.reduce((a, b) => + parseFloat(b.pr) > parseFloat(a.pr) ? b : a + ); + + const tokenId = best.id; + const token = TokenUtils.getToken(tokenId); + if (!token) { + Chat.whisperGM(`Token for ${characterName} could not be retrieved.`); + return; + } + + const rawName = token.get('name') || ''; + const { base } = TokenUtils.parseName(rawName); + const imgSrc = TokenUtils.getImgSrc(token); + + let entry = State.getTokenEntry(tokenId); + if (!entry) { + entry = { characterId, baseTokenName: base, imgSrc, effects: [] }; + } + + effects.forEach(effect => { + const idx = entry.effects.findIndex(e => e.name === effect.name); + if (idx !== -1) { + entry.effects[idx] = { ...effect }; + } else { + entry.effects.push({ ...effect }); + } + }); + + State.setTokenEntry(tokenId, entry); + + const newName = TokenUtils.buildName(entry.baseTokenName, entry.effects); + TokenUtils.applyName(token, newName); + }, + + // Snapshot active effects, clear state, restore token names, broadcast restore panel + clearAllEffects() { + const allEffects = State.getEffects(); + const snapshot = []; + + Object.entries(allEffects).forEach(([tokenId, entry]) => { + const activeEffects = entry.effects.filter(e => e.current > 0); + if (activeEffects.length === 0) return; + + let characterName = entry.baseTokenName; + if (entry.characterId) { + const char = getObj('character', entry.characterId); + if (char) characterName = char.get('name'); + } + + // Refresh imgSrc from live token if still present + let imgSrc = entry.imgSrc || ''; + const token = TokenUtils.getToken(tokenId); + if (token) { + imgSrc = TokenUtils.getImgSrc(token); + TokenUtils.applyName(token, entry.baseTokenName); + } + + snapshot.push({ + displayName: characterName || entry.baseTokenName, + imgSrc, + characterId: entry.characterId, + characterName, + effects: activeEffects + }); + + State.removeTokenEntry(tokenId); + }); + + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + + Chat.sendClearedMessage(snapshot); + } + }; + + // ───────────────────────────────────────────── + // TURN ORDER CHANGE HANDLER + // ───────────────────────────────────────────── + + const TurnHandler = { + handle(obj, prev) { + const newRaw = obj.get('turnorder') || ''; + const prevRaw = prev['turnorder'] || ''; + + // ── Detect full clear ────────────────────────────────────────── + const newIsEmpty = (newRaw === '' || newRaw === '[]'); + const prevIsEmpty = (prevRaw === '' || prevRaw === '[]'); + + if (newIsEmpty && !prevIsEmpty) { + if (State.hasAnyEffects()) Core.clearAllEffects(); + State.setLastTopTokenId(null); + State.setTurnOrderLength(0); + return; + } + + if (newIsEmpty) return; + + // ── Parse both orders ────────────────────────────────────────── + const newTokens = TurnUtils.tokenEntries(TurnUtils.parse(newRaw)); + const prevTokens = TurnUtils.tokenEntries(TurnUtils.parse(prevRaw)); + + const newTopEntry = TurnUtils.topEntry(newTokens); + const prevTopEntry = TurnUtils.topEntry(prevTokens); + + if (!prevTopEntry) { + if (newTopEntry) State.setLastTopTokenId(newTopEntry.id); + State.setTurnOrderLength(newTokens.length); + return; + } + + const prevTopId = prevTopEntry.id; + const newTopId = newTopEntry ? newTopEntry.id : null; + + // Top didn't change — nothing to tick + if (newTopId === prevTopId) { + State.setTurnOrderLength(newTokens.length); + return; + } + + // ── Determine direction and which token to tick ──────────────── + const prevTopNewIdx = TurnUtils.indexOfToken(newTokens, prevTopId); + const newTopPrevIdx = newTopId ? TurnUtils.indexOfToken(prevTokens, newTopId) : -1; + const n = newTokens.length; + const prevN = prevTokens.length; + + // Update bookkeeping before ticking + State.setLastTopTokenId(newTopId); + State.setTurnOrderLength(n); + + if (prevTopNewIdx === n - 1) { + Core.tickToken(prevTopId, 'forward'); + } else if (newTopPrevIdx === prevN - 1) { + Core.tickToken(newTopId, 'backward'); + } + // else: unrecognised change — no tick + } + }; + + // ───────────────────────────────────────────── + // COMMAND PARSER + // ───────────────────────────────────────────── + + const Commands = { + handle(msg) { + if (msg.type !== 'api') return; + const content = msg.content.trim(); + if (!content.startsWith('!duration')) return; + + const parts = content.split(/\s+/); + + // Bare "!duration" → status report + if (parts.length === 1) { + Core.showStatus(); + return; + } + + const sub = parts[1].toLowerCase(); + + switch (sub) { + case '--add': + this.handleAdd(parts, msg); + break; + + case '--edit': + this.handleEdit(parts); + break; + + case '--restore': { + const payload = content + .substring(content.indexOf('--restore') + '--restore'.length) + .trim(); + Core.restoreEffect(payload); + break; + } + + case '--clear': + if (parts[2] && parts[2].toLowerCase() === 'yes') { + Core.clearAllEffects(); + } + break; + + case '--help': + this.showHelp(); + break; + + default: + this.showHelp(); + break; + } + }, + + handleEdit(parts) { + // !duration --edit + if (parts.length < 5) { + Chat.whisperGM('Usage: !duration --edit <tokenId> <value> <effect name>'); + return; + } + + const tokenId = parts[2].trim(); + // parts[3] is the old value baked into the button — used only as the ?{} default. + // The player's answer arrives as parts[3] after Roll20 resolves the query. + const newValue = parseInt(parts[3].trim(), 10); + const effectName = parts.slice(4).join(' ').trim(); + + if (isNaN(newValue)) { + Chat.whisperGM('New duration must be a number.'); + return; + } + + Core.editEffect(tokenId, effectName, newValue); + }, + + handleAdd(parts, msg) { + // !duration --add + if (parts.length < 5) { + Chat.whisperGM( + 'Usage: !duration --add <emoji> <rounds> <effect name>' + ); + return; + } + + const emoji = parts[2].trim(); + const roundsRaw = parts[3].trim(); + const effectName = parts.slice(4).join(' ').trim(); + + if (!ALLOWED_EMOJIS.includes(emoji)) { + Chat.whisperGM(`Invalid emoji. Choose one of: ${ALLOWED_EMOJIS.join(' ')}`); + return; + } + + const rounds = parseInt(roundsRaw, 10); + if (isNaN(rounds) || rounds <= 0) { + Chat.whisperGM('Duration must be a positive whole number.'); + return; + } + + if (!effectName) { + Chat.whisperGM('Effect name cannot be empty.'); + return; + } + + Core.addEffect(emoji, rounds, effectName, msg); + }, + + showHelp() { + const row = (icon, label, desc) => + `
` + + `
${icon} ${label}
` + + `
${desc}
` + + `
`; + + const body = + `
Header Buttons
` + + row('∅', 'Clear Effects', + 'Clears all tracked effects after confirmation. Sends a restore panel to chat so effects can be recovered on a new map.') + + row('?', 'Help', + 'Shows this help card.') + + row('↻', 'Refresh', + 'Re-issues the !duration command to show a fresh copy of the effects list.') + + `
` + + `
Effect Lines
` + + row('edit', 'Edit Duration', + 'Opens a prompt pre-filled with the current round count. Enter a new number to adjust. Enter 0 or below to remove the effect entirely.') + + `
` + + `
Add Button
` + + row('+', 'Add Effect to Active Character', + 'Adds a new timed effect to the token at the top of the turn order. You will be prompted for a colour marker, a number of rounds, and a name for the effect.') + + `
` + + `
` + + `Effects tick down when a token's turn ends (forward), and tick up when rewinding to before its turn (backward). ` + + `An expiry notice is whispered to the GM and the character's controller when a duration reaches zero.` + + `
`; + + Chat.whisperGM(HTML.card(`Track Duration v${VERSION} — Help`, body, false, false)); + } + }; + + // ───────────────────────────────────────────── + // REGISTRATION + // ───────────────────────────────────────────── + + on('ready', () => { + State.init(); + on('chat:message', msg => Commands.handle(msg)); + on('change:campaign:turnorder', (obj, prev) => TurnHandler.handle(obj, prev)); + Logger.log(`v${VERSION} ready.`); + }); + + // ───────────────────────────────────────────── + // PUBLIC API + // ───────────────────────────────────────────── + + return { + version: VERSION, + STATE_KEY, + dumpState: () => JSON.stringify(state[STATE_KEY], null, 2) + }; + +})(); diff --git a/TrackDuration/script.json b/TrackDuration/script.json new file mode 100644 index 000000000..3d9a3872e --- /dev/null +++ b/TrackDuration/script.json @@ -0,0 +1,13 @@ +{ + "name": "TrackDuration", + "script": "TrackDuration.js", + "version": "1.0.0", + "description": "# trackDuration\nTrackDuration keeps timed effects attached to tokens in the turn order, counting them down automatically as combat advances — and counting them back up if you rewind.\n\n## What it does\n\n- **Attach effects to tokens** with a colour marker, a name, and a round count. The count is appended directly to the token's name (e.g. `Kaanan 🔹10`) so it is always visible on the board.\n- **Counts down automatically** when a token's turn ends (the GM presses the forward button). Counts back up when a turn is rewound (backward button).\n- **Expiry notices** are whispered to the GM and the character's controller when a duration reaches zero. The effect is removed from the token name automatically.\n- **Interactive report card** — type `!duration` to see all active effects with token thumbnails, round counts, and inline edit buttons. The card header includes one-click buttons to refresh, clear, or open help.\n- **Edit any duration on the fly** — the edit button next to each effect opens a pre-filled prompt. Set it to 0 or below to remove the effect immediately.\n- **Map-change recovery** — clearing the turn order (or using the ∅ button) broadcasts a restore panel to all players. Restore buttons re-attach effects to the matching character's token on the new turn order.\n\n## Getting started\n\n1. Install the script via One-Click or paste `Duration.js` into your campaign's API console.\n2. Build up your turn order as normal.\n3. When a character gains a timed effect, make sure their token is at the top of the turn order, then click **+ Add Effect to Active Character** in the `!duration` card (or call `!duration --add` directly).\n4. Choose a colour marker from the dropdown, enter the duration in rounds, and name the effect.\n5. Advance the turn order normally — durations count themselves down.\n\n## Commands\n\n- `!duration` — show the active effects report card.\n- `!duration --add ` — add a timed effect to the top token.\n- `!duration --edit ` — set a specific effect to a new value (generated by the edit button; not typically typed by hand).\n- `!duration --clear yes` — clear all effects and broadcast the restore panel.\n- `!duration --help` — show the in-chat help card.\n\n## Notes\n\n- Only tokens present in the turn order are tracked.\n- Ticking is driven by the GM's forward/backward turn order buttons. Manual reordering does not trigger a tick.\n- The restore workflow expects the character to already be on the new map's turn order before clicking Restore.", + "authors": "Keith Curtis", + "roll20userid": "162065", + "dependencies": [], + "patreon": "https://www.patreon.com/c/KeithCurtis", + "modifies": {}, + "conflicts": [], + "previousversions": [] +}