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 = '';
+ items.forEach((item) => {
+ list += '- ' + item + '
';
+ });
+ 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.+?>(.*?))[1];
- spell_name = msg.content.match(/header__title.+?>(.*?))[1];
+
+ 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 {
- 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 = '