diff --git a/Concentration/1.0.0/Concentration.js b/Concentration/1.0.0/Concentration.js new file mode 100644 index 000000000..3e179684d --- /dev/null +++ b/Concentration/1.0.0/Concentration.js @@ -0,0 +1,1911 @@ +// ============================ +// === Concentration v1.0.0 === +// ============================ + +// AUTHORS: +// - Robin Kuiper: https://app.roll20.net/users/1226016/robin +// - Steve Roberts: https://app.roll20.net/users/16506286/midniteshadow7 + +const Concentration = + globalThis.Concentration || + (function () { + 'use strict'; + + let checked = []; + + // Styling for the chat responses. + const styles = { + reset: 'padding: 0; margin: 0;', + menu: 'background-color: #fff; border: 1px solid #000; padding: 5px; border-radius: 5px;', + button: + 'background-color: #000; border: 1px solid #292929; border-radius: 3px; padding: 5px; color: #fff; text-align: center;', + textButton: + 'background-color: transparent; border: none; padding: 0; color: #000; text-decoration: underline', + list: 'list-style: none;', + float: { + right: 'float: right;', + left: 'float: left;', + }, + overflow: 'overflow: hidden;', + fullWidth: 'width: 100%;', + }, + script_name = 'Concentration', + state_name = 'CONCENTRATION', + debug_prefix = '[Concentration v1]', + pending_roll_ttl = 10 * 60 * 1000, + markers = [ + 'blue', + 'brown', + 'green', + 'pink', + 'purple', + 'red', + 'yellow', + '-', + 'all-for-one', + 'angel-outfit', + 'archery-target', + 'arrowed', + 'aura', + 'back-pain', + 'black-flag', + 'bleeding-eye', + 'bolt-shield', + 'broken-heart', + 'broken-shield', + 'broken-skull', + 'chained-heart', + 'chemical-bolt', + 'cobweb', + 'dead', + 'death-zone', + 'drink-me', + 'edge-crack', + 'fishing-net', + 'fist', + 'fluffy-wing', + 'flying-flag', + 'frozen-orb', + 'grab', + 'grenade', + 'half-haze', + 'half-heart', + 'interdiction', + 'lightning-helix', + 'ninja-mask', + 'overdrive', + 'padlock', + 'pummeled', + 'radioactive', + 'rolling-tomb', + 'screaming', + 'sentry-gun', + 'skull', + 'sleepy', + 'snail', + 'spanner', + 'stopwatch', + 'strong', + 'three-leaves', + 'tread', + 'trophy', + 'white-tower', + ], + allowed_reminder_targets = new Set(['everyone', 'character', 'gm']), + allowed_support_modes = new Set(['basic', 'detailed']), + /** + * @typedef {Object} ConcentrationConfig + * @property {string} command API command name without leading !. + * @property {string} statusmarker Roll20 token marker used for concentration. + * @property {1|2|3} bar HP bar index used to detect damage. + * @property {'everyone'|'character'|'gm'} send_reminder_to Reminder target scope. + * @property {boolean} auto_add_concentration_marker Auto-detect and set marker from supported spell cards. + * @property {boolean} auto_roll_save Auto-roll concentration saves on HP loss. + * @property {boolean} advantage Unused legacy config key preserved for compatibility. + * @property {string} bonus_attribute Character attribute used as concentration save modifier. + * @property {boolean} show_roll_button Show manual roll buttons when auto-roll is disabled. + * @property {boolean} debug Enable debug logs. + * @property {'basic'|'detailed'} support_mode Debug output detail level. + */ + /** + * @typedef {Object} SpellCast + * @property {'legacy'|'beacon'} sheetType Source sheet parser that detected the cast. + * @property {string|null} characterName Caster character name when available. + * @property {string|null} characterId Caster character id when available. + * @property {string} spellName Spell name. + * @property {boolean} isConcentration Whether the detected spell requires concentration. + */ + /** + * @typedef {Object} ResolvedCharacter + * @property {Object|null} character Roll20 character object if resolved. + * @property {string|null} characterId Resolved character id. + * @property {string|null} characterName Resolved character name. + * @property {string|null} warning Resolution warning details. + */ + /** + * @typedef {Object} PendingRoll + * @property {string|null} represents Character id represented by the token. + * @property {string|null} tokenId Token id that triggered the check. + * @property {number} DC Concentration check difficulty class. + * @property {number} conSaveMod Concentration modifier. + * @property {string} name Display name for prompts. + * @property {string} target Chat whisper target. + * @property {number} [createdAt] Creation timestamp in milliseconds. + */ + validateCommandName = (value) => { + if (typeof value !== 'string') { + return false; + } + + return /^[A-Za-z0-9_-]{1,32}$/.test(value.trim()); + }, + isValidRoll20Id = (value) => { + if (typeof value !== 'string') { + return false; + } + + return /^[-A-Za-z0-9_]+$/.test(value.trim()); + }, + isValidPendingRollId = (value) => { + if (typeof value !== 'string') { + return false; + } + + return /^pr_\d+_[a-z0-9]+$/.test(value.trim()); + }, + truncateText = (value, maxLength) => { + if (value === null || value === undefined) { + return value; + } + + let text = String(value); + + return text.length > maxLength + ? text.slice(0, maxLength) + '...' + : text; + }, + cleanDebugText = (value, maxLength = 240) => { + if (value === null || value === undefined) { + return null; + } + + let text = String(value) + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\s+/g, ' ') + .trim(); + + return truncateText(text || '(empty)', maxLength); + }, + formatDebugValue = (value) => { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + + if (typeof value === 'string') { + return cleanDebugText(value, 200); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (Array.isArray(value)) { + let simplified = value.map((item) => formatDebugValue(item)); + return truncateText(simplified.join(', '), 200); + } + + return '[details]'; + }, + humanizeDebugText = (value) => { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + + return String(value).replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); + }, + debugFieldLabels = { + source: 'Detection Path', + messageType: 'Message Type', + template: 'Roll Template', + spellName: 'Spell', + caster: 'Caster', + status: 'Result', + preview: 'Message Preview', + reason: 'Reason', + warning: 'Warning', + characterId: 'Character ID', + characterName: 'Character Name', + attribute: 'Attribute', + value: 'Value', + error: 'Error', + pageId: 'Page ID', + scope: 'Scope', + represents: 'Represents', + tokenId: 'Token ID', + pendingRollId: 'Pending Roll ID', + }, + getDebugFieldLabel = (key) => { + return debugFieldLabels[key] || humanizeDebugText(key); + }, + normalizeDetectionPath = (value) => { + if (value === 'legacy') { + return 'Legacy sheet'; + } + + if (value === 'beacon') { + return 'Beacon sheet'; + } + + return 'No match'; + }, + normalizeDetectionStatus = (value) => { + if (!value || value === 'n/a') { + return 'n/a'; + } + + if (value === 'detected') { + return 'Concentration spell detected'; + } + + return 'Skipped: ' + humanizeDebugText(value); + }, + detectionReasonLabels = { + 'not-legacy-spell-rolltemplate': 'Not a legacy spell card message', + 'legacy-spell-not-concentration': + 'Legacy spell card found, but concentration was not marked', + 'legacy-spell-missing-character-or-spell': + 'Legacy spell card was missing character or spell details', + 'legacy-spell-detected': 'Legacy spell card already matched', + 'not-beacon-advancedroll': 'Not a Beacon advanced roll message', + 'advancedroll-does-not-look-like-spell-card': + 'Advanced roll did not look like a spell card', + 'beacon-spell-not-concentration': + 'Beacon spell card found, but concentration was not marked', + 'beacon-spell-missing-spell-name': + 'Beacon spell card was missing spell name', + 'beacon-spell-missing-character': + 'Beacon spell card was missing caster details', + }, + normalizeDetectionReason = (value) => { + if (!value || value === 'n/a') { + return 'n/a'; + } + + let reasonText = String(value); + let reasons = reasonText + .split(';') + .map((reason) => reason.trim()) + .filter(Boolean) + .map((reason) => { + return detectionReasonLabels[reason] || humanizeDebugText(reason); + }); + + return reasons.join('; '); + }, + shouldLogSpellDetection = (msg, spellCast) => { + if (spellCast) { + return true; + } + + // Keep detection debug readable by skipping unrelated chat traffic. + if (msg?.rolltemplate === 'spell') { + return true; + } + + return msg?.type === 'advancedroll'; + }, + normalizeDebugValueByKey = (key, value) => { + if (key === 'source') { + return normalizeDetectionPath(value); + } + + if (key === 'status') { + return normalizeDetectionStatus(value); + } + + if (key === 'reason') { + return normalizeDetectionReason(value); + } + + if (key === 'warning') { + return humanizeDebugText(value); + } + + return value; + }, + formatDebugPayload = (data) => { + if (data === undefined) { + return ''; + } + + if (typeof data === 'string') { + return cleanDebugText(data, 240); + } + + if (data && typeof data === 'object') { + let entries = Object.keys(data).map((key) => { + let value = normalizeDebugValueByKey(key, data[key]); + return getDebugFieldLabel(key) + ': ' + formatDebugValue(value); + }); + + return entries.join(' | '); + } + + return String(data); + }, + getContentPreview = (content) => { + return cleanDebugText(content, 160); + }, + getConfig = () => { + return state[state_name]?.config || null; + }, + getSupportMode = () => { + let mode = getConfig()?.support_mode; + + return mode === 'detailed' ? 'detailed' : 'basic'; + }, + formatBasicSpellDetection = (data) => { + if (data.status === 'detected') { + return ( + 'Concentration spell detected' + + (data.spellName + ? ' | Spell: ' + formatDebugValue(data.spellName) + : '') + + (data.caster ? ' | Caster: ' + formatDebugValue(data.caster) : '') + + (data.source + ? ' | Path: ' + + formatDebugValue(normalizeDetectionPath(data.source)) + : '') + ); + } + + return ( + 'Skipped: ' + formatDebugValue(normalizeDetectionReason(data.status)) + ); + }, + getBasicDebugKeys = (data) => { + let preferredKeys = [ + 'reason', + 'warning', + 'error', + 'spellName', + 'caster', + 'characterName', + 'attribute', + 'value', + ]; + let keys = preferredKeys.filter((key) => Object.hasOwn(data, key)); + + if (!keys.length) { + keys = Object.keys(data).slice(0, 2); + } + + return keys; + }, + formatBasicDebugObject = (data) => { + return getBasicDebugKeys(data) + .map((key) => { + let value = normalizeDebugValueByKey(key, data[key]); + return getDebugFieldLabel(key) + ': ' + formatDebugValue(value); + }) + .join(' | '); + }, + formatBasicDebugPayload = (label, data) => { + if (data === undefined) { + return ''; + } + + if (typeof data === 'string') { + return cleanDebugText(data, 140); + } + + if (!data || typeof data !== 'object') { + return String(data); + } + + if (label === 'Spell detection') { + return formatBasicSpellDetection(data); + } + + return formatBasicDebugObject(data); + }, + debugLog = (label, data) => { + let config = getConfig(); + let supportMode = getSupportMode(); + + if (!config?.debug) { + return; + } + + let payload = ''; + + if (data !== undefined) { + try { + payload = + supportMode === 'detailed' + ? formatDebugPayload(data) + : formatBasicDebugPayload(label, data); + } catch (error) { + log( + debug_prefix + + ' debugLog stringify error: ' + + (error?.message || String(error)), + ); + payload = cleanDebugText(String(data), 240); + } + } + + log(debug_prefix + ' ' + label + (payload ? ': ' + payload : '')); + }, + decodeEntities = (value) => { + if (!value) { + return value; + } + + const entities = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + }; + + return value.replace( + /(&|<|>|"|')/g, + (match) => entities[match] || match, + ); + }, + getFirstMatch = (content, patterns) => { + if (!content) { + return null; + } + + for (const pattern of patterns) { + let match = content.match(pattern); + if (match?.[1]) { + return decodeEntities(match[1].trim()); + } + } + + return null; + }, + getConcentrationTrackingKey = (obj) => { + if (!obj || typeof obj.get !== 'function') { + return null; + } + + return obj.get('represents') || obj.get('id') || null; + }, + getTokenDisplayName = (token, fallbackName) => { + if (!token || typeof token.get !== 'function') { + return fallbackName || 'This token'; + } + + return token.get('name') || fallbackName || 'This token'; + }, + hasStatusMarker = (statusmarkers, marker) => { + if (!statusmarkers) { + return false; + } + + return new RegExp('(?:^|,)' + marker + '(?:@[^,]+)?(?:,|$)').test( + statusmarkers, + ); + }, + cleanupPendingRolls = () => { + if (!state[state_name].pendingRolls) { + state[state_name].pendingRolls = {}; + return; + } + + let now = Date.now(); + + Object.keys(state[state_name].pendingRolls).forEach((id) => { + let pendingRoll = state[state_name].pendingRolls[id]; + + if ( + !pendingRoll?.createdAt || + now - pendingRoll.createdAt > pending_roll_ttl + ) { + delete state[state_name].pendingRolls[id]; + } + }); + }, + toConfigValue = (value) => { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + return value; + }, + sanitizeSpellInput = (value) => { + if (typeof value !== 'string') { + return ''; + } + + return truncateText(value.replace(/[<>]/g, '').trim(), 80); + }, + escapeHtml = (value) => { + if (value === null || value === undefined) { + return ''; + } + + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + }, + validateConfigSetting = (key, value) => { + switch (key) { + case 'command': { + if (!validateCommandName(value)) { + return { + valid: false, + message: + 'Invalid command. Use 1-32 characters: letters, numbers, underscore, or hyphen.', + }; + } + + return { valid: true, value: value.trim() }; + } + + case 'statusmarker': + if (!markers.includes(value)) { + return { valid: false, message: 'Invalid status marker.' }; + } + + return { valid: true, value }; + + case 'bar': { + let bar = Number.parseInt(value, 10); + if (![1, 2, 3].includes(bar)) { + return { + valid: false, + message: 'Invalid HP bar. Choose 1, 2, or 3.', + }; + } + + return { valid: true, value: bar }; + } + + case 'send_reminder_to': + if (!allowed_reminder_targets.has(value)) { + return { + valid: false, + message: + 'Invalid reminder target. Choose everyone, character, or gm.', + }; + } + + return { valid: true, value }; + + case 'support_mode': + if (!allowed_support_modes.has(value)) { + return { + valid: false, + message: 'Invalid support mode. Choose basic or detailed.', + }; + } + + return { valid: true, value }; + + case 'auto_add_concentration_marker': + case 'auto_roll_save': + case 'show_roll_button': + case 'debug': + if (typeof value !== 'boolean') { + return { + valid: false, + message: 'Invalid toggle value. Use true or false.', + }; + } + + return { valid: true, value }; + + case 'bonus_attribute': { + if (typeof value !== 'string' || !value.trim()) { + return { + valid: false, + message: 'Invalid attribute name.', + }; + } + + return { valid: true, value: truncateText(value.trim(), 80) }; + } + + default: + return { + valid: false, + message: 'Unknown config setting.', + }; + } + }, + processSelectedTokens = (msg, playerid, spell) => { + let safeSpell = sanitizeSpellInput(spell); + + if (!msg.selected?.length) { + return false; + } + + msg.selected.forEach((selectedItem) => { + let token = getObj(selectedItem._type, selectedItem._id); + addConcentration(token, playerid, safeSpell); + }); + + return true; + }, + runPendingRoll = (pendingRollId, hasAdvantage) => { + if (!isValidPendingRollId(pendingRollId)) { + debugLog('Pending roll input rejected', { + pendingRollId: pendingRollId || null, + }); + return; + } + + let pendingRoll = consumePendingRoll(pendingRollId); + + if (!pendingRoll) { + debugLog('Pending roll missing or expired', { + pendingRollId: pendingRollId || null, + }); + makeAndSendMenu( + 'That concentration roll button has expired. Trigger the concentration check again to create a new button.', + '', + 'gm', + ); + return; + } + + roll( + pendingRoll.represents, + pendingRoll.DC, + pendingRoll.conSaveMod, + pendingRoll.name, + pendingRoll.target, + hasAdvantage, + pendingRoll.tokenId, + ); + }, + applyDefaultConfig = (target, defaults) => { + Object.keys(defaults).forEach((key) => { + if (!Object.hasOwn(target, key)) { + target[key] = defaults[key]; + } + }); + }, + applyConfigSetting = (args) => { + if (!args.length) { + return 'Missing config setting.'; + } + + let setting = args.shift().split('|'); + let key = (setting.shift() || '').trim(); + let rawValue = setting.join('|'); + let value = toConfigValue(rawValue); + let validation = validateConfigSetting(key, value); + + if (!validation.valid) { + return '' + validation.message + ''; + } + + state[state_name].config[key] = validation.value; + + return key === 'bar' + ? 'The API Library needs to be restarted for this to take effect.' + : null; + }, + toggleAdvantageForCharacter = (characterId) => { + if ( + !isValidRoll20Id(characterId) || + !getObj('character', characterId) + ) { + return; + } + + if (state[state_name].advantages[characterId]) { + state[state_name].advantages[characterId] = + !state[state_name].advantages[characterId]; + } else { + state[state_name].advantages[characterId] = true; + } + }, + handleGmCommand = (extracommand, args, msg) => { + switch (extracommand) { + case 'reset': + state[state_name] = {}; + setDefaults(true); + sendConfigMenu( + false, + 'The API Library needs to be restarted for this to take effect.', + ); + return; + + case 'config': { + let message = applyConfigSetting(args); + sendConfigMenu(false, message); + return; + } + + case 'advantage-menu': + sendAdvantageMenu(); + return; + + case 'toggle-advantage': + toggleAdvantageForCharacter(args[0]); + sendAdvantageMenu(); + return; + + case 'roll': + runPendingRoll(args[0], false); + return; + + case 'advantage': + runPendingRoll(args[0], true); + return; + + default: + if (processSelectedTokens(msg, msg.playerid, extracommand)) { + return; + } + + sendConfigMenu(); + } + }, + canControlToken = (token, character, playerid) => { + let tokenControllers = new Set( + (token.get('controlledby') || '').split(','), + ); + let characterControllers = new Set( + character ? (character.get('controlledby') || '').split(',') : [], + ); + + return ( + tokenControllers.has(playerid) || + tokenControllers.has('all') || + characterControllers.has(playerid) || + characterControllers.has('all') || + playerIsGM(playerid) + ); + }, + resolveReminderTarget = (token, character) => { + let target = state[state_name].config.send_reminder_to; + let characterName = character + ? character.get('name') + : token.get('name'); + let tokenName = getTokenDisplayName(token, characterName); + + if (target === 'character') { + target = characterName ? createWhisperName(characterName) : 'gm'; + } else if (target === 'everyone') { + target = ''; + } + + return { + target, + tokenName, + }; + }, + announceConcentration = (tokenName, spell, target) => { + let safeTokenName = escapeHtml(tokenName || 'Unknown'); + let safeSpell = escapeHtml(spell || ''); + let message = spell + ? '' + + safeTokenName + + ' is now concentrating on ' + + safeSpell + + '.' + : '' + safeTokenName + ' is now concentrating.'; + + makeAndSendMenu(message, '', target); + }, + /** + * Stores a pending concentration roll request and returns an expiring id. + * @param {PendingRoll} pendingRoll Pending roll payload. + * @returns {string} Pending roll id. + */ + createPendingRoll = (pendingRoll) => { + cleanupPendingRolls(); + + let id = + 'pr_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); + + state[state_name].pendingRolls[id] = { + represents: pendingRoll.represents || null, + tokenId: pendingRoll.tokenId || null, + DC: pendingRoll.DC, + conSaveMod: pendingRoll.conSaveMod, + name: pendingRoll.name, + target: pendingRoll.target, + createdAt: Date.now(), + }; + + return id; + }, + /** + * Consumes and removes a pending roll request by id. + * @param {string} id Pending roll id. + * @returns {PendingRoll|null} Pending roll payload if found and valid. + */ + consumePendingRoll = (id) => { + cleanupPendingRolls(); + + if (!id || !state[state_name].pendingRolls[id]) { + return null; + } + + let pendingRoll = state[state_name].pendingRolls[id]; + delete state[state_name].pendingRolls[id]; + + return pendingRoll; + }, + detectLegacySpellCast = (msg) => { + if (msg?.rolltemplate !== 'spell' || !msg?.content) { + return { + spellCast: null, + reason: 'not-legacy-spell-rolltemplate', + }; + } + + if (!msg.content.includes('{{concentration=1}}')) { + return { + spellCast: null, + reason: 'legacy-spell-not-concentration', + }; + } + + let characterName = getFirstMatch(msg.content, [ + /charname=([^\n{}]*[^"\n{}])/i, + ]); + let spellName = getFirstMatch(msg.content, [ + /name=([^\n{}]*[^"\n{}])/i, + ]); + + if (!characterName || !spellName) { + return { + spellCast: null, + reason: 'legacy-spell-missing-character-or-spell', + }; + } + + return { + spellCast: { + sheetType: 'legacy', + characterName, + characterId: null, + spellName, + isConcentration: true, + }, + reason: null, + }; + }, + detectBeaconSpellCast = (msg) => { + if (msg?.type !== 'advancedroll' || !msg?.content) { + return { + spellCast: null, + reason: 'not-beacon-advancedroll', + }; + } + + let looksLikeSpellCard = + /header__title/i.test(msg.content) || + /header__subtitle/i.test(msg.content) || + /meta__character/i.test(msg.content) || + /data-character-name/i.test(msg.content) || + /spell/i.test(msg.content); + + if (!looksLikeSpellCard) { + return { + spellCast: null, + reason: 'advancedroll-does-not-look-like-spell-card', + }; + } + + let isConcentration = [ + /data-chip\s*=\s*["']?concentration["']?/i, + /data-[a-z-]*\s*=\s*["']?concentration["']?/i, + /\bconcentration\b/i, + ].some((pattern) => pattern.test(msg.content)); + + if (!isConcentration) { + return { + spellCast: null, + reason: 'beacon-spell-not-concentration', + }; + } + + let spellName = getFirstMatch(msg.content, [ + /header__title[^>]*>\s*([^<]+?)\s*]*>\s*([^<]+?)\s* { + let legacyDetection = detectLegacySpellCast(msg); + let beaconDetection = legacyDetection.spellCast + ? { spellCast: null, reason: 'legacy-spell-detected' } + : detectBeaconSpellCast(msg); + let spellCast = legacyDetection.spellCast || beaconDetection.spellCast; + let detectionSource = 'none'; + + if (legacyDetection.spellCast) { + detectionSource = 'legacy'; + } else if (beaconDetection.spellCast) { + detectionSource = 'beacon'; + } + + if (shouldLogSpellDetection(msg, spellCast)) { + debugLog('Spell detection', { + source: detectionSource, + messageType: msg?.type || null, + template: msg?.rolltemplate || null, + spellName: spellCast?.spellName || null, + caster: spellCast?.characterName || spellCast?.characterId || null, + status: spellCast + ? 'detected' + : [legacyDetection.reason, beaconDetection.reason] + .filter(Boolean) + .join('; '), + preview: getContentPreview(msg?.content), + }); + } + + return spellCast; + }, + /** + * Resolves a character object from parsed spell cast details. + * @param {SpellCast} spellCast Parsed concentration spell cast details. + * @returns {ResolvedCharacter} Character resolution result. + */ + resolveCharacterFromSpellCast = (spellCast) => { + let warning = null; + let character = null; + + if (spellCast.characterId) { + character = getObj('character', spellCast.characterId); + + if (!character) { + warning = + 'character-id-not-found: ' + + truncateText(spellCast.characterId, 80); + } + } + + if (!character && spellCast.characterName) { + let exactMatches = findObjs({ + _type: 'character', + name: spellCast.characterName, + }); + + if (exactMatches.length > 1) { + warning = + 'duplicate-character-name: ' + + truncateText(spellCast.characterName, 80); + } + + character = exactMatches[0] || null; + } + + return { + character, + characterId: character + ? character.get('id') + : spellCast.characterId || null, + characterName: character + ? character.get('name') + : spellCast.characterName || null, + warning, + }; + }, + getRepresentedTokens = (characterId, player) => { + if (!characterId) { + return []; + } + + let currentPageId = player?.get ? player.get('lastpage') : null; + + if (currentPageId) { + let currentPageTokens = findObjs({ + represents: characterId, + _type: 'graphic', + _pageid: currentPageId, + }); + + if (currentPageTokens.length) { + return currentPageTokens; + } + } + + return findObjs({ + represents: characterId, + _type: 'graphic', + }); + }, + getConcentrationSaveModifier = async (characterId) => { + let attributeName = state[state_name].config.bonus_attribute; + let rawValue = null; + + if (!characterId) { + debugLog('Missing character for save modifier', { + attribute: attributeName, + }); + return 0; + } + + if (typeof getSheetItem === 'function') { + try { + rawValue = await getSheetItem(characterId, attributeName); + } catch (error) { + debugLog('getSheetItem failed', { + characterId, + attribute: attributeName, + error: error?.message || String(error), + }); + } + } + + if ( + (rawValue === null || rawValue === undefined || rawValue === '') && + typeof getAttrByName === 'function' + ) { + try { + rawValue = getAttrByName(characterId, attributeName, 'current'); + } catch (error) { + debugLog('getAttrByName failed', { + characterId, + attribute: attributeName, + error: error?.message || String(error), + }); + } + } + + if (rawValue === null || rawValue === undefined || rawValue === '') { + debugLog('Missing concentration modifier', { + characterId, + attribute: attributeName, + }); + return 0; + } + + let parsedValue = Number.parseInt(rawValue, 10); + + if (Number.isNaN(parsedValue)) { + debugLog('Non-numeric concentration modifier', { + characterId, + attribute: attributeName, + value: rawValue, + }); + return 0; + } + + return parsedValue; + }, + /** + * Runs concentration auto-detection and applies markers when enabled. + * @param {Object} msg Roll20 chat message payload. + */ + processAutoConcentrationDetection = (msg) => { + if (!state[state_name].config.auto_add_concentration_marker) { + return; + } + + let spellCast = detectConcentrationSpellCast(msg); + + if (spellCast?.isConcentration) { + handleConcentrationSpellCast(msg, spellCast); + } + }, + /** + * Parses and validates an API command payload for this script. + * @param {Object} msg Roll20 chat message payload. + * @returns {{args:string[], extracommand:string}|null} Parsed command details, or null when invalid. + */ + parseApiCommandMessage = (msg) => { + if (msg.type !== 'api' || typeof msg.content !== 'string') { + return null; + } + + let input = msg.content.trim(); + + if (!input.startsWith('!')) { + return null; + } + + let args = input.split(/\s+/); + let commandToken = args.shift() || ''; + let command = commandToken.substring(1); + let extracommand = args.shift() || ''; + + if (!validateCommandName(command)) { + return null; + } + + if (command !== state[state_name].config.command) { + return null; + } + + return { + args, + extracommand, + }; + }, + /** + * Dispatches a parsed command to GM or player command handlers. + * @param {Object} msg Roll20 chat message payload. + * @param {{args:string[], extracommand:string}} parsedCommand Parsed command details. + */ + dispatchApiCommand = (msg, parsedCommand) => { + if (playerIsGM(msg.playerid)) { + handleGmCommand(parsedCommand.extracommand, parsedCommand.args, msg); + return; + } + + processSelectedTokens(msg, msg.playerid, parsedCommand.extracommand); + }, + /** + * Handles incoming chat events for auto-detection and explicit API commands. + * @param {Object} msg Roll20 chat:message payload. + */ + handleInput = (msg) => { + processAutoConcentrationDetection(msg); + + let parsedCommand = parseApiCommandMessage(msg); + + if (!parsedCommand) { + return; + } + + dispatchApiCommand(msg, parsedCommand); + }, + addConcentration = (token, playerid, spell) => { + if (!token) { + return; + } + + const marker = state[state_name].config.statusmarker; + let characterId = token.get('represents'); + let character = characterId ? getObj('character', characterId) : null; + + if (!canControlToken(token, character, playerid)) { + return; + } + + if (!token.get('status_' + marker)) { + let reminder = resolveReminderTarget(token, character); + announceConcentration(reminder.tokenName, spell, reminder.target); + } + + token.set('status_' + marker, !token.get('status_' + marker)); + }, + /** + * Applies concentration markers from an auto-detected spell cast. + * @param {Object} msg Roll20 chat message payload. + * @param {SpellCast} spellCast Parsed concentration spell cast details. + */ + handleConcentrationSpellCast = (msg, spellCast) => { + const marker = state[state_name].config.statusmarker; + let player = getObj('player', msg.playerid); + let resolvedCharacter = resolveCharacterFromSpellCast(spellCast); + let characterId = resolvedCharacter.characterId; + let characterName = + resolvedCharacter.characterName || + spellCast.characterName || + 'Unknown'; + let representedTokens = getRepresentedTokens(characterId, player); + let message; + let target = state[state_name].config.send_reminder_to; + + if (resolvedCharacter.warning) { + debugLog('Character resolution warning', { + warning: resolvedCharacter.warning, + characterName, + characterId, + }); + } + + if (!player || !characterId) { + let abortReason = player ? 'unresolved-character' : 'missing-player'; + debugLog('Spell cast aborted', { + reason: abortReason, + characterName, + characterId, + spellName: spellCast.spellName, + }); + return; + } + + let searchAttributes = { + represents: characterId, + _type: 'graphic', + _pageid: player.get('lastpage'), + }; + searchAttributes['status_' + marker] = true; + + let isConcentrating = findObjs(searchAttributes).length > 0; + + if (isConcentrating) { + message = + '' + + escapeHtml(characterName) + + ' is concentrating already.'; + } else { + if (!representedTokens.length) { + debugLog('No represented tokens found for spell cast', { + characterName, + characterId, + spellName: spellCast.spellName, + pageId: player.get('lastpage'), + }); + return; + } + + representedTokens.forEach((token) => { + let attributes = {}; + attributes['status_' + marker] = true; + token.set(attributes); + }); + + message = + '' + + escapeHtml(characterName) + + ' is now concentrating on ' + + escapeHtml(spellCast.spellName) + + '.'; + } + + if (target === 'character') { + target = createWhisperName(characterName); + } else if (target === 'everyone') { + target = ''; + } + + makeAndSendMenu(message, '', target); + }, + handleStatusMarkerChange = (obj, prev) => { + const marker = state[state_name].config.statusmarker; + let markerWasSet = prev && hasStatusMarker(prev.statusmarkers, marker); + + if (markerWasSet && !obj.get('status_' + marker)) { + removeMarker(obj.get('represents'), 'graphic', obj); + } + }, + /** + * Checks whether a token update represents concentration-relevant HP loss. + * @param {Object} obj Roll20 token graphic object after change. + * @param {Object} prev Previous token state snapshot. + * @param {string} marker Configured concentration status marker. + * @param {string} bar Token bar property key being tracked. + * @returns {boolean} True when concentration checks should run. + */ + isConcentrationDamageEvent = (obj, prev, marker, bar) => { + return !!( + prev && + obj.get('status_' + marker) && + obj.get(bar) < prev[bar] + ); + }, + /** + * Builds reminder text and resolved whisper target for concentration checks. + * @param {string} tokenName Display token name. + * @param {number} DC Concentration check DC. + * @param {'everyone'|'character'|'gm'|string} target Configured reminder target. + * @returns {{chatText:string, target:string}} Reminder content with resolved chat target. + */ + buildConcentrationReminderMessage = (tokenName, DC, target) => { + let safeTokenName = escapeHtml(tokenName); + let chatText; + let whisperTarget; + + if (target === 'character') { + chatText = 'Make a Concentration Check - DC ' + DC + '.'; + whisperTarget = createWhisperName(tokenName); + } else if (target === 'everyone') { + chatText = + '' + + safeTokenName + + ' must make a Concentration Check - DC ' + + DC + + '.'; + whisperTarget = ''; + } else { + chatText = + '' + + safeTokenName + + ' must make a Concentration Check - DC ' + + DC + + '.'; + whisperTarget = 'gm'; + } + + return { + chatText, + target: whisperTarget, + }; + }, + /** + * Adds Roll and Advantage buttons to a reminder message. + * @param {string} chatText Existing reminder text. + * @param {string} pendingRollId Pending roll identifier. + * @returns {string} Reminder text with action buttons appended. + */ + appendPendingRollButtons = (chatText, pendingRollId) => { + let withButtons = + chatText + + '
' + + makeButton( + 'Advantage', + '!' + + state[state_name].config.command + + ' advantage ' + + pendingRollId, + styles.button + styles.float.right, + ); + + withButtons += + ' ' + + makeButton( + 'Roll', + '!' + state[state_name].config.command + ' roll ' + pendingRollId, + styles.button + styles.float.left, + ); + + return withButtons; + }, + /** + * Prevents duplicate rapid concentration processing for the same token/character. + * @param {string|null} trackingKey Token or represented character tracking key. + */ + queueTrackingKey = (trackingKey) => { + if (!trackingKey) { + return; + } + + checked.push(trackingKey); + setTimeout(() => { + let index = checked.indexOf(trackingKey); + if (index !== -1) { + checked.splice(index, 1); + } + }, 1000); + }, + /** + * Handles concentration checks after tracked HP bar damage. + * @param {Object} obj Roll20 token graphic object after change. + * @param {Object} prev Previous token state snapshot. + */ + handleGraphicChange = async (obj, prev) => { + let trackingKey = getConcentrationTrackingKey(obj); + + if (trackingKey && checked.includes(trackingKey)) { + return false; + } + + let bar = 'bar' + state[state_name].config.bar + '_value', + target = state[state_name].config.send_reminder_to, + marker = state[state_name].config.statusmarker; + + if (isConcentrationDamageEvent(obj, prev, marker, bar)) { + let calc_DC = Math.floor((prev[bar] - obj.get(bar)) / 2), + DC = Math.max(calc_DC, 10), + con_save_mod = await getConcentrationSaveModifier( + obj.get('represents'), + ), + tokenName = getTokenDisplayName(obj), + reminder = buildConcentrationReminderMessage(tokenName, DC, target), + chat_text = reminder.chatText; + + target = reminder.target; + + if (state[state_name].config.show_roll_button) { + let pendingRollId = createPendingRoll({ + represents: obj.get('represents') || null, + tokenId: obj.get('id'), + DC, + conSaveMod: con_save_mod, + name: tokenName, + target, + }); + + chat_text = appendPendingRollButtons(chat_text, pendingRollId); + } + + if (state[state_name].config.auto_roll_save) { + roll( + obj.get('represents') || null, + DC, + con_save_mod, + tokenName, + target, + !!state[state_name].advantages[obj.get('represents')], + obj.get('id'), + ); + } else { + makeAndSendMenu(chat_text, '', target); + } + + queueTrackingKey(trackingKey); + } + }, + /** + * Rolls a concentration saving throw and reports success or failure. + * @param {string|null} represents Character id represented by the token. + * @param {number} DC Concentration check DC. + * @param {number} con_save_mod Concentration modifier. + * @param {string} name Display name for result output. + * @param {string} target Chat whisper target. + * @param {boolean} advantage Whether advantage applies. + * @param {string|null} tokenId Token id used for marker cleanup on failure. + */ + roll = ( + represents, + DC, + con_save_mod, + name, + target, + advantage, + tokenId, + ) => { + let safeName = escapeHtml(name); + sendChat( + script_name, + '[[1d20cf<' + + (DC - con_save_mod - 1) + + 'cs>' + + (DC - con_save_mod - 1) + + '+' + + con_save_mod + + ']]', + (results) => { + let title = + 'Concentration Save
' + + safeName + + '', + advantageRollResult; + + let rollresult = + results[0].inlinerolls[0].results.rolls[0].results[0].v; + let result = rollresult; + + if (advantage) { + advantageRollResult = randomInteger(20); + result = Math.max(rollresult, advantageRollResult); + } + + let total = result + con_save_mod; + + let success = total >= DC; + + let result_text = success ? 'Success' : 'Failed', + result_color = success ? 'green' : 'red'; + + let rollResultString = advantage + ? rollresult + ' / ' + advantageRollResult + : rollresult; + + let contents = + '' + + '' + + '' + + '' + + '
DC' + + DC + + '
Modifier' + + con_save_mod + + '
Roll Result' + + rollResultString + + '
' + + '
' + + '' + + '[[' + + result + + '+' + + con_save_mod + + ']]

' + + result_text + + '
' + + '
'; + makeAndSendMenu(contents, title, target); + + if (target !== '' && target !== 'gm') { + makeAndSendMenu(contents, title, 'gm'); + } + + if (!success) { + removeMarker(represents, getObj('graphic', tokenId), 'graphic'); + } + }, + ); + }, + removeMarker = (represents, currentObj, type = 'graphic') => { + let marker = 'status_' + state[state_name].config.statusmarker; + + if (represents) { + debugLog('Removing concentration marker', { + scope: 'represented-tokens', + represents, + }); + + findObjs({ type, represents }).forEach((obj) => { + if (obj.get(marker)) { + obj.set(marker, false); + } + }); + + return; + } + + if (currentObj?.get(marker)) { + debugLog('Removing concentration marker', { + scope: 'single-token', + tokenId: currentObj.get('id'), + }); + currentObj.set(marker, false); + } + }, + createWhisperName = (name) => { + if (!name || typeof name !== 'string') { + return 'gm'; + } + + let safeName = name.replace(/[<>"'`]/g, '').trim(); + + return (safeName || 'gm').split(' ').shift(); + }, + ucFirst = (string) => { + return string.charAt(0).toUpperCase() + string.slice(1); + }, + /** + * Renders and sends the GM configuration menu. + * @param {boolean} [first] Whether to show first-time setup title. + * @param {string|null} [message] Optional status or validation message. + */ + sendConfigMenu = (first, message) => { + let markerDropdown = '?{Marker'; + markers.forEach((marker) => { + markerDropdown += + '|' + ucFirst(marker).replace('-', ' ') + ',' + marker; + }); + markerDropdown += '}'; + + let markerButton = makeButton( + state[state_name].config.statusmarker, + '!' + + state[state_name].config.command + + ' config statusmarker|' + + markerDropdown, + styles.button + styles.float.right, + ), + commandButton = makeButton( + '!' + state[state_name].config.command, + '!' + + state[state_name].config.command + + ' config command|?{Command (without !)}', + styles.button + styles.float.right, + ), + barButton = makeButton( + 'bar ' + state[state_name].config.bar, + '!' + + state[state_name].config.command + + ' config bar|?{Bar|Bar 1 (green),1|Bar 2 (blue),2|Bar 3 (red),3}', + styles.button + styles.float.right, + ), + sendToButton = makeButton( + state[state_name].config.send_reminder_to, + '!' + + state[state_name].config.command + + ' config send_reminder_to|?{Send To|Everyone,everyone|Character,character|GM,gm}', + styles.button + styles.float.right, + ), + addConMarkerButton = makeButton( + state[state_name].config.auto_add_concentration_marker, + '!' + + state[state_name].config.command + + ' config auto_add_concentration_marker|' + + !state[state_name].config.auto_add_concentration_marker, + styles.button + styles.float.right, + ), + autoRollButton = makeButton( + state[state_name].config.auto_roll_save, + '!' + + state[state_name].config.command + + ' config auto_roll_save|' + + !state[state_name].config.auto_roll_save, + styles.button + styles.float.right, + ), + bonusAttrButton = makeButton( + state[state_name].config.bonus_attribute, + '!' + + state[state_name].config.command + + ' config bonus_attribute|?{Attribute|' + + state[state_name].config.bonus_attribute + + '}', + styles.button + styles.float.right, + ), + showRollButtonButton = makeButton( + state[state_name].config.show_roll_button, + '!' + + state[state_name].config.command + + ' config show_roll_button|' + + !state[state_name].config.show_roll_button, + styles.button + styles.float.right, + ), + debugButton = makeButton( + state[state_name].config.debug, + '!' + + state[state_name].config.command + + ' config debug|' + + !state[state_name].config.debug, + styles.button + styles.float.right, + ), + supportModeButton = makeButton( + state[state_name].config.support_mode, + '!' + + state[state_name].config.command + + ' config support_mode|?{Support Mode|Basic,basic|Detailed,detailed}', + styles.button + styles.float.right, + ), + listItems = [ + 'Command: ' + + commandButton, + 'Statusmarker: ' + + markerButton, + 'HP Bar: ' + + barButton, + 'Send Reminder To: ' + + sendToButton, + 'Auto Add Con. Marker:

Works only for 5e OGL and 2024 Sheets.

' + + addConMarkerButton, + 'Auto Roll Save: ' + + autoRollButton, + 'Debug Mode: ' + + debugButton, + 'Support Mode: ' + + supportModeButton, + ], + resetButton = makeButton( + 'Reset', + '!' + state[state_name].config.command + ' reset', + styles.button + styles.fullWidth, + ), + title_text = first + ? script_name + ' First Time Setup' + : script_name + ' Config'; + + if (state[state_name].config.auto_roll_save) { + listItems.push( + 'Bonus Attribute: ' + + bonusAttrButton, + ); + } + + if (!state[state_name].config.auto_roll_save) { + listItems.push( + 'Roll Button: ' + + showRollButtonButton, + ); + } + + let advantageMenuButton = state[state_name].config.auto_roll_save + ? makeButton( + 'Advantage Menu', + '!' + state[state_name].config.command + ' advantage-menu', + styles.button + styles.fullWidth, + ) + : ''; + + message = message ? '

' + message + '

' : ''; + let contents = + message + + makeList( + listItems, + styles.reset + styles.list + styles.overflow, + styles.overflow, + ) + + '
' + + advantageMenuButton + + '

You can always come back to this config by typing `!' + + state[state_name].config.command + + ' config`.


' + + resetButton; + makeAndSendMenu(contents, title_text, 'gm'); + }, + sendAdvantageMenu = () => { + let menu_text = ''; + let characters = findObjs({ type: 'character' }).sort((a, b) => { + let nameA = a.get('name').toUpperCase(); + let nameB = b.get('name').toUpperCase(); + + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + + return 0; + }); + + characters.forEach((character) => { + let safeName = escapeHtml(character.get('name') || 'Unnamed'); + let name = state[state_name].advantages?.[character.get('id')] + ? '' + safeName + '' + : safeName; + menu_text += + makeButton( + name, + '!' + + state[state_name].config.command + + ' toggle-advantage ' + + character.get('id'), + styles.textButton, + ) + '
'; + }); + + makeAndSendMenu(menu_text, 'Advantage Menu', 'gm'); + }, + makeAndSendMenu = (contents, title, whisper) => { + title = title && title != '' ? makeTitle(title) : ''; + whisper = whisper && whisper !== '' ? '/w ' + whisper + ' ' : ''; + sendChat( + script_name, + whisper + + '
' + + title + + contents + + '
', + null, + { noarchive: true }, + ); + }, + makeTitle = (title) => { + return '

' + title + '

'; + }, + makeButton = (title, href, style) => { + return '' + title + ''; + }, + makeList = (items, listStyle, itemStyle) => { + let list = ''; + return list; + }, + checkInstall = () => { + if (!_.has(state, state_name)) { + state[state_name] = state[state_name] || {}; + } + setDefaults(); + cleanupPendingRolls(); + + log( + script_name + ' Ready! Command: !' + state[state_name].config.command, + ); + if (state[state_name].config.debug) { + makeAndSendMenu(script_name + ' Ready! Debug On.', '', 'gm'); + } + }, + registerEventHandlers = () => { + on('chat:message', handleInput); + on( + 'change:graphic:bar' + state[state_name].config.bar + '_value', + handleGraphicChange, + ); + on('change:graphic:statusmarkers', handleStatusMarkerChange); + }, + setDefaults = (reset) => { + const defaults = { + config: { + command: 'concentration', + statusmarker: 'stopwatch', + bar: 1, + send_reminder_to: 'everyone', + auto_add_concentration_marker: true, + auto_roll_save: true, + advantage: false, + bonus_attribute: 'constitution_save_mod', + show_roll_button: true, + debug: false, + support_mode: 'basic', + }, + advantages: {}, + pendingRolls: {}, + }; + + state[state_name].config = state[state_name].config || {}; + applyDefaultConfig(state[state_name].config, defaults.config); + + state[state_name].advantages = + state[state_name].advantages || defaults.advantages; + state[state_name].pendingRolls = + state[state_name].pendingRolls || defaults.pendingRolls; + + if (!state[state_name].config.hasOwnProperty('firsttime') && !reset) { + sendConfigMenu(true); + state[state_name].config.firsttime = false; + } + }; + + return { + CheckInstall: checkInstall, + RegisterEventHandlers: registerEventHandlers, + }; + })(); + +on('ready', function () { + 'use strict'; + + Concentration.CheckInstall(); + Concentration.RegisterEventHandlers(); +}); diff --git a/Concentration/CHANGELOG.md b/Concentration/CHANGELOG.md new file mode 100644 index 000000000..78a5087a9 --- /dev/null +++ b/Concentration/CHANGELOG.md @@ -0,0 +1,112 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-05-23 + +### Added + +- Dedicated legacy and Beacon spell-detection paths. +- Support Mode for debug output with two levels: + - Basic: short support-friendly diagnostics. + - Detailed: structured diagnostics for troubleshooting. +- Pending roll ID flow for Roll and Advantage buttons. +- Pending roll cleanup for expired entries. +- Guarded CON save modifier lookup helper. +- Documentation set for compatibility and testing: + - `docs/beacon-compatibility.md` + - `docs/troubleshooting.md` + - `docs/testing-checklist.md` + +### Changed + +- Updated script and package versioning to `1.0.0`. +- Hardened Beacon matching heuristics for concentration spell cards. +- Improved character resolution using character ID first, then exact-name fallback. +- Preferred represented tokens on the caster's current page before global fallback. +- Refreshed README and metadata for current Roll20/Beacon behavior. +- Refactored API input handling into dedicated parse/dispatch helpers. +- Refactored HP-loss concentration flow into focused reminder/button/tracking helpers. +- Added JSDoc typedefs and function docs for core detection, command, roll, and config flows. + +### Fixed + +- Fixed selected-token manual toggle whisper targeting for character mode. +- Prevented brittle regex indexing assumptions on unmatched spell content. +- Improved marker-change loop protection using tracking keys. +- Added safer marker removal behavior for represented and unlinked tokens. +- Improved handling of missing or expired pending roll IDs. + +### Security + +- Validates API command and config input values before applying state changes. +- Escapes dynamic chat-rendered values to reduce markup injection risk. + +## [0.2.0] - 2024-06-02 + +### Changed + +- Updated script behavior to work with Beacon sheets. + +## [0.1.14] + +### Changed + +- Updated autoroll advantage behavior. + +### Fixed + +- Fixed crash when processing very high damage values. + +## [0.1.13] + +### Added + +- Optional autoroll with advantage. +- Optional roll button when autoroll is disabled. + +## [0.1.12] + +### Added + +- `!concentration` accepts an additional spell-name argument. +- Players can use `!concentration` for characters they control. + +## [0.1.11] + +### Changed + +- `!concentration` with selected tokens toggles the concentration status marker. + +## [0.1.10] + +### Fixed + +- When autoroll is enabled and the save fails, the concentration marker is removed automatically. + +## [0.1.9] + +### Added + +- Optional auto-roll saves. + +## [0.1.8] - 2018-05-01 + +### Fixed + +- Bugfix. + +## [0.1.7] - 2018-04-28 + +### Fixed + +- Removes status marker from all represented objects when concentration is removed. + +## [0.1.5] - 2018-04-25 + +### Fixed + +- Correct whisper target on spell-cast concentration check. diff --git a/Concentration/Concentration.js b/Concentration/Concentration.js index 0e03f6c4e..3e179684d 100644 --- a/Concentration/Concentration.js +++ b/Concentration/Concentration.js @@ -1,19 +1,14 @@ -/* - * Version 0.1.15 - * Made By Robin Kuiper - * Skype: RobinKuiper.eu - * Discord: Atheos#1095 - * My Discord Server: https://discord.gg/AcC9VME - * Roll20: https://app.roll20.net/users/1226016/robin - * Roll20 Wiki: https://wiki.roll20.net/Script:Concentration - * Roll20 Thread: https://app.roll20.net/forum/post/6364317/script-concentration/?pageforid=6364317#post-6364317 - * Github: https://github.com/RobinKuiper/Roll20APIScripts - * Reddit: https://www.reddit.com/user/robinkuiper/ - * Patreon: https://patreon.com/robinkuiper - * Paypal.me: https://www.paypal.me/robinkuiper -*/ - -var Concentration = Concentration || (function() { +// ============================ +// === Concentration v1.0.0 === +// ============================ + +// AUTHORS: +// - Robin Kuiper: https://app.roll20.net/users/1226016/robin +// - Steve Roberts: https://app.roll20.net/users/16506286/midniteshadow7 + +const Concentration = + globalThis.Concentration || + (function () { 'use strict'; let checked = []; @@ -21,502 +16,1896 @@ var Concentration = Concentration || (function() { // Styling for the chat responses. const styles = { reset: 'padding: 0; margin: 0;', - menu: 'background-color: #fff; border: 1px solid #000; padding: 5px; border-radius: 5px;', - button: 'background-color: #000; border: 1px solid #292929; border-radius: 3px; padding: 5px; color: #fff; text-align: center;', - textButton: 'background-color: transparent; border: none; padding: 0; color: #000; text-decoration: underline', + menu: 'background-color: #fff; border: 1px solid #000; padding: 5px; border-radius: 5px;', + button: + 'background-color: #000; border: 1px solid #292929; border-radius: 3px; padding: 5px; color: #fff; text-align: center;', + textButton: + 'background-color: transparent; border: none; padding: 0; color: #000; text-decoration: underline', list: 'list-style: none;', float: { - right: 'float: right;', - left: 'float: left;' + right: 'float: right;', + left: 'float: left;', }, overflow: 'overflow: hidden;', - fullWidth: 'width: 100%;' - }, - script_name = 'Concentration', - state_name = 'CONCENTRATION', - markers = ['blue', 'brown', 'green', 'pink', 'purple', 'red', 'yellow', '-', 'all-for-one', 'angel-outfit', 'archery-target', 'arrowed', 'aura', 'back-pain', 'black-flag', 'bleeding-eye', 'bolt-shield', 'broken-heart', 'broken-shield', 'broken-skull', 'chained-heart', 'chemical-bolt', 'cobweb', 'dead', 'death-zone', 'drink-me', 'edge-crack', 'fishing-net', 'fist', 'fluffy-wing', 'flying-flag', 'frozen-orb', 'grab', 'grenade', 'half-haze', 'half-heart', 'interdiction', 'lightning-helix', 'ninja-mask', 'overdrive', 'padlock', 'pummeled', 'radioactive', 'rolling-tomb', 'screaming', 'sentry-gun', 'skull', 'sleepy', 'snail', 'spanner', 'stopwatch','strong', 'three-leaves', 'tread', 'trophy', 'white-tower'], + fullWidth: 'width: 100%;', + }, + script_name = 'Concentration', + state_name = 'CONCENTRATION', + debug_prefix = '[Concentration v1]', + pending_roll_ttl = 10 * 60 * 1000, + markers = [ + 'blue', + 'brown', + 'green', + 'pink', + 'purple', + 'red', + 'yellow', + '-', + 'all-for-one', + 'angel-outfit', + 'archery-target', + 'arrowed', + 'aura', + 'back-pain', + 'black-flag', + 'bleeding-eye', + 'bolt-shield', + 'broken-heart', + 'broken-shield', + 'broken-skull', + 'chained-heart', + 'chemical-bolt', + 'cobweb', + 'dead', + 'death-zone', + 'drink-me', + 'edge-crack', + 'fishing-net', + 'fist', + 'fluffy-wing', + 'flying-flag', + 'frozen-orb', + 'grab', + 'grenade', + 'half-haze', + 'half-heart', + 'interdiction', + 'lightning-helix', + 'ninja-mask', + 'overdrive', + 'padlock', + 'pummeled', + 'radioactive', + 'rolling-tomb', + 'screaming', + 'sentry-gun', + 'skull', + 'sleepy', + 'snail', + 'spanner', + 'stopwatch', + 'strong', + 'three-leaves', + 'tread', + 'trophy', + 'white-tower', + ], + allowed_reminder_targets = new Set(['everyone', 'character', 'gm']), + allowed_support_modes = new Set(['basic', 'detailed']), + /** + * @typedef {Object} ConcentrationConfig + * @property {string} command API command name without leading !. + * @property {string} statusmarker Roll20 token marker used for concentration. + * @property {1|2|3} bar HP bar index used to detect damage. + * @property {'everyone'|'character'|'gm'} send_reminder_to Reminder target scope. + * @property {boolean} auto_add_concentration_marker Auto-detect and set marker from supported spell cards. + * @property {boolean} auto_roll_save Auto-roll concentration saves on HP loss. + * @property {boolean} advantage Unused legacy config key preserved for compatibility. + * @property {string} bonus_attribute Character attribute used as concentration save modifier. + * @property {boolean} show_roll_button Show manual roll buttons when auto-roll is disabled. + * @property {boolean} debug Enable debug logs. + * @property {'basic'|'detailed'} support_mode Debug output detail level. + */ + /** + * @typedef {Object} SpellCast + * @property {'legacy'|'beacon'} sheetType Source sheet parser that detected the cast. + * @property {string|null} characterName Caster character name when available. + * @property {string|null} characterId Caster character id when available. + * @property {string} spellName Spell name. + * @property {boolean} isConcentration Whether the detected spell requires concentration. + */ + /** + * @typedef {Object} ResolvedCharacter + * @property {Object|null} character Roll20 character object if resolved. + * @property {string|null} characterId Resolved character id. + * @property {string|null} characterName Resolved character name. + * @property {string|null} warning Resolution warning details. + */ + /** + * @typedef {Object} PendingRoll + * @property {string|null} represents Character id represented by the token. + * @property {string|null} tokenId Token id that triggered the check. + * @property {number} DC Concentration check difficulty class. + * @property {number} conSaveMod Concentration modifier. + * @property {string} name Display name for prompts. + * @property {string} target Chat whisper target. + * @property {number} [createdAt] Creation timestamp in milliseconds. + */ + validateCommandName = (value) => { + if (typeof value !== 'string') { + return false; + } - handleInput = (msg) => { - if(state[state_name].config.auto_add_concentration_marker && msg && msg.rolltemplate && msg.rolltemplate === 'spell' && (msg.content.includes("{{concentration=1}}"))){ - handleConcentrationSpellCast(msg); + return /^[A-Za-z0-9_-]{1,32}$/.test(value.trim()); + }, + isValidRoll20Id = (value) => { + if (typeof value !== 'string') { + return false; } - if(state[state_name].config.auto_add_concentration_marker && msg && msg.type && msg.type === 'advancedroll' && /data\-chip=concentration/g.test(msg.content)){ - handleConcentrationSpellCast(msg, true); + return /^[-A-Za-z0-9_]+$/.test(value.trim()); + }, + isValidPendingRollId = (value) => { + if (typeof value !== 'string') { + return false; } - if (msg.type != 'api') return; + return /^pr_\d+_[a-z0-9]+$/.test(value.trim()); + }, + truncateText = (value, maxLength) => { + if (value === null || value === undefined) { + return value; + } - // Split the message into command and argument(s) - let args = msg.content.split(' '); - let command = args.shift().substring(1); - let extracommand = args.shift(); - let message; + let text = String(value); + + return text.length > maxLength + ? text.slice(0, maxLength) + '...' + : text; + }, + cleanDebugText = (value, maxLength = 240) => { + if (value === null || value === undefined) { + return null; + } + + let text = String(value) + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\s+/g, ' ') + .trim(); + + return truncateText(text || '(empty)', maxLength); + }, + formatDebugValue = (value) => { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + + if (typeof value === 'string') { + return cleanDebugText(value, 200); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (Array.isArray(value)) { + let simplified = value.map((item) => formatDebugValue(item)); + return truncateText(simplified.join(', '), 200); + } + + return '[details]'; + }, + humanizeDebugText = (value) => { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + + return String(value).replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); + }, + debugFieldLabels = { + source: 'Detection Path', + messageType: 'Message Type', + template: 'Roll Template', + spellName: 'Spell', + caster: 'Caster', + status: 'Result', + preview: 'Message Preview', + reason: 'Reason', + warning: 'Warning', + characterId: 'Character ID', + characterName: 'Character Name', + attribute: 'Attribute', + value: 'Value', + error: 'Error', + pageId: 'Page ID', + scope: 'Scope', + represents: 'Represents', + tokenId: 'Token ID', + pendingRollId: 'Pending Roll ID', + }, + getDebugFieldLabel = (key) => { + return debugFieldLabels[key] || humanizeDebugText(key); + }, + normalizeDetectionPath = (value) => { + if (value === 'legacy') { + return 'Legacy sheet'; + } + + if (value === 'beacon') { + return 'Beacon sheet'; + } + + return 'No match'; + }, + normalizeDetectionStatus = (value) => { + if (!value || value === 'n/a') { + return 'n/a'; + } + + if (value === 'detected') { + return 'Concentration spell detected'; + } + + return 'Skipped: ' + humanizeDebugText(value); + }, + detectionReasonLabels = { + 'not-legacy-spell-rolltemplate': 'Not a legacy spell card message', + 'legacy-spell-not-concentration': + 'Legacy spell card found, but concentration was not marked', + 'legacy-spell-missing-character-or-spell': + 'Legacy spell card was missing character or spell details', + 'legacy-spell-detected': 'Legacy spell card already matched', + 'not-beacon-advancedroll': 'Not a Beacon advanced roll message', + 'advancedroll-does-not-look-like-spell-card': + 'Advanced roll did not look like a spell card', + 'beacon-spell-not-concentration': + 'Beacon spell card found, but concentration was not marked', + 'beacon-spell-missing-spell-name': + 'Beacon spell card was missing spell name', + 'beacon-spell-missing-character': + 'Beacon spell card was missing caster details', + }, + normalizeDetectionReason = (value) => { + if (!value || value === 'n/a') { + return 'n/a'; + } + + let reasonText = String(value); + let reasons = reasonText + .split(';') + .map((reason) => reason.trim()) + .filter(Boolean) + .map((reason) => { + return detectionReasonLabels[reason] || humanizeDebugText(reason); + }); + + return reasons.join('; '); + }, + shouldLogSpellDetection = (msg, spellCast) => { + if (spellCast) { + return true; + } + + // Keep detection debug readable by skipping unrelated chat traffic. + if (msg?.rolltemplate === 'spell') { + return true; + } + + return msg?.type === 'advancedroll'; + }, + normalizeDebugValueByKey = (key, value) => { + if (key === 'source') { + return normalizeDetectionPath(value); + } + + if (key === 'status') { + return normalizeDetectionStatus(value); + } + + if (key === 'reason') { + return normalizeDetectionReason(value); + } + + if (key === 'warning') { + return humanizeDebugText(value); + } + + return value; + }, + formatDebugPayload = (data) => { + if (data === undefined) { + return ''; + } + + if (typeof data === 'string') { + return cleanDebugText(data, 240); + } + + if (data && typeof data === 'object') { + let entries = Object.keys(data).map((key) => { + let value = normalizeDebugValueByKey(key, data[key]); + return getDebugFieldLabel(key) + ': ' + formatDebugValue(value); + }); + + return entries.join(' | '); + } + + return String(data); + }, + getContentPreview = (content) => { + return cleanDebugText(content, 160); + }, + getConfig = () => { + return state[state_name]?.config || null; + }, + getSupportMode = () => { + let mode = getConfig()?.support_mode; + + return mode === 'detailed' ? 'detailed' : 'basic'; + }, + formatBasicSpellDetection = (data) => { + if (data.status === 'detected') { + return ( + 'Concentration spell detected' + + (data.spellName + ? ' | Spell: ' + formatDebugValue(data.spellName) + : '') + + (data.caster ? ' | Caster: ' + formatDebugValue(data.caster) : '') + + (data.source + ? ' | Path: ' + + formatDebugValue(normalizeDetectionPath(data.source)) + : '') + ); + } + + return ( + 'Skipped: ' + formatDebugValue(normalizeDetectionReason(data.status)) + ); + }, + getBasicDebugKeys = (data) => { + let preferredKeys = [ + 'reason', + 'warning', + 'error', + 'spellName', + 'caster', + 'characterName', + 'attribute', + 'value', + ]; + let keys = preferredKeys.filter((key) => Object.hasOwn(data, key)); + + if (!keys.length) { + keys = Object.keys(data).slice(0, 2); + } + + return keys; + }, + formatBasicDebugObject = (data) => { + return getBasicDebugKeys(data) + .map((key) => { + let value = normalizeDebugValueByKey(key, data[key]); + return getDebugFieldLabel(key) + ': ' + formatDebugValue(value); + }) + .join(' | '); + }, + formatBasicDebugPayload = (label, data) => { + if (data === undefined) { + return ''; + } + + if (typeof data === 'string') { + return cleanDebugText(data, 140); + } + + if (!data || typeof data !== 'object') { + return String(data); + } + + if (label === 'Spell detection') { + return formatBasicSpellDetection(data); + } + + return formatBasicDebugObject(data); + }, + debugLog = (label, data) => { + let config = getConfig(); + let supportMode = getSupportMode(); + + if (!config?.debug) { + return; + } + + let payload = ''; + + if (data !== undefined) { + try { + payload = + supportMode === 'detailed' + ? formatDebugPayload(data) + : formatBasicDebugPayload(label, data); + } catch (error) { + log( + debug_prefix + + ' debugLog stringify error: ' + + (error?.message || String(error)), + ); + payload = cleanDebugText(String(data), 240); + } + } + + log(debug_prefix + ' ' + label + (payload ? ': ' + payload : '')); + }, + decodeEntities = (value) => { + if (!value) { + return value; + } + + const entities = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + }; + + return value.replace( + /(&|<|>|"|')/g, + (match) => entities[match] || match, + ); + }, + getFirstMatch = (content, patterns) => { + if (!content) { + return null; + } + + for (const pattern of patterns) { + let match = content.match(pattern); + if (match?.[1]) { + return decodeEntities(match[1].trim()); + } + } + + return null; + }, + getConcentrationTrackingKey = (obj) => { + if (!obj || typeof obj.get !== 'function') { + return null; + } + + return obj.get('represents') || obj.get('id') || null; + }, + getTokenDisplayName = (token, fallbackName) => { + if (!token || typeof token.get !== 'function') { + return fallbackName || 'This token'; + } + + return token.get('name') || fallbackName || 'This token'; + }, + hasStatusMarker = (statusmarkers, marker) => { + if (!statusmarkers) { + return false; + } + + return new RegExp('(?:^|,)' + marker + '(?:@[^,]+)?(?:,|$)').test( + statusmarkers, + ); + }, + cleanupPendingRolls = () => { + if (!state[state_name].pendingRolls) { + state[state_name].pendingRolls = {}; + return; + } + + let now = Date.now(); + + Object.keys(state[state_name].pendingRolls).forEach((id) => { + let pendingRoll = state[state_name].pendingRolls[id]; + + if ( + !pendingRoll?.createdAt || + now - pendingRoll.createdAt > pending_roll_ttl + ) { + delete state[state_name].pendingRolls[id]; + } + }); + }, + toConfigValue = (value) => { + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + return value; + }, + sanitizeSpellInput = (value) => { + if (typeof value !== 'string') { + return ''; + } + + return truncateText(value.replace(/[<>]/g, '').trim(), 80); + }, + escapeHtml = (value) => { + if (value === null || value === undefined) { + return ''; + } + + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + }, + validateConfigSetting = (key, value) => { + switch (key) { + case 'command': { + if (!validateCommandName(value)) { + return { + valid: false, + message: + 'Invalid command. Use 1-32 characters: letters, numbers, underscore, or hyphen.', + }; + } + + return { valid: true, value: value.trim() }; + } + + case 'statusmarker': + if (!markers.includes(value)) { + return { valid: false, message: 'Invalid status marker.' }; + } + + return { valid: true, value }; + + case 'bar': { + let bar = Number.parseInt(value, 10); + if (![1, 2, 3].includes(bar)) { + return { + valid: false, + message: 'Invalid HP bar. Choose 1, 2, or 3.', + }; + } + + return { valid: true, value: bar }; + } + + case 'send_reminder_to': + if (!allowed_reminder_targets.has(value)) { + return { + valid: false, + message: + 'Invalid reminder target. Choose everyone, character, or gm.', + }; + } + + return { valid: true, value }; - if (command == state[state_name].config.command) { - if(playerIsGM(msg.playerid)){ - switch(extracommand){ - case 'reset': - state[state_name] = {}; - setDefaults(true); - sendConfigMenu(false, 'The API Library needs to be restarted for this to take effect.'); - break; - - case 'config': - if(args.length > 0){ - let setting = args.shift().split('|'); - let key = setting.shift(); - let value = (setting[0] === 'true') ? true : (setting[0] === 'false') ? false : setting[0]; - - state[state_name].config[key] = value; - - if(key === 'bar'){ - //registerEventHandlers(); - message = 'The API Library needs to be restarted for this to take effect.'; - } - } - - sendConfigMenu(false, message); - break; - - case 'advantage-menu': - sendAdvantageMenu(); - break; - - case 'toggle-advantage': - let id = args[0]; - - if(state[state_name].advantages[id]){ - state[state_name].advantages[id] = !state[state_name].advantages[id]; - }else{ - state[state_name].advantages[id] = true; - } - - sendAdvantageMenu(); - break; - - case 'roll': - let represents = args[0], - DC = parseInt(args[1], 10), - con_save_mod = parseInt(args[2], 10), - name = args[3], - target = args[4]; - - roll(represents, DC, con_save_mod, name, target, false); - break; - - case 'advantage': - let represents_a = args[0], - DC_a = parseInt(args[1], 10), - con_save_mod_a = parseInt(args[2], 10), - name_a = args[3], - target_a = args[4]; - - roll(represents_a, DC_a, con_save_mod_a, name_a, target_a, true); - break; - - default: - if(msg.selected && msg.selected.length){ - msg.selected.forEach(s => { - let token = getObj(s._type, s._id); - addConcentration(token, msg.playerid, extracommand); - }); - return; - } - - sendConfigMenu(); - break; - } - }else{ - if(msg.selected && msg.selected.length){ - msg.selected.forEach(s => { - let token = getObj(s._type, s._id); - addConcentration(token, msg.playerid, extracommand); - }); - } + case 'support_mode': + if (!allowed_support_modes.has(value)) { + return { + valid: false, + message: 'Invalid support mode. Choose basic or detailed.', + }; } + + return { valid: true, value }; + + case 'auto_add_concentration_marker': + case 'auto_roll_save': + case 'show_roll_button': + case 'debug': + if (typeof value !== 'boolean') { + return { + valid: false, + message: 'Invalid toggle value. Use true or false.', + }; + } + + return { valid: true, value }; + + case 'bonus_attribute': { + if (typeof value !== 'string' || !value.trim()) { + return { + valid: false, + message: 'Invalid attribute name.', + }; + } + + return { valid: true, value: truncateText(value.trim(), 80) }; + } + + default: + return { + valid: false, + message: 'Unknown config setting.', + }; + } + }, + processSelectedTokens = (msg, playerid, spell) => { + let safeSpell = sanitizeSpellInput(spell); + + if (!msg.selected?.length) { + return false; + } + + msg.selected.forEach((selectedItem) => { + let token = getObj(selectedItem._type, selectedItem._id); + addConcentration(token, playerid, safeSpell); + }); + + return true; + }, + runPendingRoll = (pendingRollId, hasAdvantage) => { + if (!isValidPendingRollId(pendingRollId)) { + debugLog('Pending roll input rejected', { + pendingRollId: pendingRollId || null, + }); + return; + } + + let pendingRoll = consumePendingRoll(pendingRollId); + + if (!pendingRoll) { + debugLog('Pending roll missing or expired', { + pendingRollId: pendingRollId || null, + }); + makeAndSendMenu( + 'That concentration roll button has expired. Trigger the concentration check again to create a new button.', + '', + 'gm', + ); + return; + } + + roll( + pendingRoll.represents, + pendingRoll.DC, + pendingRoll.conSaveMod, + pendingRoll.name, + pendingRoll.target, + hasAdvantage, + pendingRoll.tokenId, + ); + }, + applyDefaultConfig = (target, defaults) => { + Object.keys(defaults).forEach((key) => { + if (!Object.hasOwn(target, key)) { + target[key] = defaults[key]; + } + }); + }, + applyConfigSetting = (args) => { + if (!args.length) { + return 'Missing config setting.'; } - }, - - addConcentration = (token, playerid, spell) => { - const marker = state[state_name].config.statusmarker - let character = getObj('character', token.get('represents')); - if((token.get('controlledby').split(',').includes(playerid) || token.get('controlledby').split(',').includes('all')) || - (character && (character.get('controlledby').split(',').includes(playerid) || character.get('controlledby').split(',').includes('all'))) || - playerIsGM(playerid)){ - if(!token.get('status_'+marker)){ - let target = state[state_name].config.send_reminder_to; - if(target === 'character'){ - target = createWhisperName(character_name); - }else if(target === 'everyone'){ - target = '' - } - - let message; - if(spell){ - message = ''+token.get('name')+' is now concentrating on '+spell+'.'; - }else{ - message = ''+token.get('name')+' is now concentrating.'; - } - - makeAndSendMenu(message, '', target); - } - token.set('status_'+marker, !token.get('status_'+marker)); - } - }, - - handleConcentrationSpellCast = (msg, is2024=false) => { - const marker = state[state_name].config.statusmarker - - let character_name, spell_name; - - if(is2024) { - character_name = msg.content.match(/meta__character.+?>(.*?)(.*?)' + validation.message + ''; + } + + state[state_name].config[key] = validation.value; + + return key === 'bar' + ? 'The API Library needs to be restarted for this to take effect.' + : null; + }, + toggleAdvantageForCharacter = (characterId) => { + if ( + !isValidRoll20Id(characterId) || + !getObj('character', characterId) + ) { + return; + } + + if (state[state_name].advantages[characterId]) { + state[state_name].advantages[characterId] = + !state[state_name].advantages[characterId]; } else { - character_name = msg.content.match(/charname=([^\n{}]*[^"\n{}])/); - character_name = RegExp.$1; - spell_name = msg.content.match(/name=([^\n{}]*[^"\n{}])/); - spell_name = RegExp.$1; + state[state_name].advantages[characterId] = true; } + }, + handleGmCommand = (extracommand, args, msg) => { + switch (extracommand) { + case 'reset': + state[state_name] = {}; + setDefaults(true); + sendConfigMenu( + false, + 'The API Library needs to be restarted for this to take effect.', + ); + return; + + case 'config': { + let message = applyConfigSetting(args); + sendConfigMenu(false, message); + return; + } + + case 'advantage-menu': + sendAdvantageMenu(); + return; + + case 'toggle-advantage': + toggleAdvantageForCharacter(args[0]); + sendAdvantageMenu(); + return; + + case 'roll': + runPendingRoll(args[0], false); + return; + + case 'advantage': + runPendingRoll(args[0], true); + return; + + default: + if (processSelectedTokens(msg, msg.playerid, extracommand)) { + return; + } - let player = getObj('player', msg.playerid), - characterid = findObjs({ name: character_name, _type: 'character' }).shift().get('id'), - represented_tokens = findObjs({ represents: characterid, _type: 'graphic' }), - message, - target = state[state_name].config.send_reminder_to; + sendConfigMenu(); + } + }, + canControlToken = (token, character, playerid) => { + let tokenControllers = new Set( + (token.get('controlledby') || '').split(','), + ); + let characterControllers = new Set( + character ? (character.get('controlledby') || '').split(',') : [], + ); + + return ( + tokenControllers.has(playerid) || + tokenControllers.has('all') || + characterControllers.has(playerid) || + characterControllers.has('all') || + playerIsGM(playerid) + ); + }, + resolveReminderTarget = (token, character) => { + let target = state[state_name].config.send_reminder_to; + let characterName = character + ? character.get('name') + : token.get('name'); + let tokenName = getTokenDisplayName(token, characterName); + + if (target === 'character') { + target = characterName ? createWhisperName(characterName) : 'gm'; + } else if (target === 'everyone') { + target = ''; + } + + return { + target, + tokenName, + }; + }, + announceConcentration = (tokenName, spell, target) => { + let safeTokenName = escapeHtml(tokenName || 'Unknown'); + let safeSpell = escapeHtml(spell || ''); + let message = spell + ? '' + + safeTokenName + + ' is now concentrating on ' + + safeSpell + + '.' + : '' + safeTokenName + ' is now concentrating.'; + + makeAndSendMenu(message, '', target); + }, + /** + * Stores a pending concentration roll request and returns an expiring id. + * @param {PendingRoll} pendingRoll Pending roll payload. + * @returns {string} Pending roll id. + */ + createPendingRoll = (pendingRoll) => { + cleanupPendingRolls(); + + let id = + 'pr_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); + + state[state_name].pendingRolls[id] = { + represents: pendingRoll.represents || null, + tokenId: pendingRoll.tokenId || null, + DC: pendingRoll.DC, + conSaveMod: pendingRoll.conSaveMod, + name: pendingRoll.name, + target: pendingRoll.target, + createdAt: Date.now(), + }; - if(!character_name || !spell_name || !player || !characterid) return; + return id; + }, + /** + * Consumes and removes a pending roll request by id. + * @param {string} id Pending roll id. + * @returns {PendingRoll|null} Pending roll payload if found and valid. + */ + consumePendingRoll = (id) => { + cleanupPendingRolls(); + + if (!id || !state[state_name].pendingRolls[id]) { + return null; + } - let search_attributes = { - represents: characterid, + let pendingRoll = state[state_name].pendingRolls[id]; + delete state[state_name].pendingRolls[id]; + + return pendingRoll; + }, + detectLegacySpellCast = (msg) => { + if (msg?.rolltemplate !== 'spell' || !msg?.content) { + return { + spellCast: null, + reason: 'not-legacy-spell-rolltemplate', + }; + } + + if (!msg.content.includes('{{concentration=1}}')) { + return { + spellCast: null, + reason: 'legacy-spell-not-concentration', + }; + } + + let characterName = getFirstMatch(msg.content, [ + /charname=([^\n{}]*[^"\n{}])/i, + ]); + let spellName = getFirstMatch(msg.content, [ + /name=([^\n{}]*[^"\n{}])/i, + ]); + + if (!characterName || !spellName) { + return { + spellCast: null, + reason: 'legacy-spell-missing-character-or-spell', + }; + } + + return { + spellCast: { + sheetType: 'legacy', + characterName, + characterId: null, + spellName, + isConcentration: true, + }, + reason: null, + }; + }, + detectBeaconSpellCast = (msg) => { + if (msg?.type !== 'advancedroll' || !msg?.content) { + return { + spellCast: null, + reason: 'not-beacon-advancedroll', + }; + } + + let looksLikeSpellCard = + /header__title/i.test(msg.content) || + /header__subtitle/i.test(msg.content) || + /meta__character/i.test(msg.content) || + /data-character-name/i.test(msg.content) || + /spell/i.test(msg.content); + + if (!looksLikeSpellCard) { + return { + spellCast: null, + reason: 'advancedroll-does-not-look-like-spell-card', + }; + } + + let isConcentration = [ + /data-chip\s*=\s*["']?concentration["']?/i, + /data-[a-z-]*\s*=\s*["']?concentration["']?/i, + /\bconcentration\b/i, + ].some((pattern) => pattern.test(msg.content)); + + if (!isConcentration) { + return { + spellCast: null, + reason: 'beacon-spell-not-concentration', + }; + } + + let spellName = getFirstMatch(msg.content, [ + /header__title[^>]*>\s*([^<]+?)\s*]*>\s*([^<]+?)\s* { + let legacyDetection = detectLegacySpellCast(msg); + let beaconDetection = legacyDetection.spellCast + ? { spellCast: null, reason: 'legacy-spell-detected' } + : detectBeaconSpellCast(msg); + let spellCast = legacyDetection.spellCast || beaconDetection.spellCast; + let detectionSource = 'none'; + + if (legacyDetection.spellCast) { + detectionSource = 'legacy'; + } else if (beaconDetection.spellCast) { + detectionSource = 'beacon'; + } + + if (shouldLogSpellDetection(msg, spellCast)) { + debugLog('Spell detection', { + source: detectionSource, + messageType: msg?.type || null, + template: msg?.rolltemplate || null, + spellName: spellCast?.spellName || null, + caster: spellCast?.characterName || spellCast?.characterId || null, + status: spellCast + ? 'detected' + : [legacyDetection.reason, beaconDetection.reason] + .filter(Boolean) + .join('; '), + preview: getContentPreview(msg?.content), + }); + } + + return spellCast; + }, + /** + * Resolves a character object from parsed spell cast details. + * @param {SpellCast} spellCast Parsed concentration spell cast details. + * @returns {ResolvedCharacter} Character resolution result. + */ + resolveCharacterFromSpellCast = (spellCast) => { + let warning = null; + let character = null; + + if (spellCast.characterId) { + character = getObj('character', spellCast.characterId); + + if (!character) { + warning = + 'character-id-not-found: ' + + truncateText(spellCast.characterId, 80); + } + } + + if (!character && spellCast.characterName) { + let exactMatches = findObjs({ + _type: 'character', + name: spellCast.characterName, + }); + + if (exactMatches.length > 1) { + warning = + 'duplicate-character-name: ' + + truncateText(spellCast.characterName, 80); + } + + character = exactMatches[0] || null; + } + + return { + character, + characterId: character + ? character.get('id') + : spellCast.characterId || null, + characterName: character + ? character.get('name') + : spellCast.characterName || null, + warning, + }; + }, + getRepresentedTokens = (characterId, player) => { + if (!characterId) { + return []; + } + + let currentPageId = player?.get ? player.get('lastpage') : null; + + if (currentPageId) { + let currentPageTokens = findObjs({ + represents: characterId, _type: 'graphic', - _pageid: player.get('lastpage') - } - search_attributes['status_'+marker] = true; - let is_concentrating = (findObjs(search_attributes).length > 0); - - if(is_concentrating){ - message = ''+character_name+' is concentrating already.'; - }else{ - represented_tokens.forEach(token => { - let attributes = {}; - attributes['status_'+marker] = true; - token.set(attributes); - message = ''+character_name+' is now concentrating on '+spell_name+'.'; + _pageid: currentPageId, + }); + + if (currentPageTokens.length) { + return currentPageTokens; + } + } + + return findObjs({ + represents: characterId, + _type: 'graphic', + }); + }, + getConcentrationSaveModifier = async (characterId) => { + let attributeName = state[state_name].config.bonus_attribute; + let rawValue = null; + + if (!characterId) { + debugLog('Missing character for save modifier', { + attribute: attributeName, + }); + return 0; + } + + if (typeof getSheetItem === 'function') { + try { + rawValue = await getSheetItem(characterId, attributeName); + } catch (error) { + debugLog('getSheetItem failed', { + characterId, + attribute: attributeName, + error: error?.message || String(error), }); + } + } + + if ( + (rawValue === null || rawValue === undefined || rawValue === '') && + typeof getAttrByName === 'function' + ) { + try { + rawValue = getAttrByName(characterId, attributeName, 'current'); + } catch (error) { + debugLog('getAttrByName failed', { + characterId, + attribute: attributeName, + error: error?.message || String(error), + }); + } + } + + if (rawValue === null || rawValue === undefined || rawValue === '') { + debugLog('Missing concentration modifier', { + characterId, + attribute: attributeName, + }); + return 0; + } + + let parsedValue = Number.parseInt(rawValue, 10); + + if (Number.isNaN(parsedValue)) { + debugLog('Non-numeric concentration modifier', { + characterId, + attribute: attributeName, + value: rawValue, + }); + return 0; + } + + return parsedValue; + }, + /** + * Runs concentration auto-detection and applies markers when enabled. + * @param {Object} msg Roll20 chat message payload. + */ + processAutoConcentrationDetection = (msg) => { + if (!state[state_name].config.auto_add_concentration_marker) { + return; + } + + let spellCast = detectConcentrationSpellCast(msg); + + if (spellCast?.isConcentration) { + handleConcentrationSpellCast(msg, spellCast); + } + }, + /** + * Parses and validates an API command payload for this script. + * @param {Object} msg Roll20 chat message payload. + * @returns {{args:string[], extracommand:string}|null} Parsed command details, or null when invalid. + */ + parseApiCommandMessage = (msg) => { + if (msg.type !== 'api' || typeof msg.content !== 'string') { + return null; } - if(target === 'character'){ - target = createWhisperName(character_name); - }else if(target === 'everyone'){ - target = '' + let input = msg.content.trim(); + + if (!input.startsWith('!')) { + return null; + } + + let args = input.split(/\s+/); + let commandToken = args.shift() || ''; + let command = commandToken.substring(1); + let extracommand = args.shift() || ''; + + if (!validateCommandName(command)) { + return null; + } + + if (command !== state[state_name].config.command) { + return null; + } + + return { + args, + extracommand, + }; + }, + /** + * Dispatches a parsed command to GM or player command handlers. + * @param {Object} msg Roll20 chat message payload. + * @param {{args:string[], extracommand:string}} parsedCommand Parsed command details. + */ + dispatchApiCommand = (msg, parsedCommand) => { + if (playerIsGM(msg.playerid)) { + handleGmCommand(parsedCommand.extracommand, parsedCommand.args, msg); + return; + } + + processSelectedTokens(msg, msg.playerid, parsedCommand.extracommand); + }, + /** + * Handles incoming chat events for auto-detection and explicit API commands. + * @param {Object} msg Roll20 chat:message payload. + */ + handleInput = (msg) => { + processAutoConcentrationDetection(msg); + + let parsedCommand = parseApiCommandMessage(msg); + + if (!parsedCommand) { + return; + } + + dispatchApiCommand(msg, parsedCommand); + }, + addConcentration = (token, playerid, spell) => { + if (!token) { + return; + } + + const marker = state[state_name].config.statusmarker; + let characterId = token.get('represents'); + let character = characterId ? getObj('character', characterId) : null; + + if (!canControlToken(token, character, playerid)) { + return; + } + + if (!token.get('status_' + marker)) { + let reminder = resolveReminderTarget(token, character); + announceConcentration(reminder.tokenName, spell, reminder.target); + } + + token.set('status_' + marker, !token.get('status_' + marker)); + }, + /** + * Applies concentration markers from an auto-detected spell cast. + * @param {Object} msg Roll20 chat message payload. + * @param {SpellCast} spellCast Parsed concentration spell cast details. + */ + handleConcentrationSpellCast = (msg, spellCast) => { + const marker = state[state_name].config.statusmarker; + let player = getObj('player', msg.playerid); + let resolvedCharacter = resolveCharacterFromSpellCast(spellCast); + let characterId = resolvedCharacter.characterId; + let characterName = + resolvedCharacter.characterName || + spellCast.characterName || + 'Unknown'; + let representedTokens = getRepresentedTokens(characterId, player); + let message; + let target = state[state_name].config.send_reminder_to; + + if (resolvedCharacter.warning) { + debugLog('Character resolution warning', { + warning: resolvedCharacter.warning, + characterName, + characterId, + }); + } + + if (!player || !characterId) { + let abortReason = player ? 'unresolved-character' : 'missing-player'; + debugLog('Spell cast aborted', { + reason: abortReason, + characterName, + characterId, + spellName: spellCast.spellName, + }); + return; + } + + let searchAttributes = { + represents: characterId, + _type: 'graphic', + _pageid: player.get('lastpage'), + }; + searchAttributes['status_' + marker] = true; + + let isConcentrating = findObjs(searchAttributes).length > 0; + + if (isConcentrating) { + message = + '' + + escapeHtml(characterName) + + ' is concentrating already.'; + } else { + if (!representedTokens.length) { + debugLog('No represented tokens found for spell cast', { + characterName, + characterId, + spellName: spellCast.spellName, + pageId: player.get('lastpage'), + }); + return; + } + + representedTokens.forEach((token) => { + let attributes = {}; + attributes['status_' + marker] = true; + token.set(attributes); + }); + + message = + '' + + escapeHtml(characterName) + + ' is now concentrating on ' + + escapeHtml(spellCast.spellName) + + '.'; + } + + if (target === 'character') { + target = createWhisperName(characterName); + } else if (target === 'everyone') { + target = ''; } makeAndSendMenu(message, '', target); - }, - - handleStatusMarkerChange = (obj, prev) => { - const marker = state[state_name].config.statusmarker - - if(!obj.get('status_'+marker)){ - removeMarker(obj.get('represents')); - } - }, - - handleGraphicChange = async (obj, prev) => { - if(checked.includes(obj.get('represents'))){ return false; } - - let bar = 'bar'+state[state_name].config.bar+'_value', - target = state[state_name].config.send_reminder_to, - marker = state[state_name].config.statusmarker; - - if(prev && obj.get('status_'+marker) && obj.get(bar) < prev[bar]){ - let calc_DC = Math.floor((prev[bar] - obj.get(bar))/2), - DC = (calc_DC > 10) ? calc_DC : 10, - con_save_mod = parseInt(await getSheetItem(obj.get('represents'), state[state_name].config.bonus_attribute)) || 0, - chat_text; - - if(target === 'character'){ - chat_text = "Make a Concentration Check - DC " + DC + "."; - target = createWhisperName(obj.get('name')); - }else if(target === 'everyone'){ - chat_text = ''+obj.get('name')+' must make a Concentration Check - DC ' + DC + '.'; - target = ''; - }else{ - chat_text = ''+obj.get('name')+' must make a Concentration Check - DC ' + DC + '.'; - target = 'gm'; - } + }, + handleStatusMarkerChange = (obj, prev) => { + const marker = state[state_name].config.statusmarker; + let markerWasSet = prev && hasStatusMarker(prev.statusmarkers, marker); - if(state[state_name].config.show_roll_button){ - chat_text += '
' + makeButton('Advantage', '!' + state[state_name].config.command + ' advantage ' + obj.get('represents') + ' ' + DC + ' ' + con_save_mod + ' ' + obj.get('name') + ' ' + target, styles.button + styles.float.right); - chat_text += ' ' + makeButton('Roll', '!' + state[state_name].config.command + ' roll ' + obj.get('represents') + ' ' + DC + ' ' + con_save_mod + ' ' + obj.get('name') + ' ' + target, styles.button + styles.float.left); - } + if (markerWasSet && !obj.get('status_' + marker)) { + removeMarker(obj.get('represents'), 'graphic', obj); + } + }, + /** + * Checks whether a token update represents concentration-relevant HP loss. + * @param {Object} obj Roll20 token graphic object after change. + * @param {Object} prev Previous token state snapshot. + * @param {string} marker Configured concentration status marker. + * @param {string} bar Token bar property key being tracked. + * @returns {boolean} True when concentration checks should run. + */ + isConcentrationDamageEvent = (obj, prev, marker, bar) => { + return !!( + prev && + obj.get('status_' + marker) && + obj.get(bar) < prev[bar] + ); + }, + /** + * Builds reminder text and resolved whisper target for concentration checks. + * @param {string} tokenName Display token name. + * @param {number} DC Concentration check DC. + * @param {'everyone'|'character'|'gm'|string} target Configured reminder target. + * @returns {{chatText:string, target:string}} Reminder content with resolved chat target. + */ + buildConcentrationReminderMessage = (tokenName, DC, target) => { + let safeTokenName = escapeHtml(tokenName); + let chatText; + let whisperTarget; + + if (target === 'character') { + chatText = 'Make a Concentration Check - DC ' + DC + '.'; + whisperTarget = createWhisperName(tokenName); + } else if (target === 'everyone') { + chatText = + '' + + safeTokenName + + ' must make a Concentration Check - DC ' + + DC + + '.'; + whisperTarget = ''; + } else { + chatText = + '' + + safeTokenName + + ' must make a Concentration Check - DC ' + + DC + + '.'; + whisperTarget = 'gm'; + } - if(state[state_name].config.auto_roll_save){ - //&{template:default} {{name='+obj.get('name')+' - Concentration Save}} {{Modifier='+con_save_mod+'}} {{Roll=[[1d20cf<'+(DC-con_save_mod-1)+'cs>'+(DC-con_save_mod-1)+'+'+con_save_mod+']]}} {{DC='+DC+'}} - roll(obj.get('represents'), DC, con_save_mod, obj.get('name'), target, state[state_name].advantages[obj.get('represents')]); - }else{ - makeAndSendMenu(chat_text, '', target); - } + return { + chatText, + target: whisperTarget, + }; + }, + /** + * Adds Roll and Advantage buttons to a reminder message. + * @param {string} chatText Existing reminder text. + * @param {string} pendingRollId Pending roll identifier. + * @returns {string} Reminder text with action buttons appended. + */ + appendPendingRollButtons = (chatText, pendingRollId) => { + let withButtons = + chatText + + '
' + + makeButton( + 'Advantage', + '!' + + state[state_name].config.command + + ' advantage ' + + pendingRollId, + styles.button + styles.float.right, + ); + + withButtons += + ' ' + + makeButton( + 'Roll', + '!' + state[state_name].config.command + ' roll ' + pendingRollId, + styles.button + styles.float.left, + ); + + return withButtons; + }, + /** + * Prevents duplicate rapid concentration processing for the same token/character. + * @param {string|null} trackingKey Token or represented character tracking key. + */ + queueTrackingKey = (trackingKey) => { + if (!trackingKey) { + return; + } - let length = checked.push(obj.get('represents')); - setTimeout(() => { - checked.splice(length-1, 1); - }, 1000); + checked.push(trackingKey); + setTimeout(() => { + let index = checked.indexOf(trackingKey); + if (index !== -1) { + checked.splice(index, 1); + } + }, 1000); + }, + /** + * Handles concentration checks after tracked HP bar damage. + * @param {Object} obj Roll20 token graphic object after change. + * @param {Object} prev Previous token state snapshot. + */ + handleGraphicChange = async (obj, prev) => { + let trackingKey = getConcentrationTrackingKey(obj); + + if (trackingKey && checked.includes(trackingKey)) { + return false; } - }, - roll = (represents, DC, con_save_mod, name, target, advantage) => { - sendChat(script_name, '[[1d20cf<'+(DC-con_save_mod-1)+'cs>'+(DC-con_save_mod-1)+'+'+con_save_mod+']]', results => { - let title = 'Concentration Save
'+name+'', - advantageRollResult; + let bar = 'bar' + state[state_name].config.bar + '_value', + target = state[state_name].config.send_reminder_to, + marker = state[state_name].config.statusmarker; + + if (isConcentrationDamageEvent(obj, prev, marker, bar)) { + let calc_DC = Math.floor((prev[bar] - obj.get(bar)) / 2), + DC = Math.max(calc_DC, 10), + con_save_mod = await getConcentrationSaveModifier( + obj.get('represents'), + ), + tokenName = getTokenDisplayName(obj), + reminder = buildConcentrationReminderMessage(tokenName, DC, target), + chat_text = reminder.chatText; + + target = reminder.target; + + if (state[state_name].config.show_roll_button) { + let pendingRollId = createPendingRoll({ + represents: obj.get('represents') || null, + tokenId: obj.get('id'), + DC, + conSaveMod: con_save_mod, + name: tokenName, + target, + }); - let rollresult = results[0].inlinerolls[0].results.rolls[0].results[0].v; + chat_text = appendPendingRollButtons(chat_text, pendingRollId); + } + + if (state[state_name].config.auto_roll_save) { + roll( + obj.get('represents') || null, + DC, + con_save_mod, + tokenName, + target, + !!state[state_name].advantages[obj.get('represents')], + obj.get('id'), + ); + } else { + makeAndSendMenu(chat_text, '', target); + } + + queueTrackingKey(trackingKey); + } + }, + /** + * Rolls a concentration saving throw and reports success or failure. + * @param {string|null} represents Character id represented by the token. + * @param {number} DC Concentration check DC. + * @param {number} con_save_mod Concentration modifier. + * @param {string} name Display name for result output. + * @param {string} target Chat whisper target. + * @param {boolean} advantage Whether advantage applies. + * @param {string|null} tokenId Token id used for marker cleanup on failure. + */ + roll = ( + represents, + DC, + con_save_mod, + name, + target, + advantage, + tokenId, + ) => { + let safeName = escapeHtml(name); + sendChat( + script_name, + '[[1d20cf<' + + (DC - con_save_mod - 1) + + 'cs>' + + (DC - con_save_mod - 1) + + '+' + + con_save_mod + + ']]', + (results) => { + let title = + 'Concentration Save
' + + safeName + + '', + advantageRollResult; + + let rollresult = + results[0].inlinerolls[0].results.rolls[0].results[0].v; let result = rollresult; - if(advantage){ - advantageRollResult = randomInteger(20); - result = (rollresult <= advantageRollResult) ? advantageRollResult : rollresult; + if (advantage) { + advantageRollResult = randomInteger(20); + result = Math.max(rollresult, advantageRollResult); } let total = result + con_save_mod; let success = total >= DC; - let result_text = (success) ? 'Success' : 'Failed', - result_color = (success) ? 'green' : 'red'; - - let rollResultString = (advantage) ? rollresult + ' / ' + advantageRollResult : rollresult; - - let contents = ' \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ -
DC'+DC+'
Modifier'+con_save_mod+'
Roll Result'+rollResultString+'
\ -
\ - \ - [['+result+'+'+con_save_mod+']]

\ - '+result_text+' \ -
\ -
' + let result_text = success ? 'Success' : 'Failed', + result_color = success ? 'green' : 'red'; + + let rollResultString = advantage + ? rollresult + ' / ' + advantageRollResult + : rollresult; + + let contents = + '' + + '' + + '' + + '' + + '
DC' + + DC + + '
Modifier' + + con_save_mod + + '
Roll Result' + + rollResultString + + '
' + + '
' + + '' + + '[[' + + result + + '+' + + con_save_mod + + ']]

' + + result_text + + '
' + + '
'; makeAndSendMenu(contents, title, target); - if(target !== '' && target !== 'gm'){ - makeAndSendMenu(contents, title, 'gm'); + if (target !== '' && target !== 'gm') { + makeAndSendMenu(contents, title, 'gm'); } - if(!success){ - removeMarker(represents); + if (!success) { + removeMarker(represents, getObj('graphic', tokenId), 'graphic'); } - }); - }, + }, + ); + }, + removeMarker = (represents, currentObj, type = 'graphic') => { + let marker = 'status_' + state[state_name].config.statusmarker; + + if (represents) { + debugLog('Removing concentration marker', { + scope: 'represented-tokens', + represents, + }); + + findObjs({ type, represents }).forEach((obj) => { + if (obj.get(marker)) { + obj.set(marker, false); + } + }); - removeMarker = (represents, type='graphic') => { - findObjs({ type, represents }).forEach(o => { - o.set('status_'+state[state_name].config.statusmarker, false); - }); - }, + return; + } - createWhisperName = (name) => { - return name.split(' ').shift(); - }, + if (currentObj?.get(marker)) { + debugLog('Removing concentration marker', { + scope: 'single-token', + tokenId: currentObj.get('id'), + }); + currentObj.set(marker, false); + } + }, + createWhisperName = (name) => { + if (!name || typeof name !== 'string') { + return 'gm'; + } - ucFirst = (string) => { - return string.charAt(0).toUpperCase() + string.slice(1); - }, + let safeName = name.replace(/[<>"'`]/g, '').trim(); - sendConfigMenu = (first, message) => { + return (safeName || 'gm').split(' ').shift(); + }, + ucFirst = (string) => { + return string.charAt(0).toUpperCase() + string.slice(1); + }, + /** + * Renders and sends the GM configuration menu. + * @param {boolean} [first] Whether to show first-time setup title. + * @param {string|null} [message] Optional status or validation message. + */ + sendConfigMenu = (first, message) => { let markerDropdown = '?{Marker'; markers.forEach((marker) => { - markerDropdown += '|'+ucFirst(marker).replace('-', ' ')+','+marker - }) + markerDropdown += + '|' + ucFirst(marker).replace('-', ' ') + ',' + marker; + }); markerDropdown += '}'; - let markerButton = makeButton(state[state_name].config.statusmarker, '!' + state[state_name].config.command + ' config statusmarker|'+markerDropdown, styles.button + styles.float.right), - commandButton = makeButton('!'+state[state_name].config.command, '!' + state[state_name].config.command + ' config command|?{Command (without !)}', styles.button + styles.float.right), - barButton = makeButton('bar ' + state[state_name].config.bar, '!' + state[state_name].config.command + ' config bar|?{Bar|Bar 1 (green),1|Bar 2 (blue),2|Bar 3 (red),3}', styles.button + styles.float.right), - sendToButton = makeButton(state[state_name].config.send_reminder_to, '!' + state[state_name].config.command + ' config send_reminder_to|?{Send To|Everyone,everyone|Character,character|GM,gm}', styles.button + styles.float.right), - addConMarkerButton = makeButton(state[state_name].config.auto_add_concentration_marker, '!' + state[state_name].config.command + ' config auto_add_concentration_marker|'+!state[state_name].config.auto_add_concentration_marker, styles.button + styles.float.right), - autoRollButton = makeButton(state[state_name].config.auto_roll_save, '!' + state[state_name].config.command + ' config auto_roll_save|'+!state[state_name].config.auto_roll_save, styles.button + styles.float.right), - //advantageButton = makeButton(state[state_name].config.advantage, '!' + state[state_name].config.command + ' config advantage|'+!state[state_name].config.advantage, styles.button + styles.float.right), - bonusAttrButton = makeButton(state[state_name].config.bonus_attribute, '!' + state[state_name].config.command + ' config bonus_attribute|?{Attribute|'+state[state_name].config.bonus_attribute+'}', styles.button + styles.float.right), - showRollButtonButton = makeButton(state[state_name].config.show_roll_button, '!' + state[state_name].config.command + ' config show_roll_button|'+!state[state_name].config.show_roll_button, styles.button + styles.float.right), - - listItems = [ - 'Command: ' + commandButton, - 'Statusmarker: ' + markerButton, - 'HP Bar: ' + barButton, - 'Send Reminder To: ' + sendToButton, - 'Auto Add Con. Marker:

Works only for 5e OGL and 2024 Sheets.

' + addConMarkerButton, - 'Auto Roll Save: ' + autoRollButton, - ], - - resetButton = makeButton('Reset', '!' + state[state_name].config.command + ' reset', styles.button + styles.fullWidth), - - title_text = (first) ? script_name + ' First Time Setup' : script_name + ' Config'; - - /*if(state[state_name].config.auto_roll_save){ - listItems.push('Advantage: ' + advantageButton); - }*/ - - if(state[state_name].config.auto_roll_save){ - listItems.push('Bonus Attribute: ' + bonusAttrButton) + let markerButton = makeButton( + state[state_name].config.statusmarker, + '!' + + state[state_name].config.command + + ' config statusmarker|' + + markerDropdown, + styles.button + styles.float.right, + ), + commandButton = makeButton( + '!' + state[state_name].config.command, + '!' + + state[state_name].config.command + + ' config command|?{Command (without !)}', + styles.button + styles.float.right, + ), + barButton = makeButton( + 'bar ' + state[state_name].config.bar, + '!' + + state[state_name].config.command + + ' config bar|?{Bar|Bar 1 (green),1|Bar 2 (blue),2|Bar 3 (red),3}', + styles.button + styles.float.right, + ), + sendToButton = makeButton( + state[state_name].config.send_reminder_to, + '!' + + state[state_name].config.command + + ' config send_reminder_to|?{Send To|Everyone,everyone|Character,character|GM,gm}', + styles.button + styles.float.right, + ), + addConMarkerButton = makeButton( + state[state_name].config.auto_add_concentration_marker, + '!' + + state[state_name].config.command + + ' config auto_add_concentration_marker|' + + !state[state_name].config.auto_add_concentration_marker, + styles.button + styles.float.right, + ), + autoRollButton = makeButton( + state[state_name].config.auto_roll_save, + '!' + + state[state_name].config.command + + ' config auto_roll_save|' + + !state[state_name].config.auto_roll_save, + styles.button + styles.float.right, + ), + bonusAttrButton = makeButton( + state[state_name].config.bonus_attribute, + '!' + + state[state_name].config.command + + ' config bonus_attribute|?{Attribute|' + + state[state_name].config.bonus_attribute + + '}', + styles.button + styles.float.right, + ), + showRollButtonButton = makeButton( + state[state_name].config.show_roll_button, + '!' + + state[state_name].config.command + + ' config show_roll_button|' + + !state[state_name].config.show_roll_button, + styles.button + styles.float.right, + ), + debugButton = makeButton( + state[state_name].config.debug, + '!' + + state[state_name].config.command + + ' config debug|' + + !state[state_name].config.debug, + styles.button + styles.float.right, + ), + supportModeButton = makeButton( + state[state_name].config.support_mode, + '!' + + state[state_name].config.command + + ' config support_mode|?{Support Mode|Basic,basic|Detailed,detailed}', + styles.button + styles.float.right, + ), + listItems = [ + 'Command: ' + + commandButton, + 'Statusmarker: ' + + markerButton, + 'HP Bar: ' + + barButton, + 'Send Reminder To: ' + + sendToButton, + 'Auto Add Con. Marker:

Works only for 5e OGL and 2024 Sheets.

' + + addConMarkerButton, + 'Auto Roll Save: ' + + autoRollButton, + 'Debug Mode: ' + + debugButton, + 'Support Mode: ' + + supportModeButton, + ], + resetButton = makeButton( + 'Reset', + '!' + state[state_name].config.command + ' reset', + styles.button + styles.fullWidth, + ), + title_text = first + ? script_name + ' First Time Setup' + : script_name + ' Config'; + + if (state[state_name].config.auto_roll_save) { + listItems.push( + 'Bonus Attribute: ' + + bonusAttrButton, + ); } - if(!state[state_name].config.auto_roll_save){ - listItems.push('Roll Button: ' + showRollButtonButton); + if (!state[state_name].config.auto_roll_save) { + listItems.push( + 'Roll Button: ' + + showRollButtonButton, + ); } - let advantageMenuButton = (state[state_name].config.auto_roll_save) ? makeButton('Advantage Menu', '!' + state[state_name].config.command + ' advantage-menu', styles.button + styles.fullWidth) : ''; - - message = (message) ? '

'+message+'

' : ''; - let contents = message+makeList(listItems, styles.reset + styles.list + styles.overflow, styles.overflow)+'
'+advantageMenuButton+'

You can always come back to this config by typing `!'+state[state_name].config.command+' config`.


'+resetButton; + let advantageMenuButton = state[state_name].config.auto_roll_save + ? makeButton( + 'Advantage Menu', + '!' + state[state_name].config.command + ' advantage-menu', + styles.button + styles.fullWidth, + ) + : ''; + + message = message ? '

' + message + '

' : ''; + let contents = + message + + makeList( + listItems, + styles.reset + styles.list + styles.overflow, + styles.overflow, + ) + + '
' + + advantageMenuButton + + '

You can always come back to this config by typing `!' + + state[state_name].config.command + + ' config`.


' + + resetButton; makeAndSendMenu(contents, title_text, 'gm'); - }, - - sendAdvantageMenu = () => { - let menu_text = ""; + }, + sendAdvantageMenu = () => { + let menu_text = ''; let characters = findObjs({ type: 'character' }).sort((a, b) => { - let nameA = a.get('name').toUpperCase(); - let nameB = b.get('name').toUpperCase(); + let nameA = a.get('name').toUpperCase(); + let nameB = b.get('name').toUpperCase(); - if(nameA < nameB) return -1; - if(nameA > nameB) return 1; + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; - return 0; + return 0; }); - characters.forEach(character => { - let name = (state[state_name].advantages && state[state_name].advantages[character.get('id')]) ? ''+character.get('name')+'' : character.get('name'); - menu_text += makeButton(name, '!' + state[state_name].config.command + ' toggle-advantage ' + character.get('id'), styles.textButton) + '
'; + characters.forEach((character) => { + let safeName = escapeHtml(character.get('name') || 'Unnamed'); + let name = state[state_name].advantages?.[character.get('id')] + ? '' + safeName + '' + : safeName; + menu_text += + makeButton( + name, + '!' + + state[state_name].config.command + + ' toggle-advantage ' + + character.get('id'), + styles.textButton, + ) + '
'; }); makeAndSendMenu(menu_text, 'Advantage Menu', 'gm'); - }, - - makeAndSendMenu = (contents, title, whisper, callback) => { - title = (title && title != '') ? makeTitle(title) : ''; - whisper = (whisper && whisper !== '') ? '/w ' + whisper + ' ' : ''; - sendChat(script_name, whisper + '
'+title+contents+'
', null, {noarchive:true}); - }, - - makeTitle = (title) => { - return '

'+title+'

'; - }, - - makeButton = (title, href, style) => { - return ''+title+''; - }, - - makeList = (items, listStyle, itemStyle) => { - let list = '