diff --git a/HealthColors/2.4.0/HealthColors.js b/HealthColors/2.4.0/HealthColors.js new file mode 100644 index 000000000..6f1f74a11 --- /dev/null +++ b/HealthColors/2.4.0/HealthColors.js @@ -0,0 +1,4770 @@ +// =========================== +// === HealthColors v2.4.0 === +// =========================== + +// AUTHORS: +// - DXWarlock: https://app.roll20.net/users/262130/dxwarlock +// - Surok: https://app.roll20.net/users/335573/surok +// - MidNiteShadow7: https://app.roll20.net/users/16506286/midniteshadow7 + +/* global createObj TokenMod SmartAoE spawnFxWithDefinition spawnFx spawnFxBetweenPoints getObj state globalconfig playerIsGM sendChat findObjs Campaign log on getSheetItem */ + +var HealthColors = + HealthColors || + (() => { + 'use strict'; + + // ————— CONSTANTS ————— + const VERSION = '2.4.0'; + const SCRIPT_NAME = 'HealthColors'; + const SCHEMA_VERSION = '1.1.0'; + const UPDATED = '2026-10-25 12:00 UTC'; + + // ————— DEFAULTS ————— + /** + * Default values written into `state.HealthColors` on first install or after a reset. + * Every key maps directly to a property used at runtime — changing a value here changes + * the out-of-the-box behavior for new or reset campaigns. + * + * @property {boolean} auraColorOn - Master on/off switch for the whole script. + * @property {string} auraBar - Which token bar to read HP from ('bar1'|'bar2'|'bar3'|'bar4'). + * @property {boolean} auraTint - When true, colors the token tint instead of the aura rings. + * @property {number} auraPercPC - HP % threshold below which the PC aura activates (0–100). + * @property {number} auraPerc - HP % threshold below which the NPC aura activates (0–100). + * @property {boolean} PCAura - Whether to show a health aura on player-character tokens. + * @property {boolean} NPCAura - Whether to show a health aura on monster/NPC tokens. + * @property {boolean} auraDeadPC - Whether to mark a PC token with the dead status at 0 HP. + * @property {boolean} auraDead - Whether to mark an NPC token with the dead status at 0 HP. + * @property {string} GM_PCNames - GM visibility of PC token names ('Yes'|'No'|'Off'). + * @property {string} PCNames - Player visibility of PC token names ('Yes'|'No'|'Off'). + * @property {string} GM_NPCNames - GM visibility of NPC token names ('Yes'|'No'|'Off'). + * @property {string} NPCNames - Player visibility of NPC token names ('Yes'|'No'|'Off'). + * @property {number} AuraSize - Page units (usually feet) the aura extends beyond the token edge. + * @property {string} Aura1Shape - Shape applied to the Aura 1 health ring in aura mode: a Roll20 shape + * (Circle, Square, Hex, Circle Border, Square Border, Hex Border), + * Auto / Auto Border (hex on hex-grid pages, circle otherwise), or + * Keep (leave each token's own shape alone). + * @property {number} auraOpacity - Aura 1 health-ring opacity in percent (100 = solid, as before). + * @property {boolean} auraLockPlayers - When true (aura mode), controlling players cannot edit Aura 1. + * @property {string} Aura1Color - Reference value only (aura mode colours Aura 1 by health). + * @property {number} Aura2Size - Reference value only; Aura 2 is never changed by the script. + * @property {string} Aura2Shape - Reference value only; Aura 2 is never changed by the script. + * @property {string} Aura2Color - Reference value only; Aura 2 is never changed by the script. + * @property {boolean} OneOff - When true, tokens without a linked character also get auras. + * @property {boolean} FX - Whether to spawn particle FX on HP changes. + * @property {string} HealFX - Healing effect: a hex color (no '#') that recolours the default particle + * burst, a Roll20 built-in effect name (`glow-holy`), or a custom FX name. + * @property {string} HurtFX - Hurt/damage effect; same formats as HealFX (default a red particle burst). + * @property {string} auraDeadFX - Jukebox track name to play on death, or 'None' to disable. + * @property {string} colorPalette - Health aura colour palette ('default'|'colorblind'). + * @property {boolean} deathSavesOn - Master toggle for the optional Death Save Integration (off by default). + * @property {string} dsSuccessAttr - Death-save success attribute name(s); single name or comma-separated list + * of boolean checkboxes (D&D sheets use three). Read via the `@{}` chat parser + * through the generated Death-Saves macro, since the API can't read Beacon sheets. + * @property {string} dsFailureAttr - Death-save failure attribute name(s); same single-or-list format. + * @property {string} dsDyingMarker - Roll20 status marker for a dying PC at 0 HP (default 'skull'; for the D&D + * "Unconscious" condition marker use its exact tag from `!aura deathsaves markers`). + * @property {string} dsStableMarker - Roll20 status marker for a stable PC (3 successes) (default 'green'). + */ + const DEFAULTS = { + auraColorOn: true, + auraBar: 'bar1', + auraTint: false, + auraPercPC: 100, + auraPerc: 100, + PCAura: true, + NPCAura: true, + auraDeadPC: true, + auraDead: true, + GM_PCNames: 'Yes', + PCNames: 'Yes', + GM_NPCNames: 'Yes', + NPCNames: 'Yes', + AuraSize: 0.35, + Aura1Shape: 'Auto', + auraOpacity: 100, + auraLockPlayers: true, + Aura1Color: '00FF00', + Aura2Size: 5, + Aura2Shape: 'Square', + Aura2Color: '806600', + OneOff: false, + FX: true, + HealFX: 'FDDC5C', + HurtFX: 'FF0000', + auraDeadFX: 'None', + colorPalette: 'default', + deathSavesOn: false, + dsSuccessAttr: 'deathsave_succ1,deathsave_succ2,deathsave_succ3', + dsFailureAttr: 'deathsave_fail1,deathsave_fail2,deathsave_fail3', + dsDyingMarker: 'skull', + dsStableMarker: 'green', + }; + + const COLOR_PALETTES = { + default: { + high: [0, 255, 0], // green + mid: [255, 255, 0], // yellow + low: [255, 0, 0], // red + dead: [0, 0, 0], // black + }, + colorblind: { + high: [51, 187, 238], // cyan + mid: [238, 119, 51], // orange + low: [204, 51, 17], // magenta + dead: [0, 0, 0], // black + }, + }; + + // ————— BUILT-IN FX ————— + /** + * Roll20's built-in particle-effect vocabulary. A built-in effect name is `-` + * (e.g. `glow-holy`, `explode-blood`), exactly as shown in the in-game Effects menu. + * Directional types (beam, breath, splatter) need a start and end point, so they are + * spawned with `spawnFxBetweenPoints` from the token's own position. + */ + const BUILTIN_FX_TYPES = [ + 'beam', + 'bomb', + 'breath', + 'bubbling', + 'burn', + 'burst', + 'explode', + 'glow', + 'missile', + 'nova', + 'splatter', + ]; + const BUILTIN_FX_COLORS = [ + 'acid', + 'blood', + 'charm', + 'death', + 'fire', + 'frost', + 'holy', + 'magic', + 'slime', + 'smoke', + 'water', + ]; + const DIRECTIONAL_FX_TYPES = new Set(['beam', 'breath', 'splatter']); + const BUILTIN_FX_PATTERN = new RegExp(`^(${BUILTIN_FX_TYPES.join('|')})-(${BUILTIN_FX_COLORS.join('|')})$`); + + /** + * Seed definition for the '-DefaultHurt' Roll20 custom FX object created at install. + * Models a downward-falling burst (blood/debris) triggered when a token loses HP. + * `startColour` and `endColour` are placeholder zeroes — they are overwritten at + * runtime with the value of `state.HealthColors.HurtFX` (or a per-character override) + * before the FX is spawned, so changing them here has no visible effect. + * + * @property {number} maxParticles - Maximum simultaneous particles in the burst. + * @property {number} duration - How long (in frames) the emitter runs. + * @property {number} size - Base particle diameter before scale is applied. + * @property {number} sizeRandom - Random variance added to each particle's size. + * @property {number} lifeSpan - Frames each particle lives before fading. + * @property {number} lifeSpanRandom - Random variance added to each particle's lifespan. + * @property {number} speed - Base particle speed before scale is applied. + * @property {number} speedRandom - Random variance added to each particle's speed. + * @property {{x:number,y:number}} gravity - Per-frame acceleration applied to all particles. + * @property {number} angle - Emission direction in degrees (270 = straight down). + * @property {number} angleRandom - Cone spread around the emission angle. + * @property {number} emissionRate - Particles emitted per frame while the emitter is active. + * @property {number[]} startColour - RGBA start colour placeholder; overwritten at runtime. + * @property {number[]} endColour - RGBA end colour placeholder; overwritten at runtime. + */ + const DEFAULT_HURT_FX = { + maxParticles: 150, + duration: 50, + size: 10, + sizeRandom: 3, + lifeSpan: 25, + lifeSpanRandom: 5, + speed: 8, + speedRandom: 3, + gravity: { x: 0.01, y: 0.65 }, + angle: 270, + angleRandom: 25, + emissionRate: 100, + startColour: [0, 0, 0, 0], + endColour: [0, 0, 0, 0], + }; + + /** + * Seed definition for the '-DefaultHeal' Roll20 custom FX object created at install. + * Models a soft omnidirectional sparkle/glow triggered when a token regains HP. + * Like DEFAULT_HURT_FX, `startColour` and `endColour` are placeholders overwritten + * at runtime with `state.HealthColors.HealFX` before the FX is spawned. + * + * @property {number} maxParticles - Maximum simultaneous particles in the burst. + * @property {number} duration - How long (in frames) the emitter runs. + * @property {number} size - Base particle diameter before scale is applied. + * @property {number} sizeRandom - Random variance added to each particle's size (larger + * than hurt to produce a softer, more diffuse bloom). + * @property {number} lifeSpan - Frames each particle lives before fading. + * @property {number} lifeSpanRandom - Random variance added to each particle's lifespan. + * @property {number} speed - Base particle speed (slow drift upward). + * @property {number} speedRandom - Random variance added to each particle's speed. + * @property {number} angle - Emission direction in degrees (0 = straight up). + * @property {number} angleRandom - 180° spread produces full omnidirectional emission. + * @property {number} emissionRate - Very high rate creates a dense initial burst. + * @property {number[]} startColour - RGBA start colour placeholder; overwritten at runtime. + * @property {number[]} endColour - RGBA end colour placeholder; overwritten at runtime. + */ + const DEFAULT_HEAL_FX = { + maxParticles: 150, + duration: 50, + size: 10, + sizeRandom: 15, + lifeSpan: 50, + lifeSpanRandom: 30, + speed: 0.5, + speedRandom: 2, + angle: 0, + angleRandom: 180, + emissionRate: 1000, + startColour: [0, 0, 0, 0], + endColour: [0, 0, 0, 0], + }; + + /** + * Fallback baseline merged into every FX definition by `spawnFX` before spawning. + * This is NOT a Roll20 custfx object — it is a local safety net that ensures + * `spawnFX` never passes `undefined` for a required Roll20 FX field when a custom + * or per-character definition omits optional properties. + * Merge order: `{ ...FX_PARAM_DEFAULTS, ...userDefinition }`, so any property + * present in the real definition takes precedence over these fallbacks. + * + * @property {number} maxParticles - Fallback particle count. + * @property {number} duration - Fallback emitter duration (frames). + * @property {number} size - Fallback particle size. + * @property {number} sizeRandom - Fallback size variance. + * @property {number} lifeSpan - Fallback particle lifespan (frames). + * @property {number} lifeSpanRandom - Fallback lifespan variance. + * @property {number} speed - Fallback particle speed (0 = stationary). + * @property {number} speedRandom - Fallback speed variance. + * @property {number} angle - Fallback emission angle in degrees. + * @property {number} angleRandom - Fallback angular spread. + * @property {number} emissionRate - Fallback particles emitted per frame. + * @property {number[]} startColour - Fallback RGBA start colour (British spelling; opaque grey). + * @property {number[]} startColor - Fallback RGBA start color (American spelling; same value). + * @property {number[]} endColour - Fallback RGBA end colour (British spelling; opaque black). + * @property {number[]} endColor - Fallback RGBA end color (American spelling; same value). + * @property {number[]} startColourRandom - Fallback start colour randomization (zeroed). + * @property {number[]} startColorRandom - Fallback start color randomization (zeroed). + * @property {number[]} endColourRandom - Fallback end colour randomization (zeroed). + * @property {number[]} endColorRandom - Fallback end color randomization (zeroed). + * @property {{x:number,y:number}} gravity - Fallback gravity (none). + */ + const FX_PARAM_DEFAULTS = { + maxParticles: 100, + duration: 100, + size: 15, + sizeRandom: 5, + lifeSpan: 50, + lifeSpanRandom: 20, + speed: 1, + speedRandom: 1, + angle: 0, + angleRandom: 0, + emissionRate: 10, + startColour: [128, 128, 128, 1], + startColor: [128, 128, 128, 1], + endColour: [0, 0, 0, 1], + endColor: [0, 0, 0, 1], + startColourRandom: [0, 0, 0, 0], + startColorRandom: [0, 0, 0, 0], + endColourRandom: [0, 0, 0, 0], + endColorRandom: [0, 0, 0, 0], + gravity: { x: 0, y: 0 }, + }; + + // ————— UTILITIES ————— + /** + * Converts a health percentage (0–100+) to a hex color using the active palette. + * Values above 100% return blue; 0% uses dead; 1–100 interpolate low→mid→high. + * + * @param {number} pct - Health percentage. + * @returns {string} A 6-digit hex color string, e.g. '#FF0000'. + */ + function percentToHex(pct) { + const normalizedPct = Math.max(0, Number(pct) || 0); + if (normalizedPct > 100) return '#0000FF'; + const paletteName = state?.HealthColors?.colorPalette || 'default'; + const { high, mid, low, dead } = COLOR_PALETTES[paletteName] || COLOR_PALETTES.default; + const rgbToHex = (rgb) => + // eslint-disable-next-line no-bitwise + `#${((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).slice(1)}`; + + if (normalizedPct === 0) { + return rgbToHex(dead); + } + + const t = normalizedPct >= 50 ? (normalizedPct - 50) / 50 : normalizedPct / 50; + const from = normalizedPct >= 50 ? mid : low; + const to = normalizedPct >= 50 ? high : mid; + const r = Math.round(from[0] + (to[0] - from[0]) * t); + const g = Math.round(from[1] + (to[1] - from[1]) * t); + const b = Math.round(from[2] + (to[2] - from[2]) * t); + return rgbToHex([r, g, b]); + } + + /** + * Parses a hex color string into an RGBA array suitable for Roll20 FX definitions. + * Returns [0,0,0,0] when the input is invalid. + * + * @param {string} hex - Hex color string with or without leading '#'. + * @returns {number[]} Array of [r, g, b, a] where a is always 1.0 on success. + */ + function hexToRgb(hex) { + const cleanHex = (hex || '').replace('#', '').trim(); + const parts = /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(cleanHex); + if (parts) { + const rgb = parts.slice(1).map((d) => Number.parseInt(d, 16)); + rgb.push(1); + return rgb; + } + // Log invalid hex attempts if they appear non-empty + if (cleanHex) log(`${SCRIPT_NAME}: hexToRgb received invalid hex: "${hex}"`); + return [0, 0, 0, 0]; + } + + /** + * Returns a random integer between min and max inclusive. + * + * @param {number} min - Lower bound (inclusive). + * @param {number} max - Upper bound (inclusive). + * @returns {number} Random integer in [min, max]. + */ + function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; // NOSONAR — cosmetic FX variance, not security-sensitive + } + + /** + * Creates a plain-object snapshot of a Roll20 API object or any serialisable value. + * Uses JSON round-trip rather than structuredClone so that Roll20 proxy objects have + * their toJSON() method called, producing a plain object whose properties are + * accessible directly (e.g. prev.bar1_value) rather than through .get(). + * + * @param {object} obj - Roll20 API object or plain object to snapshot. + * @returns {object} Plain object deep copy. + */ + function deepClone(obj) { + return JSON.parse(JSON.stringify(obj)); // NOSONAR — intentional: triggers Roll20 proxy toJSON() + } + + /** + * Normalizes a 6-digit hex color string (without '#'). + * Returns fallback when input is invalid. + * + * @param {string} value - Candidate hex string. + * @param {string} fallback - Fallback value when invalid. + * @returns {string} Uppercase 6-digit hex. + */ + function normalizeHex6(value, fallback) { + const cleaned = (value || '').replace('#', '').trim().toUpperCase(); + return /^[0-9A-F]{6}$/.test(cleaned) ? cleaned : fallback; + } + + /** + * Returns true when the value is a 6-digit hex colour, with or without a leading '#'. + * + * @param {string} value - Candidate value. + * @returns {boolean} True for a 6-digit hex colour. + */ + function isHex6(value) { + return /^#?[0-9A-Fa-f]{6}$/.test( + String(value ?? '') + .trim() + .replace('#', ''), + ); + } + + /** + * Returns true when the value is a Roll20 built-in effect name (`-`). + * + * @param {string} value - Candidate value (case-insensitive). + * @returns {boolean} True for a known built-in effect name. + */ + function isBuiltinFxName(value) { + return BUILTIN_FX_PATTERN.test( + String(value ?? '') + .trim() + .toLowerCase(), + ); + } + + /** + * Returns true when a per-character FX override disables FX (`OFF` or `NO`, any case). + * + * @param {string|undefined} value - Attribute value. + * @returns {boolean} True when FX are switched off for the character. + */ + function isFxOff(value) { + return /^(OFF|NO)$/i.test(String(value ?? '').trim()); + } + + /** + * Finds a campaign custom FX object by name (case-insensitive) or by object id. + * + * @param {string} nameOrId - Custom FX name or Roll20 object id. + * @returns {object|undefined} The custfx object, or undefined when none matches. + */ + function findCustomFx(nameOrId) { + const key = String(nameOrId ?? '').trim(); + if (!key) return undefined; + const byName = findObjs({ _type: 'custfx', name: key }, { caseInsensitive: true })[0]; + if (byName) return byName; + return getObj('custfx', key) || undefined; + } + + /** + * Classifies a heal/hurt FX value (global setting or per-character override entry). + * + * @param {string} value - Hex colour, built-in effect name, or custom FX name/id. + * @returns {{kind:'colour',hex:string}|{kind:'builtin',name:string}|{kind:'custom',name:string,fxObj:object}|null} + * The classified choice, or null when the value is blank or names nothing known. + */ + function classifyFxValue(value) { + const raw = String(value ?? '').trim(); + if (!raw) return null; + if (isHex6(raw)) return { kind: 'colour', hex: raw.replace('#', '').toUpperCase() }; + if (isBuiltinFxName(raw)) return { kind: 'builtin', name: raw.toLowerCase() }; + const fxObj = findCustomFx(raw); + if (fxObj) return { kind: 'custom', name: String(fxObj.get('name') || raw).trim(), fxObj }; + return null; + } + + /** + * Normalizes a `!aura heal` / `!aura hurt` argument. Accepts a hex colour, a Roll20 + * built-in effect name, or a custom FX name/id (stored by its canonical name). + * `default` restores the shipped colour. Anything else keeps the fallback and warns the GM. + * + * @param {string} value - Raw command argument. + * @param {string} fallback - Current setting, kept when the input is invalid. + * @param {string} label - 'heal' or 'hurt' (for messages). + * @param {string} defaultValue - Shipped default for this setting. + * @returns {string} The normalized setting value. + */ + function normalizeFxSetting(value, fallback, label, defaultValue) { + const raw = String(value ?? '').trim(); + if (!raw) return fallback; + if (raw.toUpperCase() === 'DEFAULT') return defaultValue; + const choice = classifyFxValue(raw); + if (!choice) { + gmWhisper( + `⚠ Unknown ${label} FX "${escapeForChat(raw)}". Use a hex colour (e.g. FDDC5C), a built-in effect such as glow-holy, or a custom FX name/id from !aura listfx. Keeping "${escapeForChat(fallback)}".`, + ); + return fallback; + } + return choice.kind === 'colour' ? choice.hex : choice.name; + } + + // ————— AURA SHAPES ————— + /** + * Roll20 aura shapes (Token Settings → Aura → Shape), keyed by display label, mapped to the + * `aura1_options` / `aura2_options` API value. Roll20 keeps `aura1_square` in sync itself. + */ + const AURA_SHAPES = Object.freeze({ + Circle: 'circle', + Square: 'square', + Hex: 'hex', + 'Circle Border': 'circleborder', + 'Square Border': 'squareborder', + 'Hex Border': 'hexborder', + }); + + /** + * HealthColors-only Aura 1 choices: Auto picks hex on hex-grid pages and circle otherwise + * (Auto Border uses the outline versions); Keep never touches the token's shape. + */ + const AURA1_SPECIAL_SHAPES = Object.freeze(['Auto', 'Auto Border', 'Keep']); + + /** Roll-query option list for the Aura 1 shape prompt. */ + const AURA1_SHAPE_CHOICES = [...AURA1_SPECIAL_SHAPES, ...Object.keys(AURA_SHAPES)].join('|'); + + /** Roll-query option list for the Aura 2 (reference-only) shape prompt. */ + const AURA2_SHAPE_CHOICES = Object.keys(AURA_SHAPES).join('|'); + + /** + * Normalizes an aura shape to its display label. Accepts the label, the API value or any + * spacing/punctuation variant ("Circle Border", "circleborder", "circle-border", "circle_border"). + * + * @param {string} value - Candidate shape value. + * @param {string} fallback - Fallback shape. + * @param {boolean} [allowSpecial] - Also accept Auto, Auto Border and Keep (Aura 1 only). + * @returns {string} A display label from AURA_SHAPES (or AURA1_SPECIAL_SHAPES). + */ + function normalizeShape(value, fallback, allowSpecial = true) { + const squash = (v) => + String(v ?? '') + .toLowerCase() + .replace(/[^a-z]/g, ''); + const wanted = squash(value); + if (!wanted) return fallback; + const labels = allowSpecial ? [...AURA1_SPECIAL_SHAPES, ...Object.keys(AURA_SHAPES)] : Object.keys(AURA_SHAPES); + return labels.find((label) => squash(label) === wanted) || fallback; + } + + /** + * Resolves the configured Aura 1 shape for a token into a Roll20 `aura1_options` value. + * Auto / Auto Border look at the token's page grid (`hex` or `hexr` → hex shapes). Roll20 draws + * hex auras in whole grid cells, so a radius under one cell (e.g. the default 0.35) would show + * almost nothing; Auto then uses the circle shapes instead. + * + * @param {object} obj - Roll20 token graphic object. + * @returns {string|null} API shape value, or null when the shape should be left alone (Keep). + */ + function resolveAura1Shape(obj) { + const label = normalizeShape(state.HealthColors.Aura1Shape, DEFAULTS.Aura1Shape); + if (label === 'Keep') return null; + if (label === 'Auto' || label === 'Auto Border') { + const page = getObj('page', obj.get('_pageid')); + const gridType = String(page?.get('grid_type') || '').toLowerCase(); + const cellUnits = (Number(page?.get('scale_number')) || 5) * (Number(page?.get('snapping_increment')) || 1); + const fillsACell = Number(state.HealthColors.AuraSize) >= cellUnits; + const base = gridType.startsWith('hex') && fillsACell ? 'hex' : 'circle'; + return label === 'Auto Border' ? `${base}border` : base; + } + return AURA_SHAPES[label] || 'circle'; + } + + /** + * Applies the configured Aura 1 opacity to a `#rrggbb` colour by appending a two-digit + * alpha channel (Roll20 accepts `#rrggbbaa` for auras). 100% returns the colour unchanged. + * + * @param {string} hex - Colour in `#rrggbb` form. + * @returns {string} `#rrggbb` or `#rrggbbaa`. + */ + function withAuraOpacity(hex) { + const pct = normalizePercent(state.HealthColors.auraOpacity, 100); + if (pct >= 100 || !/^#[0-9a-f]{6}$/i.test(hex)) return hex; + const alpha = Math.round((pct / 100) * 255) + .toString(16) + .padStart(2, '0'); + return `${hex}${alpha}`; + } + + /** + * Normalizes a palette name to one of the supported keys. + * + * @param {string} value - Candidate palette key. + * @param {string} fallback - Fallback palette key when invalid. + * @returns {string} A valid palette key from COLOR_PALETTES. + */ + function normalizePalette(value, fallback) { + const p = (value || '').trim().toLowerCase(); + return COLOR_PALETTES[p] ? p : fallback; + } + + /** + * Normalizes a percentage setting to an integer between 0 and 100. + * + * @param {string|number} value - Candidate percentage. + * @param {number} fallback - Fallback percentage when invalid. + * @returns {number} A valid percentage value. + */ + function normalizePercent(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100 ? parsed : fallback; + } + + /** + * Normalizes a positive numeric setting. + * + * @param {string|number} value - Candidate numeric value. + * @param {number} fallback - Fallback value when invalid. + * @returns {number} A valid non-negative number. + */ + function normalizePositiveNumber(value, fallback) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + } + + /** + * Normalizes a Yes/No/Off style setting. + * + * @param {string} value - Candidate setting value. + * @param {string} fallback - Fallback value when invalid. + * @returns {string} One of Yes, No, or Off. + */ + function normalizeYesNoOff(value, fallback) { + const normalized = (value || '').trim().toUpperCase(); + if (normalized === 'YES') return 'Yes'; + if (normalized === 'NO') return 'No'; + if (normalized === 'OFF') return 'Off'; + return fallback; + } + + /** + * Normalizes a death sound track name. + * + * @param {string} value - Candidate track name. + * @param {string} fallback - Fallback track name when invalid. + * @returns {string} A trimmed track name or None. + */ + function normalizeTrackName(value, fallback) { + const normalized = (value || '').trim(); + if (!normalized) return fallback; + return normalized.toUpperCase() === 'NONE' ? 'None' : normalized; + } + + /** + * Normalizes a Roll20 status-marker tag. Trims and strips a leading 'status_' prefix + * but PRESERVES case and any `::id` suffix — custom/library markers (e.g. the D&D + * "Unconscious" condition) carry exact tags like `Unconscious::1234567` that must not + * be lowercased, or the marker won't render. An empty value is allowed and means the + * marker channel is disabled. + * + * @param {string} value - Candidate marker tag (e.g. 'skull', 'status_green', 'Unconscious::123'). + * @param {string} fallback - Fallback marker tag when value is omitted. + * @returns {string} A bare marker tag suitable for `status_`, or empty when disabled. + */ + function normalizeMarkerName(value, fallback) { + if (value === undefined || value === null) return fallback; + return String(value) + .trim() + .replace(/^status_/i, ''); + } + + /** + * Escapes text for safe display in a chat whisper. Converts HTML-significant and + * Roll20-parser-significant characters to HTML entities so arbitrary sheet content + * (attribute names/values that contain `@{}`, `%{}`, `[[ ]]`, `?{}`, `#macro`, etc.) + * is shown literally instead of being executed — which would emit "Unable to find + * ability …" / roll SyntaxError noise. Use for any sheet/marker-derived debug output. + * + * @param {string|number|undefined} text - Raw text to display. + * @returns {string} Entity-escaped, display-safe text. + */ + function escapeForChat(text) { + const entities = { + '&': '&', + '<': '<', + '>': '>', + '[': '[', + ']': ']', + '{': '{', + '}': '}', + '@': '@', + '%': '%', + '?': '?', + '#': '#', + '|': '|', + '`': '`', + '~': '~', + }; + // Single pass: each source char is replaced once, so the inserted entities (which + // themselves contain '&' and '#') are not re-escaped. + return String(text ?? '').replace(/[&<>[\]{}@%?#|`~]/g, (ch) => entities[ch]); + } + + /** + * Splits a death-save attribute setting into individual attribute names. + * + * @param {string} config - Single name or comma-separated list. + * @returns {string[]} Trimmed, non-empty attribute names. + */ + function parseAttrList(config) { + return String(config || '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + + /** + * Normalizes a death-save attribute setting (single name or comma-separated list), + * trimming whitespace and dropping empty entries. Returns fallback when blank. + * + * @param {string} value - Candidate attribute name or list. + * @param {string} fallback - Fallback when blank. + * @returns {string} Cleaned, comma-joined setting or the fallback. + */ + function normalizeAttrName(value, fallback) { + return parseAttrList(value).join(',') || fallback; + } + + /** + * Builds likely alias field names for a watched death-save attribute. + * This helps bridge naming differences across sheet versions (e.g. succ/success). + * + * @param {string} attrName - Raw configured attribute name. + * @returns {string[]} Lowercase unique candidate names. + */ + function getDeathSaveAttrAliases(attrName) { + const base = String(attrName || '') + .trim() + .toLowerCase(); + if (!base) return []; + + const variants = new Set([base]); + const swapPairs = [ + ['succ', 'success'], + ['success', 'succ'], + ['fail', 'failure'], + ['failure', 'fail'], + ]; + + for (const [fromWord, toWord] of swapPairs) { + if (base.includes(fromWord)) variants.add(base.replace(fromWord, toWord)); + } + + return [...variants].filter(Boolean); + } + + /** + * Returns the expanded unique probe field set for watched attributes. + * + * @param {string[]} watched - Configured watched attribute names. + * @returns {string[]} Watched names plus alias candidates. + */ + function getDeathSaveProbeFieldSet(watched) { + return [ + ...new Set( + watched + .flatMap((name) => getDeathSaveAttrAliases(name)) + .map((name) => + String(name || '') + .trim() + .toLowerCase(), + ) + .filter(Boolean), + ), + ]; + } + + /** + * Returns the probe candidates for configured fields that did not resolve from + * legacy attribute objects. When any alias of a configured field already resolved, + * every alias of that field is skipped. The game-wide field-name status cache then + * narrows the rest: names known 'bad' are never asked for (both getSheetItem and + * chat probes make Roll20 log an uncatchable sandbox error per unknown name), and + * once any alias of a field is known 'ok', only the known-good aliases are used. + * + * @param {string[]} watched - Configured watched attribute names. + * @param {object} snapshot - Snapshot map built from legacy attribute objects. + * @param {object} status - Game-wide field-name status map ('ok'|'bad'). + * @returns {string[]} Unique alias names still requiring fallback resolution. + */ + function getDeathSaveMissingProbeFields(watched, snapshot, status) { + const missing = []; + for (const name of watched) { + const aliases = getDeathSaveAttrAliases(name); + if (aliases.some((alias) => snapshot?.[alias]?.exists)) continue; + const usable = aliases.filter((alias) => status?.[alias] !== 'bad'); + const known = usable.filter((alias) => status?.[alias] === 'ok'); + missing.push(...(known.length ? known : usable)); + } + return [...new Set(missing)]; + } + + /** + * Resolves the best available snapshot entry for a configured watched field. + * Prefers checked/truthy values when multiple alias candidates exist. + * + * @param {object} snapshot - Snapshot map keyed by watched/probed names. + * @param {string} attrName - Configured watched field name. + * @returns {{exists:boolean, value:string, source:string}} Resolved entry. + */ + function resolveDeathSaveSnapshotEntry(snapshot, attrName) { + const candidates = getDeathSaveAttrAliases(attrName); + let first = null; + + for (const candidate of candidates) { + const entry = snapshot?.[candidate]; + if (!entry?.exists) continue; + const value = String(entry.value ?? ''); + if (!first) first = { exists: true, value, source: candidate }; + if (isTruthyAttr(value)) return { exists: true, value, source: candidate }; + } + + return ( + first || { + exists: false, + value: '', + source: String(attrName || '') + .trim() + .toLowerCase(), + } + ); + } + + /** + * Interprets a single resolved death-save value as a ticked checkbox. Strict on + * purpose: only the known "checked" values count (the 2024 sheet returns 0/1, the + * 2014 sheet returns 0/"on"), plus any positive number. Crucially, anything that + * looks like an UNRESOLVED `@{}` reference (or other junk) counts as NOT ticked, so a + * field that fails to resolve can't inflate the count and wrongly trigger "dead". + * + * @param {string|number|undefined} value - Resolved attribute value. + * @returns {boolean} True only when the box is genuinely ticked. + */ + function isTruthyAttr(value) { + const v = String(value ?? '') + .trim() + .toLowerCase(); + if (v === '' || v.includes('@{') || v.includes('}')) return false; + if (['1', 'on', 'true', 'yes', 'checked'].includes(v)) return true; + const n = Number.parseFloat(v); + return Number.isFinite(n) && n > 0; + } + + /** + * Counts death saves from a comma-separated, already-resolved value list. A single + * numeric value is treated as a counter (e.g. "2" → 2); otherwise each entry is a + * checkbox and the number of genuinely-ticked entries is returned. Unresolved `@{}` + * references and junk never count. + * + * @param {string} csv - Comma-separated resolved values from the chat parser. + * @returns {number} The death-save count. + */ + function countDeathSaves(csv) { + const values = String(csv || '') + .split(',') + .map((v) => v.trim()); + if (values.length === 1) { + const n = Number.parseInt(values[0], 10); + if (Number.isFinite(n)) return Math.max(0, n); + } + return values.reduce((acc, v) => acc + (isTruthyAttr(v) ? 1 : 0), 0); + } + + // ————— ONE-CLICK OPTIONS (globalconfig) ————— + /** + * Parses a One-Click checkbox/boolean option. Roll20 delivers a ticked checkbox as the + * option's `value` string ('true' here) and an unticked one as false/'false'. + * + * @param {*} value - Raw option value. + * @param {boolean} fallback - Current setting, kept when the value is unrecognised. + * @returns {boolean} Parsed boolean. + */ + function parseBooleanOption(value, fallback) { + if (typeof value === 'boolean') return value; + if (value === undefined || value === null) return fallback; + const normalized = String(value).trim().toLowerCase(); + if (['true', '1', 'checked', 'on', 'yes'].includes(normalized)) return true; + if (['false', '0', '', 'off', 'no'].includes(normalized)) return false; + return fallback; + } + + /** + * Normalizes a One-Click health-bar option ('bar1' | 'bar2' | 'bar3' | 'bar4', any case). + * bar4 needs the game's "Maximum Token Bubbles/Bars" setting raised to 4. + * + * @param {string} value - Raw option value. + * @param {string} fallback - Current setting. + * @returns {string} A valid bar key. + */ + function normalizeBarKey(value, fallback) { + const bar = String(value ?? '') + .trim() + .toLowerCase(); + return /^bar[1-4]$/.test(bar) ? bar : fallback; + } + + /** + * One-Click `useroptions` → state normalizers, keyed by DEFAULTS key (the option `name` + * in script.json is the same key). Each takes (rawValue, currentSetting) and returns + * the value to store, so invalid input keeps the current setting. + */ + const ONE_CLICK_NORMALIZERS = { + auraColorOn: parseBooleanOption, + auraBar: normalizeBarKey, + auraTint: parseBooleanOption, + auraPercPC: normalizePercent, + auraPerc: normalizePercent, + PCAura: parseBooleanOption, + NPCAura: parseBooleanOption, + auraDeadPC: parseBooleanOption, + auraDead: parseBooleanOption, + GM_PCNames: normalizeYesNoOff, + PCNames: normalizeYesNoOff, + GM_NPCNames: normalizeYesNoOff, + NPCNames: normalizeYesNoOff, + AuraSize: normalizePositiveNumber, + Aura1Shape: normalizeShape, + auraOpacity: (value, current) => { + const pct = normalizePercent(value, current); + return pct >= 1 ? pct : current; + }, + auraLockPlayers: parseBooleanOption, + Aura1Color: normalizeHex6, + Aura2Size: normalizePositiveNumber, + Aura2Shape: (value, current) => normalizeShape(value, current, false), + Aura2Color: normalizeHex6, + OneOff: parseBooleanOption, + FX: parseBooleanOption, + HealFX: (value, current) => normalizeFxSetting(value, current, 'heal', DEFAULTS.HealFX), + HurtFX: (value, current) => normalizeFxSetting(value, current, 'hurt', DEFAULTS.HurtFX), + auraDeadFX: normalizeTrackName, + colorPalette: normalizePalette, + deathSavesOn: parseBooleanOption, + dsSuccessAttr: normalizeAttrName, + dsFailureAttr: normalizeAttrName, + dsDyingMarker: normalizeMarkerName, + dsStableMarker: normalizeMarkerName, + }; + + /** + * Returns this script's One-Click options branch from Roll20's `globalconfig`, or null. + * Roll20 keys the branch by the script.json name (lower-cased, punctuation varies), so the + * lookup accepts any key that normalizes to a name ending in "healthcolors". + * + * @returns {object|null} The options object (includes `lastsaved`), or null when absent. + */ + function getOneClickOptions() { + if (typeof globalconfig !== 'object' || globalconfig === null) return null; + const normalizeKey = (key) => + String(key) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + const key = Object.keys(globalconfig).find((k) => normalizeKey(k).endsWith('healthcolors')); + const branch = key ? globalconfig[key] : null; + return branch && typeof branch === 'object' ? branch : null; + } + + /** + * Applies One-Click install options to state when they are newer than the last applied + * set (`globalconfig.