diff --git a/SwapTokenPositions/2.1.0/SwapTokenPositions.js b/SwapTokenPositions/2.1.0/SwapTokenPositions.js new file mode 100644 index 0000000000..4a487f4156 --- /dev/null +++ b/SwapTokenPositions/2.1.0/SwapTokenPositions.js @@ -0,0 +1,2262 @@ +/** + * NOTE: GENERATED FILE - DO NOT EDIT DIRECTLY. + * NOTE: Source files live under src/ and are bundled with `npm run build`. + * ------------------------------------------------ + * Name: SwapTokenPositions + * Script: SwapTokenPositions.js + * Built: 2026-05-19T19:15:31.630Z + */ +const SwapTokenPositionsMod = (() => { + 'use strict'; + + const SCRIPT_NAME = 'SwapTokenPositions'; + const SWAP_TOKEN_POSITIONS_VERSION = '2.1.0'; + const SWAP_TOKEN_POSITIONS_LAST_UPDATED = '19 May 2026'; + const COLOR_BG_SOFT_BLACK = '#0A0A12'; + const COLOR_TEXT_ARCANE_SILVER = '#E6DFFF'; + const COLOR_TEXT_DIM_SILVER = '#B8AFCF'; + const COLOR_ACCENT_PURPLE_LIGHT = '#FF4D6D'; + const COLOR_ACCENT_PURPLE_DARK = '#5B21B6'; + const COLOR_HEADER_PURPLE_LIGHT = '#E9D5FF'; + + const COLOR_INFO_LIGHT = '#DBEAFE'; + const COLOR_INFO_DARK = '#1E40AF'; + const COLOR_ERROR_RED = '#D32F2F'; + const COLOR_ERROR_DARK = '#B71C1C'; + const COLOR_ERROR_LIGHT = '#FFCDD2'; + const COLOR_ERROR_BG_LIGHT = '#FFEBEE'; + const COLOR_SUCCESS_GREEN = '#2E7D32'; + const COLOR_SUCCESS_DARK = '#1B5E20'; + const COLOR_SUCCESS_LIGHT = '#E8F5E9'; + const COLOR_SUCCESS_BG_LIGHT = '#F1F5FE'; + + const TIME_MIN = 0; + const TIME_MAX = 10; + const DELAY_MIN = 0; + const DELAY_MAX = 10; + + const ALLOWED_TRAVEL_FX = [ + 'none', + 'beam-magic', + 'beam-acid', + 'beam-charm', + 'beam-fire', + 'beam-frost', + 'beam-holy', + 'beam-death', + 'beam-energy', + 'beam-lightning', + ]; + + const ALLOWED_TRAVEL_MODES = ['normal', 'invisible']; + + const ALLOWED_TOKEN_INPUT_ACCESS_MODES = [ + 'gm-only', + 'all-players', + 'selected-users', + ]; + + const ALLOWED_POINT_FX = [ + 'none', + 'nova-magic', + 'nova-acid', + 'nova-charm', + 'nova-fire', + 'nova-frost', + 'nova-holy', + 'nova-death', + 'burst-magic', + 'burst-acid', + 'burst-charm', + 'burst-fire', + 'burst-frost', + 'burst-holy', + 'burst-death', + 'burst-energy', + 'burst-smoke', + 'explode-magic', + 'explode-acid', + 'explode-charm', + 'explode-fire', + 'explode-frost', + 'explode-holy', + 'explode-death', + 'burn-magic', + 'burn-acid', + 'burn-charm', + 'burn-fire', + 'burn-frost', + 'burn-holy', + 'burn-death', + 'splatter-magic', + 'splatter-acid', + 'splatter-charm', + 'splatter-fire', + 'splatter-frost', + 'splatter-holy', + 'splatter-death', + 'splatter-dark', + 'glow-magic', + 'glow-acid', + 'glow-charm', + 'glow-fire', + 'glow-frost', + 'glow-holy', + 'glow-death', + ]; + + const FX_PRESETS = { + portal: { + originFx: 'nova-magic', + travelFx: 'beam-magic', + destinationFx: 'burst-holy', + originTime: 1, + travelTime: 1, + destinationTime: 0.5, + swapDelay: 0.5, + destinationDelay: 1, + travelMode: 'normal', + }, + lightning: { + originFx: 'none', + travelFx: 'beam-holy', + destinationFx: 'burst-holy', + originTime: 0, + travelTime: 0.3, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0.3, + travelMode: 'normal', + }, + shadow: { + originFx: 'burst-smoke', + travelFx: 'none', + destinationFx: 'burst-smoke', + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + travelMode: 'normal', + }, + fire: { + originFx: 'explode-fire', + travelFx: 'none', + destinationFx: 'explode-fire', + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + travelMode: 'normal', + }, + magic: { + originFx: 'nova-magic', + travelFx: 'none', + destinationFx: 'burst-magic', + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + travelMode: 'normal', + }, + transport: { + originFx: 'glow-magic', + travelFx: 'none', + destinationFx: 'glow-magic', + originTime: 0.55, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.15, + destinationDelay: 0.05, + travelMode: 'invisible', + }, + none: { + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', + originTime: 0, + travelTime: 0, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0, + travelMode: 'normal', + }, + }; + + const ALLOWED_PRESETS = Object.keys(FX_PRESETS); + + const FACTORY_DEFAULTS = { + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', + originTime: 0, + travelTime: 0, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0, + travelMode: 'normal', + tokenInputAccess: 'gm-only', + tokenInputUsers: [], + }; + + const FLAG_HELP = /--help\b/i; + const FLAG_SHOW_SETTINGS = /--show-settings\b/i; + const FLAG_CHECK_SETTINGS = /--check-settings\b/i; + const FLAG_RESET_SETTINGS = /--reset-settings\b/i; + const FLAG_SAVE = /--save\b/i; + const FLAG_INSTALL_MACRO = /--install-macro\b/i; + + const FLAG_INSTANT = /--instant\b/i; + const FLAG_PRESET = /--preset\b/i; + const FLAG_ORIGIN_FX = /--origin-fx\b/i; + const FLAG_TRAVEL_FX = /--travel-fx\b/i; + const FLAG_DESTINATION_FX = /--destination-fx\b/i; + const FLAG_ORIGIN_TIME = /--origin-time\b/i; + const FLAG_TRAVEL_TIME = /--travel-time\b/i; + const FLAG_TRAVEL_MODE = /--travel-mode\b/i; + const FLAG_DESTINATION_TIME = /--destination-time\b/i; + const FLAG_SWAP_DELAY = /--swap-delay\b/i; + const FLAG_DESTINATION_DELAY = /--destination-delay\b/i; + + const FLAG_LEGACY_BEAM_FX = /--beam-fx\b/i; + const FLAG_LEGACY_BURST_FX = /--burst-fx\b/i; + const FLAG_LEGACY_DURATION = /--duration\b/i; + const FLAG_LEGACY_MODE = /--mode\b/i; + + const FLAG_TOKEN1 = /--token1\b/i; + const FLAG_TOKEN2 = /--token2\b/i; + + const FLAG_TOKEN_INPUT_ACCESS = /--token-input-access\b/i; + const FLAG_TOKEN_INPUT_USERS_REMOVE = /--token-input-users-remove\b/i; + // Negative lookahead prevents matching --token-input-users-remove. + const FLAG_TOKEN_INPUT_USERS = /--token-input-users(?!-)/i; + + const MANAGEMENT_FLAGS = [ + FLAG_SHOW_SETTINGS, + FLAG_CHECK_SETTINGS, + FLAG_RESET_SETTINGS, + FLAG_INSTALL_MACRO, + FLAG_TOKEN_INPUT_ACCESS, + FLAG_TOKEN_INPUT_USERS_REMOVE, + FLAG_TOKEN_INPUT_USERS, + ]; + + const SILENT_MANAGEMENT_FLAGS = [ + FLAG_HELP, + FLAG_SHOW_SETTINGS, + FLAG_CHECK_SETTINGS, + FLAG_RESET_SETTINGS, + FLAG_INSTALL_MACRO, + FLAG_TOKEN_INPUT_ACCESS, + FLAG_TOKEN_INPUT_USERS_REMOVE, + FLAG_TOKEN_INPUT_USERS, + ]; + + /** + * Escapes HTML-sensitive characters for safe chat rendering. + * + * @param {string} value Text to escape. + * @returns {string} Escaped text. + */ + function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + /** + * Builds a safe display name for a token in chat output. + * + * @param {object} token Roll20 graphic token object. + * @param {string} fallback Fallback label when token has no name. + * @returns {string} Escaped token display name. + */ + function getSafeTokenName(token, fallback) { + const name = token.get('name'); + return escapeHtml(name?.trim() ? name : fallback); + } + + /** + * Builds the standard styled chat message container. + * + * @param {string} msg Message body as HTML. + * @param {"left"|"center"|"right"} [align="center"] Content alignment. + * @param {string} [header=""] Optional header label. + * @returns {string} HTML for a styled chat card. + */ + function generateStyledMessage(msg, align = 'center', header = '') { + const padding = align === 'center' ? '3px 0px' : '3px 8px'; + const isScriptReadyHeader = header === 'Script Ready'; + const headerBackground = isScriptReadyHeader + ? COLOR_HEADER_PURPLE_LIGHT + : COLOR_INFO_LIGHT; + const headerTextColor = isScriptReadyHeader + ? COLOR_BG_SOFT_BLACK + : COLOR_INFO_DARK; + const headerLabel = isScriptReadyHeader + ? `😎 ${header} 😎` + : `â„šī¸ ${header}`; + const mainStyle = [ + 'width:100%', + 'border-radius:4px', + `box-shadow:1px 1px 1px ${COLOR_TEXT_DIM_SILVER}`, + `text-align:${align}`, + 'vertical-align:middle', + 'margin:0px auto', + `border:1px solid ${COLOR_BG_SOFT_BLACK}`, + `color:${COLOR_TEXT_ARCANE_SILVER}`, + `background-image:-webkit-linear-gradient(-45deg,${COLOR_ACCENT_PURPLE_DARK} 0%,${COLOR_ACCENT_PURPLE_LIGHT} 100%)`, + 'overflow:hidden', + ].join(';'); + + const headerHtml = header + ? `
${headerLabel}
` + : ''; + const contentHtml = `
${msg}
`; + + return `
${headerHtml}${contentHtml}
`; + } + + /** + * Builds a red error variant of the styled chat container. + * + * @param {string} msg Error body as HTML. + * @param {string} [header="Error"] Optional header label. + * @param {"left"|"center"|"right"} [align="left"] Content alignment. + * @returns {string} HTML for an error-styled chat card. + */ + function generateStyledErrorMessage(msg, header = 'Error', align = 'left') { + const mainStyle = [ + 'width:100%', + 'border-radius:4px', + `box-shadow:1px 1px 1px ${COLOR_ERROR_RED}`, + `text-align:${align}`, + 'vertical-align:middle', + 'margin:0px auto', + `border:1px solid ${COLOR_ERROR_DARK}`, + `color:${COLOR_ERROR_LIGHT}`, + `background-color:${COLOR_ERROR_DARK}`, + `background-image:-webkit-linear-gradient(-45deg,${COLOR_ERROR_DARK} 0%,${COLOR_ERROR_RED} 100%)`, + 'overflow:hidden', + ].join(';'); + + const headerHtml = `
âš ī¸ ${header}
`; + const contentHtml = `
${msg}
`; + + return `
${headerHtml}${contentHtml}
`; + } + + /** + * Builds a green success variant of the styled chat container. + * + * @param {string} msg Success body as HTML. + * @param {string} [header="Success"] Optional header label. + * @returns {string} HTML for a success-styled chat card. + */ + function generateStyledSuccessMessage(msg, header = 'Success') { + const mainStyle = [ + 'width:100%', + 'border-radius:4px', + `box-shadow:1px 1px 1px ${COLOR_SUCCESS_GREEN}`, + 'text-align:center', + 'vertical-align:middle', + 'margin:0px auto', + `border:1px solid ${COLOR_SUCCESS_DARK}`, + `color:${COLOR_SUCCESS_LIGHT}`, + `background-image:-webkit-linear-gradient(-45deg,${COLOR_SUCCESS_DARK} 0%,${COLOR_SUCCESS_GREEN} 100%)`, + 'overflow:hidden', + ].join(';'); + + const headerHtml = `
✅ ${header}
`; + const contentHtml = `
${msg}
`; + + return `
${headerHtml}${contentHtml}
`; + } + + /** + * Whispers a styled message card to the GM. + * + * @param {string} msg Message body as HTML. + * @param {string} [header=""] Optional header label. + * @param {"left"|"center"|"right"} [align="center"] Content alignment. + * @returns {void} + */ + function whisperGM(msg, header = '', align = 'center') { + sendChat(SCRIPT_NAME, `/w GM ${generateStyledMessage(msg, align, header)}`); + } + + /** + * Whispers a styled message card to the user that sent the command. + * + * @param {object} msgObj Roll20 chat message object. + * @param {string} text Message body as HTML. + * @param {string} [header=""] Optional header label. + * @param {"left"|"center"|"right"} [align="center"] Content alignment. + * @returns {void} + */ + function whisperSender(msgObj, text, header = '', align = 'center') { + const player = getObj('player', msgObj.playerid); + const name = player ? player.get('_displayname') : msgObj.who; + sendChat( + SCRIPT_NAME, + `/w "${name}" ${generateStyledMessage(text, align, header)}`, + ); + } + + /** + * Whispers an error-styled message card to the user that sent the command. + * + * @param {object} msgObj Roll20 chat message object. + * @param {string} text Error body as HTML. + * @param {string} [header="Error"] Optional header label. + * @param {"left"|"center"|"right"} [align="left"] Content alignment. + * @returns {void} + */ + function whisperSenderError(msgObj, text, header = 'Error', align = 'left') { + const player = getObj('player', msgObj.playerid); + const name = player ? player.get('_displayname') : msgObj.who; + sendChat( + SCRIPT_NAME, + `/w "${name}" ${generateStyledErrorMessage(text, header, align)}`, + ); + } + + /** + * Whispers a success-styled message card to the GM. + * + * @param {string} text Success body as HTML. + * @param {string} [header="Success"] Optional header label. + * @returns {void} + */ + function whisperGMSuccess(text, header = 'Success') { + sendChat( + SCRIPT_NAME, + `/w GM ${generateStyledSuccessMessage(text, header)}`, + ); + } + + /** + * Whispers an error-styled message card to the GM. + * + * @param {string} text Error body as HTML. + * @param {string} [header="Error"] Optional header label. + * @param {"left"|"center"|"right"} [align="left"] Content alignment. + * @returns {void} + */ + function whisperGMError(text, header = 'Error', align = 'left') { + sendChat( + SCRIPT_NAME, + `/w GM ${generateStyledErrorMessage(text, header, align)}`, + ); + } + + /** + * Parses a comma-separated list flag, supporting quoted and bare entries. + * + * Each member may be single-quoted, double-quoted, or bare (no commas within). + * Empty members and whitespace-only entries are filtered silently. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @returns {{found:boolean, values:string[]}} Parse result. + */ + function parseCommaListFlag(content, flagRegex) { + const match = new RegExp( + String.raw`${flagRegex.source}\s+(.+?)(?=\s+--|$)`, + 'i', + ).exec(content); + if (!match) { + return { found: false, values: [] }; + } + + const values = []; + for (const part of match[1].trim().split(',')) { + const trimmed = part + .trim() + .replace(/^(['"])(.*)\1$/, '$2') + .trim(); + if (trimmed) { + values.push(trimmed); + } + } + + return { found: true, values }; + } + + /** + * Parses a string flag whose value may be quoted (allowing spaces) or unquoted. + * + * Supports single-quoted, double-quoted, and bare (no-whitespace) values. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @returns {{found:boolean, value:(string|null)}} Parse result. + */ + function parseFreeStringFlag(content, flagRegex) { + const match = new RegExp( + String.raw`${flagRegex.source}\s+(?:"([^"]+)"|'([^']+)'|(\S+))`, + 'i', + ).exec(content); + if (!match) { + return { found: false, value: null }; + } + const value = (match[1] ?? match[2] ?? match[3]).trim(); + return { found: true, value }; + } + + /** + * Parses a string flag and validates it against an allowed set. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @param {string[]} allowedValues Allowed lower-case values. + * @returns {{found:boolean, valid:boolean, value:(string|null)}} Parse result. + */ + function parseStringFlag(content, flagRegex, allowedValues) { + const match = new RegExp(String.raw`${flagRegex.source}\s+(\S+)`, 'i').exec( + content, + ); + if (!match) { + return { found: false, valid: false, value: null }; + } + const normalized = match[1] + .trim() + .replaceAll(/(^['"]|['"]$)/g, '') + .replaceAll(/[.,;]+$/g, '') + .toLowerCase(); + if (allowedValues.includes(normalized)) { + return { found: true, valid: true, value: normalized }; + } + return { found: true, valid: false, value: match[1] }; + } + + /** + * Parses a numeric flag and validates it against an inclusive range. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @param {number} min Minimum allowed value. + * @param {number} max Maximum allowed value. + * @returns {{found:boolean, valid:boolean, value:(number|null)}} Parse result. + */ + function parseFloatFlag(content, flagRegex, min, max) { + const match = new RegExp( + String.raw`${flagRegex.source}\s+([\d.]+)`, + 'i', + ).exec(content); + if (!match) { + return { found: false, valid: false, value: null }; + } + const value = Number.parseFloat(match[1]); + if (!Number.isNaN(value) && value >= min && value <= max) { + return { found: true, valid: true, value }; + } + return { found: true, valid: false, value: null }; + } + + /** + * Applies a parsed string flag result to config and update tracking. + * + * @param {{found:boolean, valid:boolean, value:(string|null)}} result Parse result. + * @param {string} key Config key to set. + * @param {object} config Mutable config object. + * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters. + * @param {object} msg Roll20 chat message object. + * @param {string} errorMsg Error message shown when invalid. + * @returns {void} + */ + function applyStringFlagResult( + result, + key, + config, + updateTracker, + msg, + errorMsg, + ) { + if (result.valid) { + config[key] = result.value; + updateTracker.valid++; + } else { + updateTracker.invalid++; + whisperSenderError(msg, errorMsg, 'Invalid Input'); + } + } + + /** + * Applies a parsed numeric flag result to config and update tracking. + * + * @param {{found:boolean, valid:boolean, value:(number|null)}} result Parse result. + * @param {string} key Config key to set. + * @param {object} config Mutable config object. + * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters. + * @param {object} msg Roll20 chat message object. + * @param {string} label Human-readable field label. + * @param {{min:number,max:number}} range Allowed numeric range. + * @returns {void} + */ + function applyNumericFlagResult( + result, + key, + config, + updateTracker, + msg, + label, + range, + ) { + if (result.valid) { + config[key] = result.value; + updateTracker.valid++; + } else { + updateTracker.invalid++; + whisperSenderError( + msg, + `Invalid ${label}: must be between ${range.min} and ${range.max} seconds.`, + 'Invalid Input', + ); + } + } + + /** + * Parses and applies a collection of string flags. + * + * @param {string} content Full command content. + * @param {Array<{flag:RegExp,key:string,allowed:string[],label:string}>} flagConfigs Flag specs. + * @param {object} config Mutable config object. + * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters. + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function processStringFlags( + content, + flagConfigs, + config, + updateTracker, + msg, + ) { + for (const { flag, key, allowed, label } of flagConfigs) { + const result = parseStringFlag(content, flag, allowed); + if (!result.found) { + continue; + } + const errorMsg = `Invalid ${label}: '${result.value}'.

Valid: ${allowed.join(', ')}`; + applyStringFlagResult(result, key, config, updateTracker, msg, errorMsg); + } + } + + /** + * Parses and applies a collection of numeric flags. + * + * @param {string} content Full command content. + * @param {Array<{flag:RegExp,key:string,label:string,min:number,max:number}>} flagConfigs Flag specs. + * @param {(content:string, flagRegex:RegExp, min:number, max:number)=>{found:boolean, valid:boolean, value:(number|null)}} parseFunc Numeric parser. + * @param {object} config Mutable config object. + * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters. + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function processNumericFlags( + content, + flagConfigs, + parseFunc, + config, + updateTracker, + msg, + ) { + for (const { flag, key, label, min, max } of flagConfigs) { + const result = parseFunc(content, flag, min, max); + if (!result.found) { + continue; + } + applyNumericFlagResult(result, key, config, updateTracker, msg, label, { + min, + max, + }); + } + } + + /** + * Ensures persisted script settings exist and backfills missing keys with defaults. + * + * @returns {void} + */ + function initializeState() { + if (!state.SwapTokenPositions) { + state.SwapTokenPositions = {}; + } + for (const [key, value] of Object.entries(FACTORY_DEFAULTS)) { + if (state.SwapTokenPositions[key] === undefined) { + state.SwapTokenPositions[key] = value; + } + } + } + + /** + * Retrieves persisted script settings from Roll20 state. + * + * @returns {object} Effective script settings object. + */ + function getSettings() { + return state.SwapTokenPositions; + } + + /** + * Renders the current persisted settings to GM chat. + * + * @returns {void} + */ + function showSettings() { + const settings = getSettings(); + + const userListLine = + settings.tokenInputAccess === 'selected-users' + ? `Token Input Users: ${formatTokenInputUsers(settings.tokenInputUsers)}
` + : ''; + + const settingsMsg = [ + `Origin FX: ${settings.originFx}
`, + `Travel FX: ${settings.travelFx}
`, + `Travel Mode: ${settings.travelMode}
`, + `Destination FX: ${settings.destinationFx}
`, + `Origin Time: ${settings.originTime}s
`, + `Travel Time: ${settings.travelTime}s
`, + `Destination Time: ${settings.destinationTime}s
`, + `Swap Delay: ${settings.swapDelay}s
`, + `Destination Delay: ${settings.destinationDelay}s
`, + `Token Input Access: ${settings.tokenInputAccess}
`, + userListLine, + ].join(''); + whisperGM(settingsMsg, 'Persistent Settings', 'left'); + } + + /** + * Formats a list of player IDs as human-readable names with ID fallback. + * + * @param {string[]} ids Player ID array from persistent state. + * @returns {string} Comma-separated display names, or "(none)" when empty. + */ + function formatTokenInputUsers(ids) { + if (!ids || ids.length === 0) { + return '(none)'; + } + return ids + .map((id) => { + const player = getObj('player', id); + return player ? `${player.get('_displayname')} (${id})` : id; + }) + .join(', '); + } + + /** + * Resets persisted script settings to factory defaults. + * + * @returns {void} + */ + function resetSettings() { + state.SwapTokenPositions = { ...FACTORY_DEFAULTS }; + whisperGM( + 'Settings reset to factory defaults.', + 'Settings Reset', + ); + showSettings(); + } + + /** + * Validates persisted settings for supported FX values and timing ranges. + * + * @param {boolean} [silentOnSuccess=false] When true, success output is suppressed. + * @returns {boolean} True when settings are valid; otherwise false. + */ + function validateSettings(silentOnSuccess = false) { + const settings = getSettings(); + const errors = []; + + if (!ALLOWED_TOKEN_INPUT_ACCESS_MODES.includes(settings.tokenInputAccess)) { + errors.push( + `Token Input Access '${settings.tokenInputAccess}' is no longer valid.`, + ); + } + if ( + !Array.isArray(settings.tokenInputUsers) || + settings.tokenInputUsers.some( + (entry) => typeof entry !== 'string' || entry.length === 0, + ) + ) { + errors.push('Token Input Users contains invalid entries.'); + } + + if (!ALLOWED_POINT_FX.includes(settings.originFx)) { + errors.push(`Origin FX '${settings.originFx}' is no longer valid.`); + } + if (!ALLOWED_TRAVEL_FX.includes(settings.travelFx)) { + errors.push(`Travel FX '${settings.travelFx}' is no longer valid.`); + } + if (!ALLOWED_TRAVEL_MODES.includes(settings.travelMode)) { + errors.push(`Travel Mode '${settings.travelMode}' is no longer valid.`); + } + if (!ALLOWED_POINT_FX.includes(settings.destinationFx)) { + errors.push( + `Destination FX '${settings.destinationFx}' is no longer valid.`, + ); + } + + const timingFields = [ + { key: 'originTime', label: 'Origin Time', min: TIME_MIN, max: TIME_MAX }, + { key: 'travelTime', label: 'Travel Time', min: TIME_MIN, max: TIME_MAX }, + { + key: 'destinationTime', + label: 'Destination Time', + min: TIME_MIN, + max: TIME_MAX, + }, + { key: 'swapDelay', label: 'Swap Delay', min: DELAY_MIN, max: DELAY_MAX }, + { + key: 'destinationDelay', + label: 'Destination Delay', + min: DELAY_MIN, + max: DELAY_MAX, + }, + ]; + + for (const { key, label, min, max } of timingFields) { + const value = settings[key]; + if (typeof value !== 'number' || value < min || value > max) { + errors.push(`${label} (${value}) is out of range (${min}-${max}).`); + } + } + + if (errors.length > 0) { + const errorMsg = [ + 'Validation Issues Found:
', + errors.map((error) => `• ${error}`).join('
'), + '
Try running !swap-tokens --reset-settings to fix these issues.', + ].join(''); + whisperGMError(errorMsg, 'Settings Validation'); + return false; + } + + if (!silentOnSuccess) { + whisperGMSuccess( + 'All persistent settings are valid.', + 'Settings Validation', + ); + } + return true; + } + + /** + * Applies deprecated flags to the active config while emitting compatibility warnings. + * + * @param {object} msg Roll20 chat message object. + * @param {object} config Mutable config object. + * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters. + * @returns {void} + */ + function applyLegacyFlags(msg, config, updateTracker) { + const content = msg.content; + const legacyModeToPreset = { + beams: 'lightning', + transport: 'transport', + }; + + const modeResult = parseStringFlag( + content, + FLAG_LEGACY_MODE, + Object.keys(legacyModeToPreset), + ); + + if (modeResult.found) { + if (modeResult.valid) { + const mappedPreset = legacyModeToPreset[modeResult.value]; + whisperSender( + msg, + `--mode is deprecated. Use --preset ${mappedPreset} instead.`, + 'Deprecated Flag', + 'left', + ); + Object.assign(config, FX_PRESETS[mappedPreset]); + updateTracker.valid++; + } else { + updateTracker.invalid++; + whisperSenderError( + msg, + `Invalid value for deprecated --mode: '${modeResult.value}'.

Valid: ${Object.keys(legacyModeToPreset).join(', ')}`, + 'Invalid Input', + ); + } + } + + const fxMappings = [ + { + flag: FLAG_LEGACY_BEAM_FX, + key: 'travelFx', + allowed: ALLOWED_TRAVEL_FX, + oldName: '--beam-fx', + newName: '--travel-fx', + }, + { + flag: FLAG_LEGACY_BURST_FX, + key: 'destinationFx', + allowed: ALLOWED_POINT_FX, + oldName: '--burst-fx', + newName: '--destination-fx', + }, + ]; + + for (const { flag, key, allowed, oldName, newName } of fxMappings) { + const result = parseStringFlag(content, flag, allowed); + if (!result.found) { + continue; + } + whisperSender( + msg, + `${oldName} is deprecated. Use ${newName} instead.`, + 'Deprecated Flag', + 'left', + ); + if (result.valid) { + config[key] = result.value; + updateTracker.valid++; + } else { + updateTracker.invalid++; + whisperSenderError( + msg, + `Invalid value for deprecated ${oldName}: '${result.value}'.

Valid: ${allowed.join(', ')}`, + 'Invalid Input', + ); + } + } + + const durationResult = parseFloatFlag( + content, + FLAG_LEGACY_DURATION, + DELAY_MIN, + DELAY_MAX, + ); + if (durationResult.found) { + whisperSender( + msg, + '--duration is deprecated. Use --swap-delay instead.', + 'Deprecated Flag', + 'left', + ); + if (durationResult.valid) { + config.swapDelay = durationResult.value; + updateTracker.valid++; + } else { + updateTracker.invalid++; + whisperSenderError( + msg, + `Invalid value for deprecated --duration: must be between ${DELAY_MIN} and ${DELAY_MAX} seconds.`, + 'Invalid Input', + ); + } + } + } + + /** + * Applies a preset configuration layer when the preset flag is present. + * + * @param {object} msg Roll20 chat message object. + * @param {string} content Full command content. + * @param {object} config Mutable config object. + * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters. + * @returns {void} + */ + function applyPresetLayer(msg, content, config, updateTracker) { + const presetResult = parseStringFlag(content, FLAG_PRESET, ALLOWED_PRESETS); + if (!presetResult.found) { + return; + } + if (presetResult.valid) { + Object.assign(config, FX_PRESETS[presetResult.value]); + updateTracker.valid++; + } else { + updateTracker.invalid++; + whisperSenderError( + msg, + `Invalid preset: '${presetResult.value}'.

Valid presets: ${ALLOWED_PRESETS.join(', ')}`, + 'Invalid Input', + ); + } + } + + /** + * Builds the final swap configuration by layering settings, preset, and explicit flags. + * + * @param {object} msg Roll20 chat message object. + * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters. + * @returns {object} Effective swap configuration. + */ + function buildSwapConfig(msg, updateTracker) { + const content = msg.content; + const config = { ...getSettings() }; + + applyPresetLayer(msg, content, config, updateTracker); + applyLegacyFlags(msg, config, updateTracker); + + const fxFlags = [ + { + flag: FLAG_ORIGIN_FX, + key: 'originFx', + allowed: ALLOWED_POINT_FX, + label: 'Origin FX', + }, + { + flag: FLAG_TRAVEL_FX, + key: 'travelFx', + allowed: ALLOWED_TRAVEL_FX, + label: 'Travel FX', + }, + { + flag: FLAG_TRAVEL_MODE, + key: 'travelMode', + allowed: ALLOWED_TRAVEL_MODES, + label: 'Travel Mode', + }, + { + flag: FLAG_DESTINATION_FX, + key: 'destinationFx', + allowed: ALLOWED_POINT_FX, + label: 'Destination FX', + }, + ]; + processStringFlags(content, fxFlags, config, updateTracker, msg); + + const timeFlags = [ + { + flag: FLAG_ORIGIN_TIME, + key: 'originTime', + label: 'Origin Time', + min: TIME_MIN, + max: TIME_MAX, + }, + { + flag: FLAG_TRAVEL_TIME, + key: 'travelTime', + label: 'Travel Time', + min: TIME_MIN, + max: TIME_MAX, + }, + { + flag: FLAG_DESTINATION_TIME, + key: 'destinationTime', + label: 'Destination Time', + min: TIME_MIN, + max: TIME_MAX, + }, + ]; + processNumericFlags( + content, + timeFlags, + parseFloatFlag, + config, + updateTracker, + msg, + ); + + const delayFlags = [ + { + flag: FLAG_SWAP_DELAY, + key: 'swapDelay', + label: 'Swap Delay', + min: DELAY_MIN, + max: DELAY_MAX, + }, + { + flag: FLAG_DESTINATION_DELAY, + key: 'destinationDelay', + label: 'Destination Delay', + min: DELAY_MIN, + max: DELAY_MAX, + }, + ]; + processNumericFlags( + content, + delayFlags, + parseFloatFlag, + config, + updateTracker, + msg, + ); + + return config; + } + + /** + * Sends full command and option help text to the invoking player. + * + * @param {object} msgObj Roll20 chat message object. + * @returns {void} + */ + function showHelp(msgObj) { + const helpMsg = [ + `SwapTokenPositions v${SWAP_TOKEN_POSITIONS_VERSION}
`, + `Last Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}
`, + '
Basic Usage:
', + '!swap-tokens — Instant swap of 2 selected tokens.
', + '!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
', + '!swap-tokens --help — Show this help message (available to all players).
', + '
Explicit Token Targeting (Macro-Friendly):
', + 'Use both flags together to target tokens without selection.
', + '--token1 <id|name> — First token, resolved by ID then by name on the active page.
', + '--token2 <id|name> — Second token, resolved by ID then by name on the active page.
', + 'Quote names that contain spaces: --token1 "Goblin A"
', + 'When a name matches multiple tokens, use the token ID instead.
', + '
FX Stages:
', + 'Pipeline order: Origin FX → Travel FX → Swap → Destination FX.
', + '--origin-fx <type> — FX at both original positions before movement.
', + '--travel-fx <type> — FX between tokens during transition.
', + '--travel-mode <normal|invisible> — Keep tokens visible during travel or hide them until reveal.
', + '--destination-fx <type> — FX at both new positions after swap.
', + '
Stage Timing:
', + `--origin-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Origin FX before continuing.
`, + `--travel-time <${TIME_MIN}-${TIME_MAX}> — Duration (s) of the travel animation stage.
`, + `--destination-time <${TIME_MIN}-${TIME_MAX}> — Additional wait (s) before Destination FX is shown.
`, + '
Delays:
', + `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, + `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause before Destination FX is shown.
`, + '
Presets:
', + `--preset <name> — Apply a preset. Valid: ${ALLOWED_PRESETS.join(', ')}
`, + '• portal — Magical portal teleport (nova, beam, burst).
', + '• lightning — Fast lightning strike (beam, burst).
', + '• shadow — Dark shadow blink (splatter, no travel FX).
', + '• fire — Fiery explosion swap (explode, no travel FX).
', + '• magic — Arcane sparkle swap (nova, burst).
', + '• transport — Starship transport shimmer (invisible travel reveal).
', + '• none — No FX, equivalent to instant mode.
', + 'Explicit flags override preset values. Example: --preset portal --travel-time 3
', + '
Global Configuration (GM Only):
', + '--save — Commit provided flags as the new global defaults.
', + '--show-settings — View current persistent defaults.
', + '--reset-settings — Restore all factory defaults.
', + "--install-macro — Create a global 'SwapTokens' macro.
", + '
Explicit Token Access Control (GM Only):
', + 'Controls who may use --token1 and --token2. Takes effect immediately.
', + '--token-input-access <mode> — Set access mode. Valid: gm-only (default), all-players, selected-users.
', + '--token-input-users <id|name,...> — Replace the allow-list (used with selected-users mode).
', + '--token-input-users-remove <id|name,...> — Remove specific players from the allow-list.
', + 'Names containing spaces must be quoted. Comma-separated entries are supported.
', + 'The GM is always permitted regardless of access mode.
', + '
Examples:
', + '!swap-tokens
', + '!swap-tokens --preset portal
', + '!swap-tokens --preset transport
', + '!swap-tokens --preset portal --travel-time 3
', + '!swap-tokens --origin-fx nova-magic --swap-delay 1 --destination-fx burst-holy
', + '!swap-tokens --preset lightning --save
', + '!swap-tokens --token1 -Kabc123 --token2 -Kdef456
', + '!swap-tokens --token1 "Goblin A" --token2 "Goblin B"
', + '!swap-tokens --token1 -Kabc123 --token2 "Goblin B" --preset portal
', + '!swap-tokens --token-input-access all-players
', + '!swap-tokens --token-input-access selected-users
', + '!swap-tokens --token-input-users "Alice","Bob"
', + '!swap-tokens --token-input-users-remove Alice
', + ].join(''); + + whisperSender(msgObj, helpMsg, 'SwapTokenPositions Help', 'left'); + } + + /** + * Spawns a point FX on a page when enabled. + * + * @param {number} x X coordinate. + * @param {number} y Y coordinate. + * @param {string} fxType Roll20 FX type. + * @param {string} pageId Roll20 page id. + * @returns {void} + */ + function spawnPointFx(x, y, fxType, pageId) { + if (fxType === 'none') { + return; + } + try { + spawnFx(x, y, fxType, pageId); + } catch (error) { + log( + `SwapTokenPositions: Point FX failed, but swap will continue: ${error.message}`, + ); + } + } + + /** + * Spawns travel FX between two positions when enabled. + * + * @param {{left:number, top:number, page:string}} pos1 Source position. + * @param {{left:number, top:number, page:string}} pos2 Destination position. + * @param {string} fxType Roll20 FX type. + * @returns {void} + */ + function spawnTravelFx(pos1, pos2, fxType) { + if (fxType === 'none') { + return; + } + try { + spawnFxBetweenPoints( + { x: pos1.left, y: pos1.top, pageid: pos1.page }, + { x: pos2.left, y: pos2.top, pageid: pos2.page }, + fxType, + ); + } catch (error) { + log( + `SwapTokenPositions: Travel FX failed, but swap will continue: ${error.message}`, + ); + } + } + + /** + * Resolves a token object by its Roll20 graphic ID. + * + * @param {string} id Token graphic ID. + * @returns {object|null} Token object or null when not found. + */ + function resolveTokenById(id) { + return getObj('graphic', id) ?? null; + } + + /** + * Finds all graphic tokens with a given name on a specific page. + * + * @param {string} name Token name to search for. + * @param {string} pageId Page to search on. + * @returns {object[]} Array of matching token objects. + */ + function resolveTokensByName(name, pageId) { + return findObjs({ type: 'graphic', pageid: pageId, name }).filter( + (t) => t.get('subtype') === 'token', + ); + } + + /** + * Resolves a token from an input string by ID first, then by name on the active page. + * + * @param {string} input Token ID or name to resolve. + * @param {string} pageId Active page ID to scope name lookups. + * @returns {{token:(object|null), error:(string|null)}} Resolved token or a targeted error message. + */ + function resolveTokenInput(input, pageId) { + const byId = resolveTokenById(input); + if (byId) { + return { token: byId, error: null }; + } + + const byName = resolveTokensByName(input, pageId); + if (byName.length === 1) { + return { token: byName[0], error: null }; + } + if (byName.length > 1) { + return { + token: null, + error: `Multiple tokens named "${input}" were found on the active page. Use the token ID instead to avoid ambiguity.`, + }; + } + return { + token: null, + error: `No token found with ID or name "${input}" on the active page.`, + }; + } + + /** + * Resolves and validates a pair of explicitly specified tokens for swapping. + * + * Resolution order per input: token ID first, then token name on the active page. + * Fails with a targeted error on ambiguous names, missing tokens, same-token pairs, or cross-page pairs. + * + * @param {string} token1Input ID or name for the first token. + * @param {string} token2Input ID or name for the second token. + * @param {object} msg Roll20 chat message object. + * @returns {Array|null} Two token objects or null when resolution fails. + */ + function resolveExplicitTokenPair(token1Input, token2Input, msg) { + const pageId = Campaign().get('playerpageid'); + + const result1 = resolveTokenInput(token1Input, pageId); + if (result1.error) { + whisperSenderError(msg, result1.error, 'Token Not Found'); + return null; + } + + const result2 = resolveTokenInput(token2Input, pageId); + if (result2.error) { + whisperSenderError(msg, result2.error, 'Token Not Found'); + return null; + } + + const token1 = result1.token; + const token2 = result2.token; + + if (token1.get('_id') === token2.get('_id')) { + whisperSenderError( + msg, + 'Both --token1 and --token2 resolved to the same token. Please provide two distinct tokens.', + 'Selection Error', + ); + return null; + } + + if (token1.get('pageid') !== token2.get('pageid')) { + whisperSenderError( + msg, + 'Both tokens must be on the same page to perform a swap.', + 'Selection Error', + ); + return null; + } + + return [token1, token2]; + } + + /** + * Validates selection and resolves the two tokens targeted for swapping. + * + * @param {object} msg Roll20 chat message object. + * @returns {Array|null} Two graphic token objects or null when invalid. + */ + function getSelectedTokens(msg) { + const selectedCount = (msg.selected || []).length; + + if (selectedCount !== 2) { + const isSilent = SILENT_MANAGEMENT_FLAGS.some((flag) => + flag.test(msg.content), + ); + if (!isSilent) { + whisperSenderError( + msg, + `Please select exactly two tokens to perform a swap. (Currently selected: ${selectedCount})`, + 'Selection Error', + ); + } + return null; + } + + const token1 = getObj('graphic', msg.selected[0]._id); + const token2 = getObj('graphic', msg.selected[1]._id); + + if (!token1 || !token2) { + whisperSenderError( + msg, + 'One or both selected tokens could not be found.', + ); + return null; + } + + if (token1.get('pageid') !== token2.get('pageid')) { + whisperSenderError( + msg, + 'Please select two tokens on the same page to perform a swap.', + 'Selection Error', + ); + return null; + } + + return [token1, token2]; + } + + /** + * Confirms both tokens reached their intended destination coordinates. + * + * @param {object} token1 First token object. + * @param {object} token2 Second token object. + * @param {{left:number, top:number}} pos1 Original position for token1. + * @param {{left:number, top:number}} pos2 Original position for token2. + * @returns {boolean} True when both tokens match expected post-swap coordinates. + */ + function hasVerifiedSwapPosition(token1, token2, pos1, pos2) { + return ( + token1.get('left') === pos2.left && + token1.get('top') === pos2.top && + token2.get('left') === pos1.left && + token2.get('top') === pos1.top + ); + } + + /** + * Resolves the current live token objects from stored ids. + * + * @param {string} token1Id First token id. + * @param {string} token2Id Second token id. + * @returns {{token1:object, token2:object}|null} Live tokens or null when missing. + */ + function getLiveTokenPair(token1Id, token2Id) { + const token1 = getObj('graphic', token1Id); + const token2 = getObj('graphic', token2Id); + if (!token1 || !token2) { + return null; + } + return { token1, token2 }; + } + + /** + * Resolves live tokens and handles missing-token failures consistently. + * + * @param {{token1Id:string, token2Id:string, msg:object}} context Token ids and message context. + * @param {(tokens:{token1:object, token2:object})=>void} callback Work to execute when tokens are live. + * @returns {boolean} True when callback ran; false when tokens were missing. + */ + function withLiveTokens(context, callback) { + const livePair = getLiveTokenPair(context.token1Id, context.token2Id); + if (!livePair) { + whisperSenderError( + context.msg, + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled', + ); + return false; + } + callback(livePair); + return true; + } + + /** + * Spawns destination FX at both destination points after an optional delay. + * + * @param {{left:number, top:number, page:string}} pos1 Original position for token1. + * @param {{left:number, top:number, page:string}} pos2 Original position for token2. + * @param {string} destinationFx FX to spawn at destination points. + * @param {number} delayMs Delay in milliseconds before spawning FX. + * @returns {void} + */ + function scheduleDestinationFx(pos1, pos2, destinationFx, delayMs) { + const spawn = () => { + spawnPointFx(pos2.left, pos2.top, destinationFx, pos2.page); + spawnPointFx(pos1.left, pos1.top, destinationFx, pos1.page); + }; + + if (delayMs > 0) { + setTimeout(spawn, delayMs); + return; + } + + spawn(); + } + + /** + * Keeps travel FX visible for the configured travel duration. + * + * Roll20's spawnFxBetweenPoints API does not expose a duration argument for + * built-in beam FX, so persistence is achieved by re-spawning bursts across + * the travel window. + * + * @param {{left:number, top:number, page:string}} pos1 Start position. + * @param {{left:number, top:number, page:string}} pos2 End position. + * @param {string} travelFx Travel FX type. + * @param {number} durationMs Duration in milliseconds. + * @param {Function} onComplete Callback when the FX window completes. + * @returns {void} + */ + function sustainTravelFx(pos1, pos2, travelFx, durationMs, onComplete) { + if (travelFx === 'none') { + onComplete(); + return; + } + + if (durationMs <= 0) { + spawnTravelFx(pos1, pos2, travelFx); + onComplete(); + return; + } + + const pulseMs = 350; + const startedAt = Date.now(); + + const pulse = () => { + spawnTravelFx(pos1, pos2, travelFx); + if (Date.now() - startedAt >= durationMs) { + onComplete(); + return; + } + setTimeout(pulse, pulseMs); + }; + + pulse(); + } + + /** + * Animates both tokens toward their destination over the configured travel duration. + * + * @param {object} token1 First token object. + * @param {object} token2 Second token object. + * @param {{left:number, top:number}} pos1 Original position for token1. + * @param {{left:number, top:number}} pos2 Original position for token2. + * @param {number} durationMs Travel animation duration in milliseconds. + * @param {object} msg Roll20 chat message object. + * @param {Function} onComplete Callback after animation reaches the destination. + * @returns {void} + */ + function animateTravel( + token1, + token2, + pos1, + pos2, + durationMs, + msg, + onComplete, + ) { + if (durationMs <= 0) { + onComplete(); + return; + } + + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); + // Roll20 can coalesce very frequent token updates. Use paced, fixed steps so + // travel visibly spans the configured duration. + const maxTickMs = 120; + const stepCount = Math.max(1, Math.ceil(durationMs / maxTickMs)); + const stepIntervalMs = durationMs / stepCount; + let stepIndex = 0; + + const step = () => { + stepIndex += 1; + const progress = Math.min(stepIndex / stepCount, 1); + + const nextToken1Left = pos1.left + (pos2.left - pos1.left) * progress; + const nextToken1Top = pos1.top + (pos2.top - pos1.top) * progress; + const nextToken2Left = pos2.left + (pos1.left - pos2.left) * progress; + const nextToken2Top = pos2.top + (pos1.top - pos2.top) * progress; + + if ( + !withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ left: nextToken1Left, top: nextToken1Top }); + liveToken2.set({ left: nextToken2Left, top: nextToken2Top }); + }, + ) + ) { + return; + } + + if (progress >= 1) { + onComplete(); + return; + } + + setTimeout(step, stepIntervalMs); + }; + + setTimeout(step, stepIntervalMs); + } + + /** + * Swaps token coordinates, verifies the result, and runs a completion callback. + * + * @param {object} token1 First token object. + * @param {object} token2 Second token object. + * @param {{left:number, top:number, page:string}} pos1 Original position for token1. + * @param {{left:number, top:number, page:string}} pos2 Original position for token2. + * @param {object} msg Roll20 chat message object. + * @param {Function} [onVerified] Optional callback executed after verification. + * @param {Function} [onFailed] Optional callback executed when verification fails. + * @returns {void} + */ + function performSwap(token1, token2, pos1, pos2, msg, onVerified, onFailed) { + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); + + if ( + !withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ left: pos2.left, top: pos2.top }); + liveToken2.set({ left: pos1.left, top: pos1.top }); + }, + ) + ) { + return; + } + + const maxVerificationAttempts = 8; + const verificationRetryMs = 50; + let attempt = 0; + + const verifyThenFinalize = () => { + const livePair = getLiveTokenPair(token1Id, token2Id); + if (!livePair) { + whisperSenderError( + msg, + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled', + ); + return; + } + + if ( + hasVerifiedSwapPosition(livePair.token1, livePair.token2, pos1, pos2) + ) { + const token1Name = getSafeTokenName(livePair.token1, 'Token 1'); + const token2Name = getSafeTokenName(livePair.token2, 'Token 2'); + whisperSender( + msg, + `Swap Successful!
${token1Name} ↔ ${token2Name}`, + 'Success', + ); + if (typeof onVerified === 'function') { + onVerified(); + } + return; + } + + attempt += 1; + if (attempt >= maxVerificationAttempts) { + whisperSenderError(msg, 'Token swap failed verification.'); + return; + } + + setTimeout(verifyThenFinalize, verificationRetryMs); + }; + + verifyThenFinalize(); + } + + function runNormalTravelPhase(context) { + const { + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msTravelTime, + msSwapDelay, + msBeforeDestinationFx, + } = context; + + const runSwap = () => { + performSwap(token1, token2, pos1, pos2, msg, () => { + scheduleDestinationFx(pos1, pos2, destinationFx, msBeforeDestinationFx); + }); + }; + + let completedTracks = 0; + const finishTravelPhase = () => { + completedTracks += 1; + if (completedTracks < 2) { + return; + } + if (msSwapDelay > 0) { + setTimeout(runSwap, msSwapDelay); + } else { + runSwap(); + } + }; + + animateTravel( + token1, + token2, + pos1, + pos2, + msTravelTime, + msg, + finishTravelPhase, + ); + sustainTravelFx(pos1, pos2, travelFx, msTravelTime, finishTravelPhase); + } + + function runInvisibleTravelPhase(context) { + const { + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msTravelTime, + msSwapDelay, + msBeforeDestinationFx, + } = context; + const revealRenderBufferMs = 120; + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); + + const layer1 = token1.get('layer'); + const layer2 = token2.get('layer'); + + const revealThenFx = () => { + withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + // Restore layer — tokens appear at their new positions with no render artifact. + liveToken1.set({ layer: layer1 }); + liveToken2.set({ layer: layer2 }); + setTimeout( + () => scheduleDestinationFx(pos1, pos2, destinationFx, 0), + revealRenderBufferMs, + ); + }, + ); + }; + + const doMove = () => { + withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + // Tokens are on the GM layer so the position change is invisible to players. + liveToken1.set({ left: pos2.left, top: pos2.top }); + liveToken2.set({ left: pos1.left, top: pos1.top }); + + const token1Name = getSafeTokenName(liveToken1, 'Token 1'); + const token2Name = getSafeTokenName(liveToken2, 'Token 2'); + whisperSender( + msg, + `Swap Successful!
${token1Name} ↔ ${token2Name}`, + 'Success', + ); + + if (msBeforeDestinationFx > 0) { + setTimeout(revealThenFx, msBeforeDestinationFx); + } else { + revealThenFx(); + } + }, + ); + }; + + // Moving to gmlayer removes tokens from the player canvas instantly — no + // position-change flash, unlike baseOpacity which Roll20 ignores on move renders. + if ( + !withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ layer: 'gmlayer' }); + liveToken2.set({ layer: 'gmlayer' }); + }, + ) + ) { + return; + } + + setTimeout(() => { + sustainTravelFx(pos1, pos2, travelFx, msTravelTime, () => {}); + + const msBeforeHiddenSwap = msTravelTime + msSwapDelay; + if (msBeforeHiddenSwap > 0) { + setTimeout(doMove, msBeforeHiddenSwap); + return; + } + doMove(); + }); + } + + /** + * Executes staged FX before performing the final swap. + * + * @param {object} config Effective swap configuration. + * @param {object} token1 First token object. + * @param {object} token2 Second token object. + * @param {{left:number, top:number, page:string}} pos1 Original position for token1. + * @param {{left:number, top:number, page:string}} pos2 Original position for token2. + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function executeSwapPipeline(config, token1, token2, pos1, pos2, msg) { + const { + originFx, + travelFx, + travelMode, + destinationFx, + originTime, + travelTime, + swapDelay, + destinationDelay, + destinationTime, + } = config; + + const msBeforeTravel = originTime * 1000; + const msTravelTime = travelTime * 1000; + const msSwapDelay = swapDelay * 1000; + const msBeforeDestinationFx = (destinationDelay + destinationTime) * 1000; + const useInvisibleTravel = travelMode === 'invisible'; + + spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); + spawnPointFx(pos2.left, pos2.top, originFx, pos2.page); + + setTimeout(() => { + if (useInvisibleTravel) { + runInvisibleTravelPhase({ + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msTravelTime, + msSwapDelay, + msBeforeDestinationFx, + }); + return; + } + + runNormalTravelPhase({ + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msTravelTime, + msSwapDelay, + msBeforeDestinationFx, + }); + }, msBeforeTravel); + } + + /** + * Resolves an array of player ID or display-name inputs to canonical player IDs. + * + * Resolution order per entry: exact player ID first, then case-insensitive display name. + * Emits an error whisper and returns null on ambiguous or unknown entries. + * Deduplicates resolved IDs silently. + * + * @param {string[]} entries Raw player ID or display-name strings. + * @param {object} msg Roll20 chat message object. + * @returns {string[]|null} Canonical player ID array, or null on error. + */ + function resolvePlayerList(entries, msg) { + const allPlayers = findObjs({ type: 'player' }); + const resolved = new Set(); + + for (const entry of entries) { + const byId = getObj('player', entry); + if (byId) { + resolved.add(byId.get('_id')); + continue; + } + + const lower = entry.toLowerCase(); + const byName = allPlayers.filter( + (p) => p.get('_displayname').toLowerCase() === lower, + ); + + if (byName.length > 1) { + whisperSenderError( + msg, + `Multiple players share the display name "${entry}". Use the player ID instead.`, + 'Ambiguous Name', + ); + return null; + } + + if (byName.length === 0) { + whisperSenderError( + msg, + `No player found with ID or name "${entry}".`, + 'Unknown Player', + ); + return null; + } + + resolved.add(byName[0].get('_id')); + } + + return [...resolved]; + } + + /** + * Creates a shared SwapTokens macro for the game when one does not already exist. + * + * @param {object} msgObj Roll20 chat message object. + * @returns {void} + */ + function installMacro(msgObj) { + const macroName = 'SwapTokens'; + const existing = findObjs({ type: 'macro', name: macroName }); + + if (existing.length > 0) { + whisperSenderError( + msgObj, + `A macro named '${macroName}' already exists.`, + 'Macro Exists', + ); + return; + } + + createObj('macro', { + name: macroName, + action: '!swap-tokens', + playerid: msgObj.playerid, + isvisibleto: 'all', + }); + + whisperGMSuccess( + `Global macro '${macroName}' has been created and is visible to all players.`, + 'Macro Installed', + ); + } + + /** + * Handles management flags such as help, settings, reset, and macro install. + * + * @param {object} msg Roll20 chat message object. + * @param {boolean} isGM Whether the sender is a GM. + * @returns {boolean} True when a management command was handled. + */ + function handleManagementCommands(msg, isGM) { + if (FLAG_HELP.test(msg.content)) { + showHelp(msg); + return true; + } + + const hasManagementFlag = MANAGEMENT_FLAGS.some((flag) => + flag.test(msg.content), + ); + if (!isGM && hasManagementFlag) { + whisperSenderError( + msg, + 'You do not have permission to use script management flags.', + 'Access Denied', + ); + return true; + } + + if (FLAG_SHOW_SETTINGS.test(msg.content)) { + showSettings(); + return true; + } + if (FLAG_CHECK_SETTINGS.test(msg.content)) { + validateSettings(); + return true; + } + if (FLAG_RESET_SETTINGS.test(msg.content)) { + resetSettings(); + return true; + } + if (FLAG_INSTALL_MACRO.test(msg.content)) { + installMacro(msg); + return true; + } + + // Check remove before set — FLAG_TOKEN_INPUT_USERS would otherwise match the remove variant. + if (FLAG_TOKEN_INPUT_ACCESS.test(msg.content)) { + handleTokenInputAccess(msg); + return true; + } + if (FLAG_TOKEN_INPUT_USERS_REMOVE.test(msg.content)) { + handleTokenInputUsersRemove(msg); + return true; + } + if (FLAG_TOKEN_INPUT_USERS.test(msg.content)) { + handleTokenInputUsersSet(msg); + return true; + } + + return false; + } + + /** + * Sets the persistent token-input access mode. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function handleTokenInputAccess(msg) { + const result = parseStringFlag( + msg.content, + FLAG_TOKEN_INPUT_ACCESS, + ALLOWED_TOKEN_INPUT_ACCESS_MODES, + ); + if (result.valid) { + state.SwapTokenPositions.tokenInputAccess = result.value; + whisperGMSuccess( + `Token input access set to ${result.value}.`, + 'Access Updated', + ); + } else { + whisperSenderError( + msg, + `Invalid access mode: '${result.value}'.

Valid: ${ALLOWED_TOKEN_INPUT_ACCESS_MODES.join(', ')}`, + 'Invalid Input', + ); + } + } + + /** + * Removes specific players from the token-input allow-list. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function handleTokenInputUsersRemove(msg) { + const listResult = parseCommaListFlag( + msg.content, + FLAG_TOKEN_INPUT_USERS_REMOVE, + ); + if (!listResult.found || listResult.values.length === 0) { + whisperSenderError( + msg, + 'Please provide at least one player ID or name to remove.', + 'Invalid Input', + ); + return; + } + const toRemove = resolvePlayerList(listResult.values, msg); + if (!toRemove) { + return; + } + const removeSet = new Set(toRemove); + state.SwapTokenPositions.tokenInputUsers = + state.SwapTokenPositions.tokenInputUsers.filter( + (id) => !removeSet.has(id), + ); + const removedNames = toRemove.map( + (id) => getObj('player', id)?.get('_displayname') ?? id, + ); + whisperGMSuccess( + `Removed from allow-list: ${removedNames.join(', ')}.`, + 'Users Removed', + ); + if ( + state.SwapTokenPositions.tokenInputAccess === 'selected-users' && + state.SwapTokenPositions.tokenInputUsers.length === 0 + ) { + whisperGM( + 'The allow-list is now empty. While mode is selected-users, only the GM can use explicit token targeting.', + 'Allow-List Empty', + ); + } + } + + /** + * Replaces the token-input allow-list with a new set of resolved players. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function handleTokenInputUsersSet(msg) { + const listResult = parseCommaListFlag(msg.content, FLAG_TOKEN_INPUT_USERS); + if (!listResult.found || listResult.values.length === 0) { + whisperSenderError( + msg, + 'Please provide at least one player ID or name.', + 'Invalid Input', + ); + return; + } + const resolved = resolvePlayerList(listResult.values, msg); + if (!resolved) { + return; + } + state.SwapTokenPositions.tokenInputUsers = resolved; + const names = resolved.map((id) => { + const player = getObj('player', id); + return player ? `${player.get('_displayname')} (${id})` : id; + }); + whisperGMSuccess( + `Allow-list updated. Users: ${names.join(', ')}.`, + 'Users Updated', + ); + } + + /** + * Persists settings when a GM invokes save mode. + * + * @param {object} msg Roll20 chat message object. + * @param {boolean} isGM Whether the sender is a GM. + * @param {{valid:number, invalid:number}} tracker Valid/invalid counters. + * @param {object} config Effective swap configuration to persist. + * @returns {boolean} True when save mode was processed and execution should stop. + */ + function processPersistence(msg, isGM, tracker, config) { + if (!FLAG_SAVE.test(msg.content)) { + return false; + } + + if (!isGM) { + whisperSenderError( + msg, + 'You do not have permission to set game defaults.', + 'Access Denied', + ); + return false; + } + + if (tracker.valid > 0 && tracker.invalid === 0) { + Object.assign(state.SwapTokenPositions, config); + whisperGMSuccess( + 'New defaults saved to persistent state.', + 'Configuration', + ); + showSettings(); + } else if (tracker.invalid > 0) { + whisperGMError( + 'Settings not saved due to invalid parameters.', + 'Save Failed', + ); + } else { + whisperGMError( + 'No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.', + 'Nothing to Save', + ); + } + return true; + } + + /** + * Resolves the token pair for the swap from explicit flags or selection. + * + * Returns null and emits an error whisper when resolution fails. + * + * @param {object} msg Roll20 chat message object. + * @param {boolean} isGM Whether the sender is a GM. + * @returns {Array|null} Two token objects or null on failure. + */ + function resolveSwapTokens(msg, isGM) { + const hasToken1 = FLAG_TOKEN1.test(msg.content); + const hasToken2 = FLAG_TOKEN2.test(msg.content); + + if (!hasToken1 && !hasToken2) { + return getSelectedTokens(msg); + } + + if (hasToken1 !== hasToken2) { + whisperSenderError( + msg, + 'Both --token1 and --token2 must be provided together. Omit both flags to use selection mode instead.', + 'Invalid Input', + ); + return null; + } + + const { tokenInputAccess, tokenInputUsers } = getSettings(); + if (tokenInputAccess === 'gm-only' && !isGM) { + whisperSenderError( + msg, + 'Explicit token targeting is restricted to the GM.', + 'Access Denied', + ); + return null; + } + if ( + tokenInputAccess === 'selected-users' && + !isGM && + !tokenInputUsers.includes(msg.playerid) + ) { + whisperSenderError( + msg, + 'You are not on the explicit token targeting allow-list.', + 'Access Denied', + ); + return null; + } + + const input1 = parseFreeStringFlag(msg.content, FLAG_TOKEN1); + const input2 = parseFreeStringFlag(msg.content, FLAG_TOKEN2); + if (!input1.found || !input2.found) { + whisperSenderError( + msg, + 'Please provide a value for both --token1 and --token2.', + 'Invalid Input', + ); + return null; + } + + return resolveExplicitTokenPair(input1.value, input2.value, msg); + } + + /** + * Main API command handler for !swap-tokens. + * + * Supports two token input modes: + * - Selection mode: exactly two tokens selected, no token flags. + * - Explicit mode: both --token1 and --token2 provided (ID or name). + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function handleSwapTokens(msg) { + if (msg.type !== 'api' || !/^!swap-tokens\b/i.test(msg.content)) { + return; + } + + const isGM = playerIsGM(msg.playerid); + + if (handleManagementCommands(msg, isGM)) { + return; + } + + const tokens = resolveSwapTokens(msg, isGM); + if (!tokens) { + return; + } + + const [token1, token2] = tokens; + const pos1 = { + left: token1.get('left'), + top: token1.get('top'), + page: token1.get('pageid'), + }; + const pos2 = { + left: token2.get('left'), + top: token2.get('top'), + page: token2.get('pageid'), + }; + + if (FLAG_INSTANT.test(msg.content)) { + performSwap(token1, token2, pos1, pos2, msg); + return; + } + + const updateTracker = { valid: 0, invalid: 0 }; + const config = buildSwapConfig(msg, updateTracker); + + processPersistence(msg, isGM, updateTracker, config); + + if (updateTracker.valid > 0 && (!FLAG_SAVE.test(msg.content) || !isGM)) { + const overrideDetails = [ + `Origin FX: ${config.originFx}`, + `Travel FX: ${config.travelFx}`, + `Travel Mode: ${config.travelMode}`, + `Destination FX: ${config.destinationFx}`, + `Origin Time: ${config.originTime}s`, + `Travel Time: ${config.travelTime}s`, + `Swap Delay: ${config.swapDelay}s`, + `Destination Delay: ${config.destinationDelay}s`, + ].join('
'); + whisperSender(msg, overrideDetails, 'Override Active', 'left'); + } + + const hasNoFx = + config.originFx === 'none' && + config.travelFx === 'none' && + config.destinationFx === 'none'; + const hasNoTiming = + config.originTime === 0 && + config.travelTime === 0 && + config.swapDelay === 0 && + config.destinationDelay === 0; + + if (hasNoFx && hasNoTiming) { + performSwap(token1, token2, pos1, pos2, msg); + return; + } + + executeSwapPipeline(config, token1, token2, pos1, pos2, msg); + } + + /** + * Boots the script when Roll20 signals API readiness. + * Initializes state, performs validation, logs status, and registers chat handlers. + * + * @returns {void} + */ + on('ready', () => { + initializeState(); + validateSettings(true); + log( + `-=> ${SCRIPT_NAME} v${SWAP_TOKEN_POSITIONS_VERSION} [Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}] <=-`, + ); + whisperGM( + `MOD READY (v${SWAP_TOKEN_POSITIONS_VERSION})`, + 'Script Ready', + ); + on('chat:message', handleSwapTokens); + }); +})(); diff --git a/SwapTokenPositions/CHANGELOG.md b/SwapTokenPositions/CHANGELOG.md index 6e138fb973..6fe2a72186 100644 --- a/SwapTokenPositions/CHANGELOG.md +++ b/SwapTokenPositions/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to the **SwapTokenPositions** script will be documented in this file. +## [2.1.0] - 2026-05-19 · [Milestone](https://github.com/steverobertsuk/roll20-api-scripts/milestone/4) + +### Added + +- Explicit token targeting via `--token1 ` and `--token2 ` flags. + - Both flags must be provided together; omitting one produces a clear usage error. + - Each input resolves by token ID first, then by token name on the active page. + - Ambiguous name matches (multiple tokens with the same name) are rejected with guidance to use a token ID. + - Cross-page explicit pairs are rejected the same as cross-page selection pairs. +- `parseFreeStringFlag` parser utility to handle quoted (space-containing) and unquoted string values. +- Explicit token access control via three new GM-only management commands (take effect immediately, no `--save` required): + - `--token-input-access ` — sets who may use `--token1` and `--token2`. + - `--token-input-users ` — replaces the allow-list with the specified players (resolved by ID then display name). + - `--token-input-users-remove ` — removes specific players from the allow-list. + - Default mode is `gm-only`. The GM is always permitted regardless of mode. + - `--show-settings` now includes `Token Input Access` and (in `selected-users` mode) `Token Input Users`. +- `parseCommaListFlag` parser utility for comma-separated quoted and bare values. + +### Developer + +- Build process now automatically syncs `package.json` version from `script.json` on each build. +- Build process now auto-increments the trailing build number on pre-release versions (e.g. `2.1.0.beta.0` → `2.1.0.beta.1`) so the version is always up to date after each `npm run build`. Release versions (`major.minor.patch`) are not auto-incremented. +- Versioned archive folder now uses the base semver (`major.minor.patch`) rather than the full pre-release string, so pre-release builds update the same folder in place. + ## [2.0.0] - 2026-04-24 ### Added diff --git a/SwapTokenPositions/README.md b/SwapTokenPositions/README.md index dc38743e71..5b63ac2ec0 100644 --- a/SwapTokenPositions/README.md +++ b/SwapTokenPositions/README.md @@ -5,6 +5,8 @@ ## Features - **Seamless Swapping**: Select exactly two tokens on the same page and run `!swap-tokens` to switch their positions. +- **Explicit Token Targeting**: Target tokens directly by ID or name using `--token1` and `--token2` — no selection required, ideal for macros. +- **Explicit Token Access Control**: GMs can restrict `--token1`/`--token2` usage to GM-only, all players, or a named allow-list. - **Staged Animation Pipeline**: - `origin`: Point FX at starting positions. - `travel`: Beam FX and optional travel visibility behavior. @@ -44,10 +46,42 @@ The v2 series keeps the same core command (`!swap-tokens`) but changes how anima `!swap-tokens` Swaps the two currently selected tokens using the default settings. +### Explicit Token Targeting (Macro-Friendly) + +`!swap-tokens --token1 --token2 ` +Swaps two specific tokens without requiring them to be selected. Both flags must be provided together. + +**Token resolution order (per flag):** + +1. Resolve as a token ID. +2. If not found, resolve as a token name on the active page. + +**Rules:** + +- Quoted names support spaces: `--token1 "Goblin A"` +- When a name matches multiple tokens on the active page, the command fails with an ambiguity error. Use the token ID instead. +- Cross-page pairs are rejected the same as cross-page selections. +- Providing only `--token1` or only `--token2` is an error. + +**Examples:** + +- `!swap-tokens --token1 -Kabc123 --token2 -Kdef456` — swap by token ID +- `!swap-tokens --token1 "Goblin A" --token2 "Goblin B"` — swap by unique name +- `!swap-tokens --token1 -Kabc123 --token2 "Goblin B"` — mixed ID and name +- `!swap-tokens --token1 "Goblin A" --token2 "Goblin B" --preset portal` — explicit targeting with FX + +**Advanced macro example:** + +``` +!swap-tokens --token1 @{selected|token_id} --token2 @{target|token_id} --preset portal +``` + ### Acceptable Parameters for Customization (Available to Everyone) - `--help`: Displays the help menu. - `--instant`: Skips all FX and timing and swaps immediately. +- `--token1 `: First token for explicit targeting (must be paired with `--token2`). +- `--token2 `: Second token for explicit targeting (must be paired with `--token1`). - `--preset `: Applies a preset. - Values: `portal`, `lightning`, `shadow`, `fire`, `magic`, `transport`, `none` - `--origin-fx `: Point FX at both origin positions. @@ -80,6 +114,28 @@ Swaps the two currently selected tokens using the default settings. - `--reset-settings`: Restores the script to its factory defaults. - `--install-macro`: Automatically creates a global "SwapTokens" macro in your campaign. +### Explicit Token Access Control (GM Only) + +These commands take effect immediately — `--save` is not required. + +- `--token-input-access `: Sets who may use `--token1` and `--token2`. + - `gm-only` (default): Only the GM can use explicit token targeting. + - `all-players`: Any player can use explicit token targeting. + - `selected-users`: Only players on the allow-list (and the GM) can use explicit token targeting. +- `--token-input-users `: Replaces the allow-list with the specified players (comma-separated, quoted names supported). +- `--token-input-users-remove `: Removes specific players from the allow-list. + +The GM is always permitted regardless of the configured mode. + +**Examples:** + +``` +!swap-tokens --token-input-access all-players +!swap-tokens --token-input-access selected-users +!swap-tokens --token-input-users "Alice","Bob" +!swap-tokens --token-input-users-remove Alice +``` + ### Deprecated Flags The following flags are still supported for backward compatibility but are deprecated: diff --git a/SwapTokenPositions/SwapTokenPositions.js b/SwapTokenPositions/SwapTokenPositions.js index 8835bd3179..4a487f4156 100644 --- a/SwapTokenPositions/SwapTokenPositions.js +++ b/SwapTokenPositions/SwapTokenPositions.js @@ -4,14 +4,14 @@ * ------------------------------------------------ * Name: SwapTokenPositions * Script: SwapTokenPositions.js - * Built: 2026-04-25T01:23:35.563Z + * Built: 2026-05-19T19:15:31.630Z */ const SwapTokenPositionsMod = (() => { 'use strict'; const SCRIPT_NAME = 'SwapTokenPositions'; - const SWAP_TOKEN_POSITIONS_VERSION = '2.0.0'; - const SWAP_TOKEN_POSITIONS_LAST_UPDATED = '2026-04-25T01:23:35.563Z'; + const SWAP_TOKEN_POSITIONS_VERSION = '2.1.0'; + const SWAP_TOKEN_POSITIONS_LAST_UPDATED = '19 May 2026'; const COLOR_BG_SOFT_BLACK = '#0A0A12'; const COLOR_TEXT_ARCANE_SILVER = '#E6DFFF'; const COLOR_TEXT_DIM_SILVER = '#B8AFCF'; @@ -50,6 +50,12 @@ const SwapTokenPositionsMod = (() => { const ALLOWED_TRAVEL_MODES = ['normal', 'invisible']; + const ALLOWED_TOKEN_INPUT_ACCESS_MODES = [ + 'gm-only', + 'all-players', + 'selected-users', + ]; + const ALLOWED_POINT_FX = [ 'none', 'nova-magic', @@ -191,6 +197,8 @@ const SwapTokenPositionsMod = (() => { swapDelay: 0, destinationDelay: 0, travelMode: 'normal', + tokenInputAccess: 'gm-only', + tokenInputUsers: [], }; const FLAG_HELP = /--help\b/i; @@ -217,11 +225,22 @@ const SwapTokenPositionsMod = (() => { const FLAG_LEGACY_DURATION = /--duration\b/i; const FLAG_LEGACY_MODE = /--mode\b/i; + const FLAG_TOKEN1 = /--token1\b/i; + const FLAG_TOKEN2 = /--token2\b/i; + + const FLAG_TOKEN_INPUT_ACCESS = /--token-input-access\b/i; + const FLAG_TOKEN_INPUT_USERS_REMOVE = /--token-input-users-remove\b/i; + // Negative lookahead prevents matching --token-input-users-remove. + const FLAG_TOKEN_INPUT_USERS = /--token-input-users(?!-)/i; + const MANAGEMENT_FLAGS = [ FLAG_SHOW_SETTINGS, FLAG_CHECK_SETTINGS, FLAG_RESET_SETTINGS, FLAG_INSTALL_MACRO, + FLAG_TOKEN_INPUT_ACCESS, + FLAG_TOKEN_INPUT_USERS_REMOVE, + FLAG_TOKEN_INPUT_USERS, ]; const SILENT_MANAGEMENT_FLAGS = [ @@ -230,6 +249,9 @@ const SwapTokenPositionsMod = (() => { FLAG_CHECK_SETTINGS, FLAG_RESET_SETTINGS, FLAG_INSTALL_MACRO, + FLAG_TOKEN_INPUT_ACCESS, + FLAG_TOKEN_INPUT_USERS_REMOVE, + FLAG_TOKEN_INPUT_USERS, ]; /** @@ -433,6 +455,60 @@ const SwapTokenPositionsMod = (() => { ); } + /** + * Parses a comma-separated list flag, supporting quoted and bare entries. + * + * Each member may be single-quoted, double-quoted, or bare (no commas within). + * Empty members and whitespace-only entries are filtered silently. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @returns {{found:boolean, values:string[]}} Parse result. + */ + function parseCommaListFlag(content, flagRegex) { + const match = new RegExp( + String.raw`${flagRegex.source}\s+(.+?)(?=\s+--|$)`, + 'i', + ).exec(content); + if (!match) { + return { found: false, values: [] }; + } + + const values = []; + for (const part of match[1].trim().split(',')) { + const trimmed = part + .trim() + .replace(/^(['"])(.*)\1$/, '$2') + .trim(); + if (trimmed) { + values.push(trimmed); + } + } + + return { found: true, values }; + } + + /** + * Parses a string flag whose value may be quoted (allowing spaces) or unquoted. + * + * Supports single-quoted, double-quoted, and bare (no-whitespace) values. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @returns {{found:boolean, value:(string|null)}} Parse result. + */ + function parseFreeStringFlag(content, flagRegex) { + const match = new RegExp( + String.raw`${flagRegex.source}\s+(?:"([^"]+)"|'([^']+)'|(\S+))`, + 'i', + ).exec(content); + if (!match) { + return { found: false, value: null }; + } + const value = (match[1] ?? match[2] ?? match[3]).trim(); + return { found: true, value }; + } + /** * Parses a string flag and validates it against an allowed set. * @@ -635,6 +711,12 @@ const SwapTokenPositionsMod = (() => { */ function showSettings() { const settings = getSettings(); + + const userListLine = + settings.tokenInputAccess === 'selected-users' + ? `Token Input Users: ${formatTokenInputUsers(settings.tokenInputUsers)}
` + : ''; + const settingsMsg = [ `Origin FX: ${settings.originFx}
`, `Travel FX: ${settings.travelFx}
`, @@ -645,10 +727,30 @@ const SwapTokenPositionsMod = (() => { `Destination Time: ${settings.destinationTime}s
`, `Swap Delay: ${settings.swapDelay}s
`, `Destination Delay: ${settings.destinationDelay}s
`, + `Token Input Access: ${settings.tokenInputAccess}
`, + userListLine, ].join(''); whisperGM(settingsMsg, 'Persistent Settings', 'left'); } + /** + * Formats a list of player IDs as human-readable names with ID fallback. + * + * @param {string[]} ids Player ID array from persistent state. + * @returns {string} Comma-separated display names, or "(none)" when empty. + */ + function formatTokenInputUsers(ids) { + if (!ids || ids.length === 0) { + return '(none)'; + } + return ids + .map((id) => { + const player = getObj('player', id); + return player ? `${player.get('_displayname')} (${id})` : id; + }) + .join(', '); + } + /** * Resets persisted script settings to factory defaults. * @@ -673,6 +775,20 @@ const SwapTokenPositionsMod = (() => { const settings = getSettings(); const errors = []; + if (!ALLOWED_TOKEN_INPUT_ACCESS_MODES.includes(settings.tokenInputAccess)) { + errors.push( + `Token Input Access '${settings.tokenInputAccess}' is no longer valid.`, + ); + } + if ( + !Array.isArray(settings.tokenInputUsers) || + settings.tokenInputUsers.some( + (entry) => typeof entry !== 'string' || entry.length === 0, + ) + ) { + errors.push('Token Input Users contains invalid entries.'); + } + if (!ALLOWED_POINT_FX.includes(settings.originFx)) { errors.push(`Origin FX '${settings.originFx}' is no longer valid.`); } @@ -985,6 +1101,12 @@ const SwapTokenPositionsMod = (() => { '!swap-tokens — Instant swap of 2 selected tokens.
', '!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
', '!swap-tokens --help — Show this help message (available to all players).
', + '
Explicit Token Targeting (Macro-Friendly):
', + 'Use both flags together to target tokens without selection.
', + '--token1 <id|name> — First token, resolved by ID then by name on the active page.
', + '--token2 <id|name> — Second token, resolved by ID then by name on the active page.
', + 'Quote names that contain spaces: --token1 "Goblin A"
', + 'When a name matches multiple tokens, use the token ID instead.
', '
FX Stages:
', 'Pipeline order: Origin FX → Travel FX → Swap → Destination FX.
', '--origin-fx <type> — FX at both original positions before movement.
', @@ -1013,6 +1135,13 @@ const SwapTokenPositionsMod = (() => { '--show-settings — View current persistent defaults.
', '--reset-settings — Restore all factory defaults.
', "--install-macro — Create a global 'SwapTokens' macro.
", + '
Explicit Token Access Control (GM Only):
', + 'Controls who may use --token1 and --token2. Takes effect immediately.
', + '--token-input-access <mode> — Set access mode. Valid: gm-only (default), all-players, selected-users.
', + '--token-input-users <id|name,...> — Replace the allow-list (used with selected-users mode).
', + '--token-input-users-remove <id|name,...> — Remove specific players from the allow-list.
', + 'Names containing spaces must be quoted. Comma-separated entries are supported.
', + 'The GM is always permitted regardless of access mode.
', '
Examples:
', '!swap-tokens
', '!swap-tokens --preset portal
', @@ -1020,6 +1149,13 @@ const SwapTokenPositionsMod = (() => { '!swap-tokens --preset portal --travel-time 3
', '!swap-tokens --origin-fx nova-magic --swap-delay 1 --destination-fx burst-holy
', '!swap-tokens --preset lightning --save
', + '!swap-tokens --token1 -Kabc123 --token2 -Kdef456
', + '!swap-tokens --token1 "Goblin A" --token2 "Goblin B"
', + '!swap-tokens --token1 -Kabc123 --token2 "Goblin B" --preset portal
', + '!swap-tokens --token-input-access all-players
', + '!swap-tokens --token-input-access selected-users
', + '!swap-tokens --token-input-users "Alice","Bob"
', + '!swap-tokens --token-input-users-remove Alice
', ].join(''); whisperSender(msgObj, helpMsg, 'SwapTokenPositions Help', 'left'); @@ -1072,6 +1208,108 @@ const SwapTokenPositionsMod = (() => { } } + /** + * Resolves a token object by its Roll20 graphic ID. + * + * @param {string} id Token graphic ID. + * @returns {object|null} Token object or null when not found. + */ + function resolveTokenById(id) { + return getObj('graphic', id) ?? null; + } + + /** + * Finds all graphic tokens with a given name on a specific page. + * + * @param {string} name Token name to search for. + * @param {string} pageId Page to search on. + * @returns {object[]} Array of matching token objects. + */ + function resolveTokensByName(name, pageId) { + return findObjs({ type: 'graphic', pageid: pageId, name }).filter( + (t) => t.get('subtype') === 'token', + ); + } + + /** + * Resolves a token from an input string by ID first, then by name on the active page. + * + * @param {string} input Token ID or name to resolve. + * @param {string} pageId Active page ID to scope name lookups. + * @returns {{token:(object|null), error:(string|null)}} Resolved token or a targeted error message. + */ + function resolveTokenInput(input, pageId) { + const byId = resolveTokenById(input); + if (byId) { + return { token: byId, error: null }; + } + + const byName = resolveTokensByName(input, pageId); + if (byName.length === 1) { + return { token: byName[0], error: null }; + } + if (byName.length > 1) { + return { + token: null, + error: `Multiple tokens named "${input}" were found on the active page. Use the token ID instead to avoid ambiguity.`, + }; + } + return { + token: null, + error: `No token found with ID or name "${input}" on the active page.`, + }; + } + + /** + * Resolves and validates a pair of explicitly specified tokens for swapping. + * + * Resolution order per input: token ID first, then token name on the active page. + * Fails with a targeted error on ambiguous names, missing tokens, same-token pairs, or cross-page pairs. + * + * @param {string} token1Input ID or name for the first token. + * @param {string} token2Input ID or name for the second token. + * @param {object} msg Roll20 chat message object. + * @returns {Array|null} Two token objects or null when resolution fails. + */ + function resolveExplicitTokenPair(token1Input, token2Input, msg) { + const pageId = Campaign().get('playerpageid'); + + const result1 = resolveTokenInput(token1Input, pageId); + if (result1.error) { + whisperSenderError(msg, result1.error, 'Token Not Found'); + return null; + } + + const result2 = resolveTokenInput(token2Input, pageId); + if (result2.error) { + whisperSenderError(msg, result2.error, 'Token Not Found'); + return null; + } + + const token1 = result1.token; + const token2 = result2.token; + + if (token1.get('_id') === token2.get('_id')) { + whisperSenderError( + msg, + 'Both --token1 and --token2 resolved to the same token. Please provide two distinct tokens.', + 'Selection Error', + ); + return null; + } + + if (token1.get('pageid') !== token2.get('pageid')) { + whisperSenderError( + msg, + 'Both tokens must be on the same page to perform a swap.', + 'Selection Error', + ); + return null; + } + + return [token1, token2]; + } + /** * Validates selection and resolves the two tokens targeted for swapping. * @@ -1571,6 +1809,57 @@ const SwapTokenPositionsMod = (() => { }, msBeforeTravel); } + /** + * Resolves an array of player ID or display-name inputs to canonical player IDs. + * + * Resolution order per entry: exact player ID first, then case-insensitive display name. + * Emits an error whisper and returns null on ambiguous or unknown entries. + * Deduplicates resolved IDs silently. + * + * @param {string[]} entries Raw player ID or display-name strings. + * @param {object} msg Roll20 chat message object. + * @returns {string[]|null} Canonical player ID array, or null on error. + */ + function resolvePlayerList(entries, msg) { + const allPlayers = findObjs({ type: 'player' }); + const resolved = new Set(); + + for (const entry of entries) { + const byId = getObj('player', entry); + if (byId) { + resolved.add(byId.get('_id')); + continue; + } + + const lower = entry.toLowerCase(); + const byName = allPlayers.filter( + (p) => p.get('_displayname').toLowerCase() === lower, + ); + + if (byName.length > 1) { + whisperSenderError( + msg, + `Multiple players share the display name "${entry}". Use the player ID instead.`, + 'Ambiguous Name', + ); + return null; + } + + if (byName.length === 0) { + whisperSenderError( + msg, + `No player found with ID or name "${entry}".`, + 'Unknown Player', + ); + return null; + } + + resolved.add(byName[0].get('_id')); + } + + return [...resolved]; + } + /** * Creates a shared SwapTokens macro for the game when one does not already exist. * @@ -1645,9 +1934,127 @@ const SwapTokenPositionsMod = (() => { return true; } + // Check remove before set — FLAG_TOKEN_INPUT_USERS would otherwise match the remove variant. + if (FLAG_TOKEN_INPUT_ACCESS.test(msg.content)) { + handleTokenInputAccess(msg); + return true; + } + if (FLAG_TOKEN_INPUT_USERS_REMOVE.test(msg.content)) { + handleTokenInputUsersRemove(msg); + return true; + } + if (FLAG_TOKEN_INPUT_USERS.test(msg.content)) { + handleTokenInputUsersSet(msg); + return true; + } + return false; } + /** + * Sets the persistent token-input access mode. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function handleTokenInputAccess(msg) { + const result = parseStringFlag( + msg.content, + FLAG_TOKEN_INPUT_ACCESS, + ALLOWED_TOKEN_INPUT_ACCESS_MODES, + ); + if (result.valid) { + state.SwapTokenPositions.tokenInputAccess = result.value; + whisperGMSuccess( + `Token input access set to ${result.value}.`, + 'Access Updated', + ); + } else { + whisperSenderError( + msg, + `Invalid access mode: '${result.value}'.

Valid: ${ALLOWED_TOKEN_INPUT_ACCESS_MODES.join(', ')}`, + 'Invalid Input', + ); + } + } + + /** + * Removes specific players from the token-input allow-list. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function handleTokenInputUsersRemove(msg) { + const listResult = parseCommaListFlag( + msg.content, + FLAG_TOKEN_INPUT_USERS_REMOVE, + ); + if (!listResult.found || listResult.values.length === 0) { + whisperSenderError( + msg, + 'Please provide at least one player ID or name to remove.', + 'Invalid Input', + ); + return; + } + const toRemove = resolvePlayerList(listResult.values, msg); + if (!toRemove) { + return; + } + const removeSet = new Set(toRemove); + state.SwapTokenPositions.tokenInputUsers = + state.SwapTokenPositions.tokenInputUsers.filter( + (id) => !removeSet.has(id), + ); + const removedNames = toRemove.map( + (id) => getObj('player', id)?.get('_displayname') ?? id, + ); + whisperGMSuccess( + `Removed from allow-list: ${removedNames.join(', ')}.`, + 'Users Removed', + ); + if ( + state.SwapTokenPositions.tokenInputAccess === 'selected-users' && + state.SwapTokenPositions.tokenInputUsers.length === 0 + ) { + whisperGM( + 'The allow-list is now empty. While mode is selected-users, only the GM can use explicit token targeting.', + 'Allow-List Empty', + ); + } + } + + /** + * Replaces the token-input allow-list with a new set of resolved players. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function handleTokenInputUsersSet(msg) { + const listResult = parseCommaListFlag(msg.content, FLAG_TOKEN_INPUT_USERS); + if (!listResult.found || listResult.values.length === 0) { + whisperSenderError( + msg, + 'Please provide at least one player ID or name.', + 'Invalid Input', + ); + return; + } + const resolved = resolvePlayerList(listResult.values, msg); + if (!resolved) { + return; + } + state.SwapTokenPositions.tokenInputUsers = resolved; + const names = resolved.map((id) => { + const player = getObj('player', id); + return player ? `${player.get('_displayname')} (${id})` : id; + }); + whisperGMSuccess( + `Allow-list updated. Users: ${names.join(', ')}.`, + 'Users Updated', + ); + } + /** * Persists settings when a GM invokes save mode. * @@ -1692,9 +2099,75 @@ const SwapTokenPositionsMod = (() => { return true; } + /** + * Resolves the token pair for the swap from explicit flags or selection. + * + * Returns null and emits an error whisper when resolution fails. + * + * @param {object} msg Roll20 chat message object. + * @param {boolean} isGM Whether the sender is a GM. + * @returns {Array|null} Two token objects or null on failure. + */ + function resolveSwapTokens(msg, isGM) { + const hasToken1 = FLAG_TOKEN1.test(msg.content); + const hasToken2 = FLAG_TOKEN2.test(msg.content); + + if (!hasToken1 && !hasToken2) { + return getSelectedTokens(msg); + } + + if (hasToken1 !== hasToken2) { + whisperSenderError( + msg, + 'Both --token1 and --token2 must be provided together. Omit both flags to use selection mode instead.', + 'Invalid Input', + ); + return null; + } + + const { tokenInputAccess, tokenInputUsers } = getSettings(); + if (tokenInputAccess === 'gm-only' && !isGM) { + whisperSenderError( + msg, + 'Explicit token targeting is restricted to the GM.', + 'Access Denied', + ); + return null; + } + if ( + tokenInputAccess === 'selected-users' && + !isGM && + !tokenInputUsers.includes(msg.playerid) + ) { + whisperSenderError( + msg, + 'You are not on the explicit token targeting allow-list.', + 'Access Denied', + ); + return null; + } + + const input1 = parseFreeStringFlag(msg.content, FLAG_TOKEN1); + const input2 = parseFreeStringFlag(msg.content, FLAG_TOKEN2); + if (!input1.found || !input2.found) { + whisperSenderError( + msg, + 'Please provide a value for both --token1 and --token2.', + 'Invalid Input', + ); + return null; + } + + return resolveExplicitTokenPair(input1.value, input2.value, msg); + } + /** * Main API command handler for !swap-tokens. * + * Supports two token input modes: + * - Selection mode: exactly two tokens selected, no token flags. + * - Explicit mode: both --token1 and --token2 provided (ID or name). + * * @param {object} msg Roll20 chat message object. * @returns {void} */ @@ -1704,12 +2177,12 @@ const SwapTokenPositionsMod = (() => { } const isGM = playerIsGM(msg.playerid); - const tokens = getSelectedTokens(msg); if (handleManagementCommands(msg, isGM)) { return; } + const tokens = resolveSwapTokens(msg, isGM); if (!tokens) { return; } diff --git a/SwapTokenPositions/TESTING.md b/SwapTokenPositions/TESTING.md index 093a056e8c..0973a5bd9f 100644 --- a/SwapTokenPositions/TESTING.md +++ b/SwapTokenPositions/TESTING.md @@ -200,7 +200,7 @@ Run these as GM unless otherwise specified. - Second run reports `Macro Exists`. 5. Non-GM permission checks - - Action: As player, run `--show-settings`, `--check-settings`, `--reset-settings`, and `--install-macro`. + - Action: As player, run `--show-settings`, `--check-settings`, `--reset-settings`, `--install-macro`, `--token-input-access all-players`, `--token-input-users Alice`, and `--token-input-users-remove Alice`. - Expected: `Access Denied` message for each. 6. Player `--save` permission check @@ -231,6 +231,117 @@ Run these as GM unless otherwise specified. - Action: `!swap-tokens --save` (GM). - Expected: `Nothing to Save` message. +## Explicit Token Targeting (`--token1` / `--token2`) + +1. **Swap by token ID** + - Action: `!swap-tokens --token1 --token2 ` using the IDs of two tokens on the active page. + - Expected: + - Tokens swap positions. + - Sender receives `Swap Successful!` message. + +2. **Swap by unique token name** + - Action: `!swap-tokens --token1 "Goblin A" --token2 "Goblin B"` where each name matches exactly one token on the active page. + - Expected: + - Tokens swap positions. + - Sender receives `Swap Successful!` message. + +3. **Mixed ID and name input** + - Action: `!swap-tokens --token1 --token2 "Token Name"` using an ID for one and a unique name for the other. + - Expected: Swap succeeds normally. + +4. **Only one explicit flag provided** + - Action: `!swap-tokens --token1 ` with no `--token2`. + - Expected: Sender receives `Invalid Input` error explaining both flags must be provided together. + +5. **Ambiguous name match** + - Action: `!swap-tokens --token1 "Goblin" --token2 ` where "Goblin" matches more than one token on the active page. + - Expected: Sender receives `Token Not Found` error advising use of a token ID. + +6. **Name not found on active page** + - Action: `!swap-tokens --token1 "NoSuchToken" --token2 `. + - Expected: Sender receives `Token Not Found` error for the unresolved name. + +7. **Invalid ID provided** + - Action: `!swap-tokens --token1 invalid-id --token2 ` where `invalid-id` does not match any token or name. + - Expected: Sender receives `Token Not Found` error. + +8. **Cross-page explicit pair** + - Action: `!swap-tokens --token1 --token2 `. + - Expected: Sender receives `Selection Error` about same-page requirement. + +9. **Explicit targeting with preset** + - Action: `!swap-tokens --token1 --token2 --preset portal`. + - Expected: Swap proceeds with portal FX pipeline applied. + +10. **Missing flag value** + - Action: `!swap-tokens --token1 --token2 ` (no value after `--token1`). + - Expected: Sender receives `Invalid Input` error asking for values for both flags. + +11. **Same token provided for both flags** + - Action: `!swap-tokens --token1 --token2 ` using the same token ID for both, or two names that resolve to the same token. + - Expected: Sender receives `Selection Error` explaining both inputs resolved to the same token. + +## Explicit Token Access Control + +Run all steps as GM unless otherwise specified. + +1. **Default mode is `gm-only`** + - Action: As player, run `!swap-tokens --token1 --token2 `. + - Expected: `Access Denied` whisper to the player. + +2. **Set mode to `all-players`** + - Action (GM): `!swap-tokens --token-input-access all-players` + - Expected: `Access Updated` success whisper to GM. + - Action (player): `!swap-tokens --token1 --token2 ` + - Expected: Swap succeeds. + +3. **Set mode to `selected-users` with valid allow-list** + - Action (GM): `!swap-tokens --token-input-access selected-users` + - Action (GM): `!swap-tokens --token-input-users "PlayerName"` using the test player's display name. + - Expected: `Users Updated` success with resolved name and ID. + - Action (allowed player): `!swap-tokens --token1 --token2 ` + - Expected: Swap succeeds. + - Action (non-listed player): `!swap-tokens --token1 --token2 ` + - Expected: `Access Denied` whisper. + +4. **Remove a player from the allow-list** + - Action (GM): `!swap-tokens --token-input-users-remove "PlayerName"` + - Expected: `Users Removed` success whisper. + - Action (removed player): `!swap-tokens --token1 --token2 ` + - Expected: `Access Denied` whisper. + +5. **Empty allow-list warning** + - Action (GM): Remove the last player from the allow-list while mode is `selected-users`. + - Expected: `Users Removed` success and an `Allow-List Empty` warning whisper. + +6. **Resolve by player ID** + - Action (GM): `!swap-tokens --token-input-users ` using a valid Roll20 player ID. + - Expected: `Users Updated` with the resolved player's display name. + +7. **Unknown player name** + - Action (GM): `!swap-tokens --token-input-users "NoSuchPlayer"` + - Expected: `Unknown Player` error; allow-list unchanged. + +8. **Ambiguous player name** + - Action: If two test accounts share a display name, attempt to add by that name. + - Expected: `Ambiguous Name` error; allow-list unchanged. + +9. **Invalid access mode value** + - Action (GM): `!swap-tokens --token-input-access invalid-mode` + - Expected: `Invalid Input` error listing valid modes; access mode unchanged. + +10. **Settings display** + - Action (GM): `!swap-tokens --show-settings` + - Expected: `Token Input Access` and (when mode is `selected-users`) `Token Input Users` lines are present with current values. + +11. **GM always allowed** + - Action (GM): With mode set to `gm-only` or `selected-users`, run `!swap-tokens --token1 --token2 `. + - Expected: Swap succeeds regardless of allow-list. + +12. **Access settings survive sandbox restart** + - Action: Configure a non-default mode, restart sandbox. + - Expected: `--show-settings` reflects the saved access configuration. + ## Regression and Stability Checks 1. Run 10+ swaps in sequence with mixed presets and overrides. @@ -269,3 +380,5 @@ All tests pass when: 3. Deprecated flags emit warnings and remain backward compatible. 4. Invalid inputs produce clear feedback without script failure. 5. Persistence (`--save`, restart, reset) is correct and stable. +6. Explicit token access control enforces mode correctly for GM and players. +7. Allow-list management (add, remove, resolve by name/ID) works as expected. diff --git a/SwapTokenPositions/package.json b/SwapTokenPositions/package.json index 7148f6dce0..c806516577 100644 --- a/SwapTokenPositions/package.json +++ b/SwapTokenPositions/package.json @@ -1,6 +1,6 @@ { "name": "swap-token-positions", - "version": "2.0.0", + "version": "2.1.0", "private": true, "type": "module", "scripts": { diff --git a/SwapTokenPositions/rollup.config.mjs b/SwapTokenPositions/rollup.config.mjs index 7ca51fe984..5deb547cd3 100644 --- a/SwapTokenPositions/rollup.config.mjs +++ b/SwapTokenPositions/rollup.config.mjs @@ -1,28 +1,95 @@ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { format as prettierFormat } from "prettier"; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { format as prettierFormat } from 'prettier'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const scriptJson = JSON.parse( - fs.readFileSync(path.join(__dirname, "script.json"), "utf8"), -); -const buildTimestamp = new Date().toISOString(); + +const scriptJsonPath = path.join(__dirname, 'script.json'); +const scriptJson = JSON.parse(fs.readFileSync(scriptJsonPath, 'utf8')); +const buildNow = new Date(); +const buildTimestamp = buildNow.toISOString(); +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; +const buildDate = `${buildNow.getUTCDate()} ${MONTHS[buildNow.getUTCMonth()]} ${buildNow.getUTCFullYear()}`; const scriptName = scriptJson.name; const scriptFile = scriptJson.script; -const buildVersion = scriptJson.version; + +/** + * Increments the trailing numeric build segment for pre-release versions. + * + * Release versions with exactly three dot-separated parts (e.g. "2.1.0") are + * returned unchanged so the patch number is never bumped automatically. + * + * @param {string} version Current version string. + * @returns {string} Version with build number incremented, or the original string. + */ +function incrementBuildNumber(version) { + const parts = version.split('.'); + if (parts.length <= 3) { + return version; + } + const last = parts.at(-1); + const n = Number(last); + if (Number.isInteger(n) && n >= 0 && String(n) === last) { + parts[parts.length - 1] = String(n + 1); + return parts.join('.'); + } + return version; +} + +/** + * Returns the semver base (major.minor.patch) from any version string. + * + * Used for the versioned archive folder so pre-release builds don't generate a + * new folder on every run. + * + * @param {string} version Version string. + * @returns {string} First three dot-separated segments joined by ".". + */ +function getBaseVersion(version) { + return version.split('.').slice(0, 3).join('.'); +} + +const buildVersion = incrementBuildNumber(scriptJson.version); +if (buildVersion !== scriptJson.version) { + scriptJson.version = buildVersion; + fs.writeFileSync(scriptJsonPath, JSON.stringify(scriptJson, null, 2) + '\n'); +} + +const baseVersion = getBaseVersion(buildVersion); + +// Keep package.json version in sync with script.json on every build. +const packageJsonPath = path.join(__dirname, 'package.json'); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +if (packageJson.version !== buildVersion) { + packageJson.version = buildVersion; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); +} const banner = [ - "/**", - " * NOTE: GENERATED FILE - DO NOT EDIT DIRECTLY.", - " * NOTE: Source files live under src/ and are bundled with `npm run build`.", - " * ------------------------------------------------", + '/**', + ' * NOTE: GENERATED FILE - DO NOT EDIT DIRECTLY.', + ' * NOTE: Source files live under src/ and are bundled with `npm run build`.', + ' * ------------------------------------------------', ` * Name: ${scriptName}`, ` * Script: ${scriptFile}`, ` * Built: ${buildTimestamp}`, - " */", -].join("\n"); + ' */', +].join('\n'); /** * Formats generated JavaScript chunks after Rollup has applied banner/intro/outro. @@ -31,7 +98,7 @@ const banner = [ */ function formatOutputPlugin() { return { - name: "format-output", + name: 'format-output', /** * Formats each emitted chunk with Prettier. * @@ -41,14 +108,14 @@ function formatOutputPlugin() { */ async generateBundle(options, bundle) { for (const output of Object.values(bundle)) { - if (output.type !== "chunk") { + if (output.type !== 'chunk') { continue; } output.code = await prettierFormat(output.code, { - parser: "babel", + parser: 'babel', singleQuote: true, - trailingComma: "all", + trailingComma: 'all', }); } }, @@ -57,10 +124,10 @@ function formatOutputPlugin() { /** @type {import("rollup").RollupOptions} */ export default { - input: path.join(__dirname, "src", "index.js"), + input: path.join(__dirname, 'src', 'index.js'), plugins: [ { - name: "inject-build-metadata", + name: 'inject-build-metadata', /** * Replaces metadata placeholders in constants with build-time values. * @@ -69,16 +136,16 @@ export default { * @returns {{code: string, map: null} | null} */ transform(code, id) { - if (!id.endsWith(path.join("src", "constants.js"))) { + if (!id.endsWith(path.join('src', 'constants.js'))) { return null; } return { code: code - .replaceAll("__SCRIPT_NAME__", scriptName) - .replaceAll("__SCRIPT_FILE__", scriptFile) - .replaceAll("__BUILD_VERSION__", buildVersion) - .replaceAll("__BUILD_DATE__", buildTimestamp), + .replaceAll('__SCRIPT_NAME__', scriptName) + .replaceAll('__SCRIPT_FILE__', scriptFile) + .replaceAll('__BUILD_VERSION__', buildVersion) + .replaceAll('__BUILD_DATE__', buildDate), map: null, }; }, @@ -87,18 +154,18 @@ export default { output: [ { file: path.join(__dirname, `${scriptJson.name}.js`), - format: "es", + format: 'es', banner, intro: `const ${scriptName}Mod = (() => {\n 'use strict';`, - outro: "})();", + outro: '})();', plugins: [formatOutputPlugin()], }, { - file: path.join(__dirname, scriptJson.version, `${scriptJson.name}.js`), - format: "es", + file: path.join(__dirname, baseVersion, `${scriptName}.js`), + format: 'es', banner, intro: `const ${scriptName}Mod = (() => {\n 'use strict';`, - outro: "})();", + outro: '})();', plugins: [formatOutputPlugin()], }, ], diff --git a/SwapTokenPositions/script.json b/SwapTokenPositions/script.json index bc5e4ddfde..23412fffea 100644 --- a/SwapTokenPositions/script.json +++ b/SwapTokenPositions/script.json @@ -1,7 +1,7 @@ { "name": "SwapTokenPositions", "script": "SwapTokenPositions.js", - "version": "2.0.0", + "version": "2.1.0", "description": "Allows GMs and players to quickly swap the positions of two selected tokens on the same page using a staged FX pipeline (origin, travel, destination), presets, and persistent defaults.\r\n\r\nFor instructions see the *Help: SwapTokenPositions* Handout in game, or run `!swap-tokens --help` in game, or visit [SwapTokenPositions Forum Thread](https://app.roll20.net/forum/post/xxxxxxxx/swap-token-positions-api-script).", "authors": "MidNiteShadow7", "roll20userid": "16506286", @@ -147,10 +147,7 @@ { "name": "travel-mode", "type": "select", - "options": [ - "normal", - "invisible" - ], + "options": ["normal", "invisible"], "default": "normal", "description": "Controls whether tokens remain visible during travel stage." }, @@ -177,10 +174,13 @@ "modifies": { "state.SwapTokenPositions": "read,write", "graphic.left": "read,write", - "graphic.top": "read,write" + "graphic.top": "read,write", + "graphic.pageid": "read", + "graphic.layer": "read,write", + "graphic.name": "read", + "graphic.subtype": "read", + "Campaign.playerpageid": "read" }, "conflicts": [], - "previousversions": [ - "1.0.0" - ] + "previousversions": ["2.0.0", "1.0.0"] } diff --git a/SwapTokenPositions/src/commands.js b/SwapTokenPositions/src/commands.js index b893ea0fb0..e6c44448bb 100644 --- a/SwapTokenPositions/src/commands.js +++ b/SwapTokenPositions/src/commands.js @@ -1,4 +1,5 @@ import { + ALLOWED_TOKEN_INPUT_ACCESS_MODES, FLAG_HELP, FLAG_INSTANT, FLAG_INSTALL_MACRO, @@ -6,13 +7,75 @@ import { FLAG_SHOW_SETTINGS, FLAG_CHECK_SETTINGS, FLAG_RESET_SETTINGS, + FLAG_TOKEN1, + FLAG_TOKEN2, + FLAG_TOKEN_INPUT_ACCESS, + FLAG_TOKEN_INPUT_USERS, + FLAG_TOKEN_INPUT_USERS_REMOVE, MANAGEMENT_FLAGS, -} from "./constants.js"; -import { buildSwapConfig } from "./config.js"; -import { showHelp } from "./help.js"; -import { whisperGMError, whisperGMSuccess, whisperSenderError, whisperSender } from "./messages.js"; -import { resetSettings, showSettings, validateSettings } from "./state.js"; -import { executeSwapPipeline, getSelectedTokens, performSwap } from "./swap.js"; +} from './constants.js'; +import { buildSwapConfig } from './config.js'; +import { showHelp } from './help.js'; +import { + whisperGM, + whisperGMError, + whisperGMSuccess, + whisperSenderError, + whisperSender, +} from './messages.js'; +import { parseCommaListFlag, parseFreeStringFlag, parseStringFlag } from './parsers.js'; +import { getSettings, resetSettings, showSettings, validateSettings } from './state.js'; +import { + executeSwapPipeline, + getSelectedTokens, + performSwap, + resolveExplicitTokenPair, +} from './swap.js'; + +/** + * Resolves an array of player ID or display-name inputs to canonical player IDs. + * + * Resolution order per entry: exact player ID first, then case-insensitive display name. + * Emits an error whisper and returns null on ambiguous or unknown entries. + * Deduplicates resolved IDs silently. + * + * @param {string[]} entries Raw player ID or display-name strings. + * @param {object} msg Roll20 chat message object. + * @returns {string[]|null} Canonical player ID array, or null on error. + */ +function resolvePlayerList(entries, msg) { + const allPlayers = findObjs({ type: 'player' }); + const resolved = new Set(); + + for (const entry of entries) { + const byId = getObj('player', entry); + if (byId) { + resolved.add(byId.get('_id')); + continue; + } + + const lower = entry.toLowerCase(); + const byName = allPlayers.filter((p) => p.get('_displayname').toLowerCase() === lower); + + if (byName.length > 1) { + whisperSenderError( + msg, + `Multiple players share the display name "${entry}". Use the player ID instead.`, + 'Ambiguous Name' + ); + return null; + } + + if (byName.length === 0) { + whisperSenderError(msg, `No player found with ID or name "${entry}".`, 'Unknown Player'); + return null; + } + + resolved.add(byName[0].get('_id')); + } + + return [...resolved]; +} /** * Creates a shared SwapTokens macro for the game when one does not already exist. @@ -21,28 +84,28 @@ import { executeSwapPipeline, getSelectedTokens, performSwap } from "./swap.js"; * @returns {void} */ export function installMacro(msgObj) { - const macroName = "SwapTokens"; - const existing = findObjs({ type: "macro", name: macroName }); + const macroName = 'SwapTokens'; + const existing = findObjs({ type: 'macro', name: macroName }); if (existing.length > 0) { whisperSenderError( msgObj, `A macro named '${macroName}' already exists.`, - "Macro Exists", + 'Macro Exists' ); return; } - createObj("macro", { + createObj('macro', { name: macroName, - action: "!swap-tokens", + action: '!swap-tokens', playerid: msgObj.playerid, - isvisibleto: "all", + isvisibleto: 'all', }); whisperGMSuccess( `Global macro '${macroName}' has been created and is visible to all players.`, - "Macro Installed", + 'Macro Installed' ); } @@ -63,8 +126,8 @@ export function handleManagementCommands(msg, isGM) { if (!isGM && hasManagementFlag) { whisperSenderError( msg, - "You do not have permission to use script management flags.", - "Access Denied", + 'You do not have permission to use script management flags.', + 'Access Denied' ); return true; } @@ -86,9 +149,109 @@ export function handleManagementCommands(msg, isGM) { return true; } + // Check remove before set — FLAG_TOKEN_INPUT_USERS would otherwise match the remove variant. + if (FLAG_TOKEN_INPUT_ACCESS.test(msg.content)) { + handleTokenInputAccess(msg); + return true; + } + if (FLAG_TOKEN_INPUT_USERS_REMOVE.test(msg.content)) { + handleTokenInputUsersRemove(msg); + return true; + } + if (FLAG_TOKEN_INPUT_USERS.test(msg.content)) { + handleTokenInputUsersSet(msg); + return true; + } + return false; } +/** + * Sets the persistent token-input access mode. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ +function handleTokenInputAccess(msg) { + const result = parseStringFlag(msg.content, FLAG_TOKEN_INPUT_ACCESS, ALLOWED_TOKEN_INPUT_ACCESS_MODES); + if (result.valid) { + state.SwapTokenPositions.tokenInputAccess = result.value; + whisperGMSuccess( + `Token input access set to ${result.value}.`, + 'Access Updated' + ); + } else { + whisperSenderError( + msg, + `Invalid access mode: '${result.value}'.

Valid: ${ALLOWED_TOKEN_INPUT_ACCESS_MODES.join(', ')}`, + 'Invalid Input' + ); + } +} + +/** + * Removes specific players from the token-input allow-list. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ +function handleTokenInputUsersRemove(msg) { + const listResult = parseCommaListFlag(msg.content, FLAG_TOKEN_INPUT_USERS_REMOVE); + if (!listResult.found || listResult.values.length === 0) { + whisperSenderError(msg, 'Please provide at least one player ID or name to remove.', 'Invalid Input'); + return; + } + const toRemove = resolvePlayerList(listResult.values, msg); + if (!toRemove) { + return; + } + const removeSet = new Set(toRemove); + state.SwapTokenPositions.tokenInputUsers = state.SwapTokenPositions.tokenInputUsers.filter( + (id) => !removeSet.has(id) + ); + const removedNames = toRemove.map((id) => getObj('player', id)?.get('_displayname') ?? id); + whisperGMSuccess( + `Removed from allow-list: ${removedNames.join(', ')}.`, + 'Users Removed' + ); + if ( + state.SwapTokenPositions.tokenInputAccess === 'selected-users' && + state.SwapTokenPositions.tokenInputUsers.length === 0 + ) { + whisperGM( + 'The allow-list is now empty. While mode is selected-users, only the GM can use explicit token targeting.', + 'Allow-List Empty' + ); + } +} + +/** + * Replaces the token-input allow-list with a new set of resolved players. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ +function handleTokenInputUsersSet(msg) { + const listResult = parseCommaListFlag(msg.content, FLAG_TOKEN_INPUT_USERS); + if (!listResult.found || listResult.values.length === 0) { + whisperSenderError(msg, 'Please provide at least one player ID or name.', 'Invalid Input'); + return; + } + const resolved = resolvePlayerList(listResult.values, msg); + if (!resolved) { + return; + } + state.SwapTokenPositions.tokenInputUsers = resolved; + const names = resolved.map((id) => { + const player = getObj('player', id); + return player ? `${player.get('_displayname')} (${id})` : id; + }); + whisperGMSuccess( + `Allow-list updated. Users: ${names.join(', ')}.`, + 'Users Updated' + ); +} + /** * Persists settings when a GM invokes save mode. * @@ -104,61 +267,115 @@ export function processPersistence(msg, isGM, tracker, config) { } if (!isGM) { - whisperSenderError( - msg, - "You do not have permission to set game defaults.", - "Access Denied", - ); + whisperSenderError(msg, 'You do not have permission to set game defaults.', 'Access Denied'); return false; } if (tracker.valid > 0 && tracker.invalid === 0) { Object.assign(state.SwapTokenPositions, config); - whisperGMSuccess("New defaults saved to persistent state.", "Configuration"); + whisperGMSuccess('New defaults saved to persistent state.', 'Configuration'); showSettings(); } else if (tracker.invalid > 0) { - whisperGMError("Settings not saved due to invalid parameters.", "Save Failed"); + whisperGMError('Settings not saved due to invalid parameters.', 'Save Failed'); } else { whisperGMError( - "No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.", - "Nothing to Save", + 'No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.', + 'Nothing to Save' ); } return true; } +/** + * Resolves the token pair for the swap from explicit flags or selection. + * + * Returns null and emits an error whisper when resolution fails. + * + * @param {object} msg Roll20 chat message object. + * @param {boolean} isGM Whether the sender is a GM. + * @returns {Array|null} Two token objects or null on failure. + */ +function resolveSwapTokens(msg, isGM) { + const hasToken1 = FLAG_TOKEN1.test(msg.content); + const hasToken2 = FLAG_TOKEN2.test(msg.content); + + if (!hasToken1 && !hasToken2) { + return getSelectedTokens(msg); + } + + if (hasToken1 !== hasToken2) { + whisperSenderError( + msg, + 'Both --token1 and --token2 must be provided together. Omit both flags to use selection mode instead.', + 'Invalid Input' + ); + return null; + } + + const { tokenInputAccess, tokenInputUsers } = getSettings(); + if (tokenInputAccess === 'gm-only' && !isGM) { + whisperSenderError(msg, 'Explicit token targeting is restricted to the GM.', 'Access Denied'); + return null; + } + if (tokenInputAccess === 'selected-users' && !isGM && !tokenInputUsers.includes(msg.playerid)) { + whisperSenderError( + msg, + 'You are not on the explicit token targeting allow-list.', + 'Access Denied' + ); + return null; + } + + const input1 = parseFreeStringFlag(msg.content, FLAG_TOKEN1); + const input2 = parseFreeStringFlag(msg.content, FLAG_TOKEN2); + if (!input1.found || !input2.found) { + whisperSenderError( + msg, + 'Please provide a value for both --token1 and --token2.', + 'Invalid Input' + ); + return null; + } + + return resolveExplicitTokenPair(input1.value, input2.value, msg); +} + /** * Main API command handler for !swap-tokens. * + * Supports two token input modes: + * - Selection mode: exactly two tokens selected, no token flags. + * - Explicit mode: both --token1 and --token2 provided (ID or name). + * * @param {object} msg Roll20 chat message object. * @returns {void} */ export function handleSwapTokens(msg) { - if (msg.type !== "api" || !/^!swap-tokens\b/i.test(msg.content)) { + if (msg.type !== 'api' || !/^!swap-tokens\b/i.test(msg.content)) { return; } const isGM = playerIsGM(msg.playerid); - const tokens = getSelectedTokens(msg); if (handleManagementCommands(msg, isGM)) { return; } + const tokens = resolveSwapTokens(msg, isGM); if (!tokens) { return; } const [token1, token2] = tokens; const pos1 = { - left: token1.get("left"), - top: token1.get("top"), - page: token1.get("pageid"), + left: token1.get('left'), + top: token1.get('top'), + page: token1.get('pageid'), }; const pos2 = { - left: token2.get("left"), - top: token2.get("top"), - page: token2.get("pageid"), + left: token2.get('left'), + top: token2.get('top'), + page: token2.get('pageid'), }; if (FLAG_INSTANT.test(msg.content)) { @@ -181,14 +398,12 @@ export function handleSwapTokens(msg) { `Travel Time: ${config.travelTime}s`, `Swap Delay: ${config.swapDelay}s`, `Destination Delay: ${config.destinationDelay}s`, - ].join("
"); - whisperSender(msg, overrideDetails, "Override Active", "left"); + ].join('
'); + whisperSender(msg, overrideDetails, 'Override Active', 'left'); } const hasNoFx = - config.originFx === "none" && - config.travelFx === "none" && - config.destinationFx === "none"; + config.originFx === 'none' && config.travelFx === 'none' && config.destinationFx === 'none'; const hasNoTiming = config.originTime === 0 && config.travelTime === 0 && diff --git a/SwapTokenPositions/src/constants.js b/SwapTokenPositions/src/constants.js index e5702bbf9b..fa8ff29d57 100644 --- a/SwapTokenPositions/src/constants.js +++ b/SwapTokenPositions/src/constants.js @@ -1,26 +1,26 @@ -export const SCRIPT_NAME = "__SCRIPT_NAME__"; -export const SCRIPT_FILE = "__SCRIPT_FILE__"; -export const SWAP_TOKEN_POSITIONS_VERSION = "__BUILD_VERSION__"; -export const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "__BUILD_DATE__"; - -export const COLOR_GLOW_PURPLE = "#B388FF"; -export const COLOR_BG_SOFT_BLACK = "#0A0A12"; -export const COLOR_TEXT_ARCANE_SILVER = "#E6DFFF"; -export const COLOR_TEXT_DIM_SILVER = "#B8AFCF"; -export const COLOR_ACCENT_PURPLE_LIGHT = "#FF4D6D"; -export const COLOR_ACCENT_PURPLE_DARK = "#5B21B6"; -export const COLOR_HEADER_PURPLE_LIGHT = "#E9D5FF"; - -export const COLOR_INFO_LIGHT = "#DBEAFE"; -export const COLOR_INFO_DARK = "#1E40AF"; -export const COLOR_ERROR_RED = "#D32F2F"; -export const COLOR_ERROR_DARK = "#B71C1C"; -export const COLOR_ERROR_LIGHT = "#FFCDD2"; -export const COLOR_ERROR_BG_LIGHT = "#FFEBEE"; -export const COLOR_SUCCESS_GREEN = "#2E7D32"; -export const COLOR_SUCCESS_DARK = "#1B5E20"; -export const COLOR_SUCCESS_LIGHT = "#E8F5E9"; -export const COLOR_SUCCESS_BG_LIGHT = "#F1F5FE"; +export const SCRIPT_NAME = '__SCRIPT_NAME__'; +export const SCRIPT_FILE = '__SCRIPT_FILE__'; +export const SWAP_TOKEN_POSITIONS_VERSION = '__BUILD_VERSION__'; +export const SWAP_TOKEN_POSITIONS_LAST_UPDATED = '__BUILD_DATE__'; + +export const COLOR_GLOW_PURPLE = '#B388FF'; +export const COLOR_BG_SOFT_BLACK = '#0A0A12'; +export const COLOR_TEXT_ARCANE_SILVER = '#E6DFFF'; +export const COLOR_TEXT_DIM_SILVER = '#B8AFCF'; +export const COLOR_ACCENT_PURPLE_LIGHT = '#FF4D6D'; +export const COLOR_ACCENT_PURPLE_DARK = '#5B21B6'; +export const COLOR_HEADER_PURPLE_LIGHT = '#E9D5FF'; + +export const COLOR_INFO_LIGHT = '#DBEAFE'; +export const COLOR_INFO_DARK = '#1E40AF'; +export const COLOR_ERROR_RED = '#D32F2F'; +export const COLOR_ERROR_DARK = '#B71C1C'; +export const COLOR_ERROR_LIGHT = '#FFCDD2'; +export const COLOR_ERROR_BG_LIGHT = '#FFEBEE'; +export const COLOR_SUCCESS_GREEN = '#2E7D32'; +export const COLOR_SUCCESS_DARK = '#1B5E20'; +export const COLOR_SUCCESS_LIGHT = '#E8F5E9'; +export const COLOR_SUCCESS_BG_LIGHT = '#F1F5FE'; export const TIME_MIN = 0; export const TIME_MAX = 10; @@ -28,161 +28,165 @@ export const DELAY_MIN = 0; export const DELAY_MAX = 10; export const ALLOWED_TRAVEL_FX = [ - "none", - "beam-magic", - "beam-acid", - "beam-charm", - "beam-fire", - "beam-frost", - "beam-holy", - "beam-death", - "beam-energy", - "beam-lightning", + 'none', + 'beam-magic', + 'beam-acid', + 'beam-charm', + 'beam-fire', + 'beam-frost', + 'beam-holy', + 'beam-death', + 'beam-energy', + 'beam-lightning', ]; -export const ALLOWED_TRAVEL_MODES = ["normal", "invisible"]; +export const ALLOWED_TRAVEL_MODES = ['normal', 'invisible']; + +export const ALLOWED_TOKEN_INPUT_ACCESS_MODES = ['gm-only', 'all-players', 'selected-users']; export const ALLOWED_POINT_FX = [ - "none", - "nova-magic", - "nova-acid", - "nova-charm", - "nova-fire", - "nova-frost", - "nova-holy", - "nova-death", - "burst-magic", - "burst-acid", - "burst-charm", - "burst-fire", - "burst-frost", - "burst-holy", - "burst-death", - "burst-energy", - "burst-smoke", - "explode-magic", - "explode-acid", - "explode-charm", - "explode-fire", - "explode-frost", - "explode-holy", - "explode-death", - "burn-magic", - "burn-acid", - "burn-charm", - "burn-fire", - "burn-frost", - "burn-holy", - "burn-death", - "splatter-magic", - "splatter-acid", - "splatter-charm", - "splatter-fire", - "splatter-frost", - "splatter-holy", - "splatter-death", - "splatter-dark", - "glow-magic", - "glow-acid", - "glow-charm", - "glow-fire", - "glow-frost", - "glow-holy", - "glow-death", + 'none', + 'nova-magic', + 'nova-acid', + 'nova-charm', + 'nova-fire', + 'nova-frost', + 'nova-holy', + 'nova-death', + 'burst-magic', + 'burst-acid', + 'burst-charm', + 'burst-fire', + 'burst-frost', + 'burst-holy', + 'burst-death', + 'burst-energy', + 'burst-smoke', + 'explode-magic', + 'explode-acid', + 'explode-charm', + 'explode-fire', + 'explode-frost', + 'explode-holy', + 'explode-death', + 'burn-magic', + 'burn-acid', + 'burn-charm', + 'burn-fire', + 'burn-frost', + 'burn-holy', + 'burn-death', + 'splatter-magic', + 'splatter-acid', + 'splatter-charm', + 'splatter-fire', + 'splatter-frost', + 'splatter-holy', + 'splatter-death', + 'splatter-dark', + 'glow-magic', + 'glow-acid', + 'glow-charm', + 'glow-fire', + 'glow-frost', + 'glow-holy', + 'glow-death', ]; export const FX_PRESETS = { portal: { - originFx: "nova-magic", - travelFx: "beam-magic", - destinationFx: "burst-holy", + originFx: 'nova-magic', + travelFx: 'beam-magic', + destinationFx: 'burst-holy', originTime: 1, travelTime: 1, destinationTime: 0.5, swapDelay: 0.5, destinationDelay: 1, - travelMode: "normal", + travelMode: 'normal', }, lightning: { - originFx: "none", - travelFx: "beam-holy", - destinationFx: "burst-holy", + originFx: 'none', + travelFx: 'beam-holy', + destinationFx: 'burst-holy', originTime: 0, travelTime: 0.3, destinationTime: 0, swapDelay: 0, destinationDelay: 0.3, - travelMode: "normal", + travelMode: 'normal', }, shadow: { - originFx: "burst-smoke", - travelFx: "none", - destinationFx: "burst-smoke", + originFx: 'burst-smoke', + travelFx: 'none', + destinationFx: 'burst-smoke', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, fire: { - originFx: "explode-fire", - travelFx: "none", - destinationFx: "explode-fire", + originFx: 'explode-fire', + travelFx: 'none', + destinationFx: 'explode-fire', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, magic: { - originFx: "nova-magic", - travelFx: "none", - destinationFx: "burst-magic", + originFx: 'nova-magic', + travelFx: 'none', + destinationFx: 'burst-magic', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, transport: { - originFx: "glow-magic", - travelFx: "none", - destinationFx: "glow-magic", + originFx: 'glow-magic', + travelFx: 'none', + destinationFx: 'glow-magic', originTime: 0.55, travelTime: 0, destinationTime: 0, swapDelay: 0.15, destinationDelay: 0.05, - travelMode: "invisible", + travelMode: 'invisible', }, none: { - originFx: "none", - travelFx: "none", - destinationFx: "none", + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', originTime: 0, travelTime: 0, destinationTime: 0, swapDelay: 0, destinationDelay: 0, - travelMode: "normal", + travelMode: 'normal', }, }; export const ALLOWED_PRESETS = Object.keys(FX_PRESETS); export const FACTORY_DEFAULTS = { - originFx: "none", - travelFx: "none", - destinationFx: "none", + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', originTime: 0, travelTime: 0, destinationTime: 0, swapDelay: 0, destinationDelay: 0, - travelMode: "normal", + travelMode: 'normal', + tokenInputAccess: 'gm-only', + tokenInputUsers: [], }; export const FLAG_HELP = /--help\b/i; @@ -209,11 +213,22 @@ export const FLAG_LEGACY_BURST_FX = /--burst-fx\b/i; export const FLAG_LEGACY_DURATION = /--duration\b/i; export const FLAG_LEGACY_MODE = /--mode\b/i; +export const FLAG_TOKEN1 = /--token1\b/i; +export const FLAG_TOKEN2 = /--token2\b/i; + +export const FLAG_TOKEN_INPUT_ACCESS = /--token-input-access\b/i; +export const FLAG_TOKEN_INPUT_USERS_REMOVE = /--token-input-users-remove\b/i; +// Negative lookahead prevents matching --token-input-users-remove. +export const FLAG_TOKEN_INPUT_USERS = /--token-input-users(?!-)/i; + export const MANAGEMENT_FLAGS = [ FLAG_SHOW_SETTINGS, FLAG_CHECK_SETTINGS, FLAG_RESET_SETTINGS, FLAG_INSTALL_MACRO, + FLAG_TOKEN_INPUT_ACCESS, + FLAG_TOKEN_INPUT_USERS_REMOVE, + FLAG_TOKEN_INPUT_USERS, ]; export const SILENT_MANAGEMENT_FLAGS = [ @@ -222,4 +237,7 @@ export const SILENT_MANAGEMENT_FLAGS = [ FLAG_CHECK_SETTINGS, FLAG_RESET_SETTINGS, FLAG_INSTALL_MACRO, + FLAG_TOKEN_INPUT_ACCESS, + FLAG_TOKEN_INPUT_USERS_REMOVE, + FLAG_TOKEN_INPUT_USERS, ]; diff --git a/SwapTokenPositions/src/help.js b/SwapTokenPositions/src/help.js index 979ddfe702..2031bb67ae 100644 --- a/SwapTokenPositions/src/help.js +++ b/SwapTokenPositions/src/help.js @@ -6,8 +6,8 @@ import { SWAP_TOKEN_POSITIONS_VERSION, TIME_MAX, TIME_MIN, -} from "./constants.js"; -import { whisperSender } from "./messages.js"; +} from './constants.js'; +import { whisperSender } from './messages.js'; /** * Sends full command and option help text to the invoking player. @@ -19,46 +19,66 @@ export function showHelp(msgObj) { const helpMsg = [ `SwapTokenPositions v${SWAP_TOKEN_POSITIONS_VERSION}
`, `Last Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}
`, - "
Basic Usage:
", - "!swap-tokens — Instant swap of 2 selected tokens.
", - "!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
", - "!swap-tokens --help — Show this help message (available to all players).
", - "
FX Stages:
", - "Pipeline order: Origin FX → Travel FX → Swap → Destination FX.
", - "--origin-fx <type> — FX at both original positions before movement.
", - "--travel-fx <type> — FX between tokens during transition.
", - "--travel-mode <normal|invisible> — Keep tokens visible during travel or hide them until reveal.
", - "--destination-fx <type> — FX at both new positions after swap.
", - "
Stage Timing:
", + '
Basic Usage:
', + '!swap-tokens — Instant swap of 2 selected tokens.
', + '!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
', + '!swap-tokens --help — Show this help message (available to all players).
', + '
Explicit Token Targeting (Macro-Friendly):
', + 'Use both flags together to target tokens without selection.
', + '--token1 <id|name> — First token, resolved by ID then by name on the active page.
', + '--token2 <id|name> — Second token, resolved by ID then by name on the active page.
', + 'Quote names that contain spaces: --token1 "Goblin A"
', + 'When a name matches multiple tokens, use the token ID instead.
', + '
FX Stages:
', + 'Pipeline order: Origin FX → Travel FX → Swap → Destination FX.
', + '--origin-fx <type> — FX at both original positions before movement.
', + '--travel-fx <type> — FX between tokens during transition.
', + '--travel-mode <normal|invisible> — Keep tokens visible during travel or hide them until reveal.
', + '--destination-fx <type> — FX at both new positions after swap.
', + '
Stage Timing:
', `--origin-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Origin FX before continuing.
`, `--travel-time <${TIME_MIN}-${TIME_MAX}> — Duration (s) of the travel animation stage.
`, `--destination-time <${TIME_MIN}-${TIME_MAX}> — Additional wait (s) before Destination FX is shown.
`, - "
Delays:
", + '
Delays:
', `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause before Destination FX is shown.
`, - "
Presets:
", - `--preset <name> — Apply a preset. Valid: ${ALLOWED_PRESETS.join(", ")}
`, - "• portal — Magical portal teleport (nova, beam, burst).
", - "• lightning — Fast lightning strike (beam, burst).
", - "• shadow — Dark shadow blink (splatter, no travel FX).
", - "• fire — Fiery explosion swap (explode, no travel FX).
", - "• magic — Arcane sparkle swap (nova, burst).
", - "• transport — Starship transport shimmer (invisible travel reveal).
", - "• none — No FX, equivalent to instant mode.
", - "Explicit flags override preset values. Example: --preset portal --travel-time 3
", - "
Global Configuration (GM Only):
", - "--save — Commit provided flags as the new global defaults.
", - "--show-settings — View current persistent defaults.
", - "--reset-settings — Restore all factory defaults.
", + '
Presets:
', + `--preset <name> — Apply a preset. Valid: ${ALLOWED_PRESETS.join(', ')}
`, + '• portal — Magical portal teleport (nova, beam, burst).
', + '• lightning — Fast lightning strike (beam, burst).
', + '• shadow — Dark shadow blink (splatter, no travel FX).
', + '• fire — Fiery explosion swap (explode, no travel FX).
', + '• magic — Arcane sparkle swap (nova, burst).
', + '• transport — Starship transport shimmer (invisible travel reveal).
', + '• none — No FX, equivalent to instant mode.
', + 'Explicit flags override preset values. Example: --preset portal --travel-time 3
', + '
Global Configuration (GM Only):
', + '--save — Commit provided flags as the new global defaults.
', + '--show-settings — View current persistent defaults.
', + '--reset-settings — Restore all factory defaults.
', "--install-macro — Create a global 'SwapTokens' macro.
", - "
Examples:
", - "!swap-tokens
", - "!swap-tokens --preset portal
", - "!swap-tokens --preset transport
", - "!swap-tokens --preset portal --travel-time 3
", - "!swap-tokens --origin-fx nova-magic --swap-delay 1 --destination-fx burst-holy
", - "!swap-tokens --preset lightning --save
", - ].join(""); + '
Explicit Token Access Control (GM Only):
', + 'Controls who may use --token1 and --token2. Takes effect immediately.
', + '--token-input-access <mode> — Set access mode. Valid: gm-only (default), all-players, selected-users.
', + '--token-input-users <id|name,...> — Replace the allow-list (used with selected-users mode).
', + '--token-input-users-remove <id|name,...> — Remove specific players from the allow-list.
', + 'Names containing spaces must be quoted. Comma-separated entries are supported.
', + 'The GM is always permitted regardless of access mode.
', + '
Examples:
', + '!swap-tokens
', + '!swap-tokens --preset portal
', + '!swap-tokens --preset transport
', + '!swap-tokens --preset portal --travel-time 3
', + '!swap-tokens --origin-fx nova-magic --swap-delay 1 --destination-fx burst-holy
', + '!swap-tokens --preset lightning --save
', + '!swap-tokens --token1 -Kabc123 --token2 -Kdef456
', + '!swap-tokens --token1 "Goblin A" --token2 "Goblin B"
', + '!swap-tokens --token1 -Kabc123 --token2 "Goblin B" --preset portal
', + '!swap-tokens --token-input-access all-players
', + '!swap-tokens --token-input-access selected-users
', + '!swap-tokens --token-input-users "Alice","Bob"
', + '!swap-tokens --token-input-users-remove Alice
', + ].join(''); - whisperSender(msgObj, helpMsg, "SwapTokenPositions Help", "left"); + whisperSender(msgObj, helpMsg, 'SwapTokenPositions Help', 'left'); } diff --git a/SwapTokenPositions/src/parsers.js b/SwapTokenPositions/src/parsers.js index 4c587101e2..1b63310eac 100644 --- a/SwapTokenPositions/src/parsers.js +++ b/SwapTokenPositions/src/parsers.js @@ -1,4 +1,55 @@ -import { whisperSenderError } from "./messages.js"; +import { whisperSenderError } from './messages.js'; + +/** + * Parses a comma-separated list flag, supporting quoted and bare entries. + * + * Each member may be single-quoted, double-quoted, or bare (no commas within). + * Empty members and whitespace-only entries are filtered silently. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @returns {{found:boolean, values:string[]}} Parse result. + */ +export function parseCommaListFlag(content, flagRegex) { + const match = new RegExp(String.raw`${flagRegex.source}\s+(.+?)(?=\s+--|$)`, 'i').exec(content); + if (!match) { + return { found: false, values: [] }; + } + + const values = []; + for (const part of match[1].trim().split(',')) { + const trimmed = part + .trim() + .replace(/^(['"])(.*)\1$/, '$2') + .trim(); + if (trimmed) { + values.push(trimmed); + } + } + + return { found: true, values }; +} + +/** + * Parses a string flag whose value may be quoted (allowing spaces) or unquoted. + * + * Supports single-quoted, double-quoted, and bare (no-whitespace) values. + * + * @param {string} content Full command content. + * @param {RegExp} flagRegex Regex for the flag name. + * @returns {{found:boolean, value:(string|null)}} Parse result. + */ +export function parseFreeStringFlag(content, flagRegex) { + const match = new RegExp( + String.raw`${flagRegex.source}\s+(?:"([^"]+)"|'([^']+)'|(\S+))`, + 'i' + ).exec(content); + if (!match) { + return { found: false, value: null }; + } + const value = (match[1] ?? match[2] ?? match[3]).trim(); + return { found: true, value }; +} /** * Parses a string flag and validates it against an allowed set. @@ -9,14 +60,14 @@ import { whisperSenderError } from "./messages.js"; * @returns {{found:boolean, valid:boolean, value:(string|null)}} Parse result. */ export function parseStringFlag(content, flagRegex, allowedValues) { - const match = new RegExp(String.raw`${flagRegex.source}\s+(\S+)`, "i").exec(content); + const match = new RegExp(String.raw`${flagRegex.source}\s+(\S+)`, 'i').exec(content); if (!match) { return { found: false, valid: false, value: null }; } const normalized = match[1] .trim() - .replaceAll(/(^['"]|['"]$)/g, "") - .replaceAll(/[.,;]+$/g, "") + .replaceAll(/(^['"]|['"]$)/g, '') + .replaceAll(/[.,;]+$/g, '') .toLowerCase(); if (allowedValues.includes(normalized)) { return { found: true, valid: true, value: normalized }; @@ -34,7 +85,7 @@ export function parseStringFlag(content, flagRegex, allowedValues) { * @returns {{found:boolean, valid:boolean, value:(number|null)}} Parse result. */ export function parseFloatFlag(content, flagRegex, min, max) { - const match = new RegExp(String.raw`${flagRegex.source}\s+([\d.]+)`, "i").exec(content); + const match = new RegExp(String.raw`${flagRegex.source}\s+([\d.]+)`, 'i').exec(content); if (!match) { return { found: false, valid: false, value: null }; } @@ -62,7 +113,7 @@ export function applyStringFlagResult(result, key, config, updateTracker, msg, e updateTracker.valid++; } else { updateTracker.invalid++; - whisperSenderError(msg, errorMsg, "Invalid Input"); + whisperSenderError(msg, errorMsg, 'Invalid Input'); } } @@ -87,7 +138,7 @@ export function applyNumericFlagResult(result, key, config, updateTracker, msg, whisperSenderError( msg, `Invalid ${label}: must be between ${range.min} and ${range.max} seconds.`, - "Invalid Input", + 'Invalid Input' ); } } @@ -108,7 +159,7 @@ export function processStringFlags(content, flagConfigs, config, updateTracker, if (!result.found) { continue; } - const errorMsg = `Invalid ${label}: '${result.value}'.

Valid: ${allowed.join(", ")}`; + const errorMsg = `Invalid ${label}: '${result.value}'.

Valid: ${allowed.join(', ')}`; applyStringFlagResult(result, key, config, updateTracker, msg, errorMsg); } } diff --git a/SwapTokenPositions/src/state.js b/SwapTokenPositions/src/state.js index 9952f37e28..7ae083658f 100644 --- a/SwapTokenPositions/src/state.js +++ b/SwapTokenPositions/src/state.js @@ -1,5 +1,6 @@ import { ALLOWED_POINT_FX, + ALLOWED_TOKEN_INPUT_ACCESS_MODES, ALLOWED_TRAVEL_FX, ALLOWED_TRAVEL_MODES, DELAY_MAX, @@ -42,6 +43,12 @@ export function getSettings() { */ export function showSettings() { const settings = getSettings(); + + const userListLine = + settings.tokenInputAccess === 'selected-users' + ? `Token Input Users: ${formatTokenInputUsers(settings.tokenInputUsers)}
` + : ''; + const settingsMsg = [ `Origin FX: ${settings.originFx}
`, `Travel FX: ${settings.travelFx}
`, @@ -52,8 +59,28 @@ export function showSettings() { `Destination Time: ${settings.destinationTime}s
`, `Swap Delay: ${settings.swapDelay}s
`, `Destination Delay: ${settings.destinationDelay}s
`, - ].join(""); - whisperGM(settingsMsg, "Persistent Settings", "left"); + `Token Input Access: ${settings.tokenInputAccess}
`, + userListLine, + ].join(''); + whisperGM(settingsMsg, 'Persistent Settings', 'left'); +} + +/** + * Formats a list of player IDs as human-readable names with ID fallback. + * + * @param {string[]} ids Player ID array from persistent state. + * @returns {string} Comma-separated display names, or "(none)" when empty. + */ +function formatTokenInputUsers(ids) { + if (!ids || ids.length === 0) { + return '(none)'; + } + return ids + .map((id) => { + const player = getObj('player', id); + return player ? `${player.get('_displayname')} (${id})` : id; + }) + .join(', '); } /** @@ -80,6 +107,16 @@ export function validateSettings(silentOnSuccess = false) { const settings = getSettings(); const errors = []; + if (!ALLOWED_TOKEN_INPUT_ACCESS_MODES.includes(settings.tokenInputAccess)) { + errors.push(`Token Input Access '${settings.tokenInputAccess}' is no longer valid.`); + } + if ( + !Array.isArray(settings.tokenInputUsers) || + settings.tokenInputUsers.some((entry) => typeof entry !== 'string' || entry.length === 0) + ) { + errors.push('Token Input Users contains invalid entries.'); + } + if (!ALLOWED_POINT_FX.includes(settings.originFx)) { errors.push(`Origin FX '${settings.originFx}' is no longer valid.`); } diff --git a/SwapTokenPositions/src/swap.js b/SwapTokenPositions/src/swap.js index cbf4de7d9e..be713e9e17 100644 --- a/SwapTokenPositions/src/swap.js +++ b/SwapTokenPositions/src/swap.js @@ -1,6 +1,108 @@ -import { SILENT_MANAGEMENT_FLAGS } from "./constants.js"; -import { spawnPointFx, spawnTravelFx } from "./effects.js"; -import { getSafeTokenName, whisperSender, whisperSenderError } from "./messages.js"; +import { SILENT_MANAGEMENT_FLAGS } from './constants.js'; +import { spawnPointFx, spawnTravelFx } from './effects.js'; +import { getSafeTokenName, whisperSender, whisperSenderError } from './messages.js'; + +/** + * Resolves a token object by its Roll20 graphic ID. + * + * @param {string} id Token graphic ID. + * @returns {object|null} Token object or null when not found. + */ +function resolveTokenById(id) { + return getObj('graphic', id) ?? null; +} + +/** + * Finds all graphic tokens with a given name on a specific page. + * + * @param {string} name Token name to search for. + * @param {string} pageId Page to search on. + * @returns {object[]} Array of matching token objects. + */ +function resolveTokensByName(name, pageId) { + return findObjs({ type: 'graphic', pageid: pageId, name }).filter( + (t) => t.get('subtype') === 'token' + ); +} + +/** + * Resolves a token from an input string by ID first, then by name on the active page. + * + * @param {string} input Token ID or name to resolve. + * @param {string} pageId Active page ID to scope name lookups. + * @returns {{token:(object|null), error:(string|null)}} Resolved token or a targeted error message. + */ +function resolveTokenInput(input, pageId) { + const byId = resolveTokenById(input); + if (byId) { + return { token: byId, error: null }; + } + + const byName = resolveTokensByName(input, pageId); + if (byName.length === 1) { + return { token: byName[0], error: null }; + } + if (byName.length > 1) { + return { + token: null, + error: `Multiple tokens named "${input}" were found on the active page. Use the token ID instead to avoid ambiguity.`, + }; + } + return { + token: null, + error: `No token found with ID or name "${input}" on the active page.`, + }; +} + +/** + * Resolves and validates a pair of explicitly specified tokens for swapping. + * + * Resolution order per input: token ID first, then token name on the active page. + * Fails with a targeted error on ambiguous names, missing tokens, same-token pairs, or cross-page pairs. + * + * @param {string} token1Input ID or name for the first token. + * @param {string} token2Input ID or name for the second token. + * @param {object} msg Roll20 chat message object. + * @returns {Array|null} Two token objects or null when resolution fails. + */ +export function resolveExplicitTokenPair(token1Input, token2Input, msg) { + const pageId = Campaign().get('playerpageid'); + + const result1 = resolveTokenInput(token1Input, pageId); + if (result1.error) { + whisperSenderError(msg, result1.error, 'Token Not Found'); + return null; + } + + const result2 = resolveTokenInput(token2Input, pageId); + if (result2.error) { + whisperSenderError(msg, result2.error, 'Token Not Found'); + return null; + } + + const token1 = result1.token; + const token2 = result2.token; + + if (token1.get('_id') === token2.get('_id')) { + whisperSenderError( + msg, + 'Both --token1 and --token2 resolved to the same token. Please provide two distinct tokens.', + 'Selection Error' + ); + return null; + } + + if (token1.get('pageid') !== token2.get('pageid')) { + whisperSenderError( + msg, + 'Both tokens must be on the same page to perform a swap.', + 'Selection Error' + ); + return null; + } + + return [token1, token2]; +} /** * Validates selection and resolves the two tokens targeted for swapping. @@ -17,25 +119,25 @@ export function getSelectedTokens(msg) { whisperSenderError( msg, `Please select exactly two tokens to perform a swap. (Currently selected: ${selectedCount})`, - "Selection Error", + 'Selection Error' ); } return null; } - const token1 = getObj("graphic", msg.selected[0]._id); - const token2 = getObj("graphic", msg.selected[1]._id); + const token1 = getObj('graphic', msg.selected[0]._id); + const token2 = getObj('graphic', msg.selected[1]._id); if (!token1 || !token2) { - whisperSenderError(msg, "One or both selected tokens could not be found."); + whisperSenderError(msg, 'One or both selected tokens could not be found.'); return null; } - if (token1.get("pageid") !== token2.get("pageid")) { + if (token1.get('pageid') !== token2.get('pageid')) { whisperSenderError( msg, - "Please select two tokens on the same page to perform a swap.", - "Selection Error", + 'Please select two tokens on the same page to perform a swap.', + 'Selection Error' ); return null; } @@ -54,10 +156,10 @@ export function getSelectedTokens(msg) { */ function hasVerifiedSwapPosition(token1, token2, pos1, pos2) { return ( - token1.get("left") === pos2.left && - token1.get("top") === pos2.top && - token2.get("left") === pos1.left && - token2.get("top") === pos1.top + token1.get('left') === pos2.left && + token1.get('top') === pos2.top && + token2.get('left') === pos1.left && + token2.get('top') === pos1.top ); } @@ -69,8 +171,8 @@ function hasVerifiedSwapPosition(token1, token2, pos1, pos2) { * @returns {{token1:object, token2:object}|null} Live tokens or null when missing. */ function getLiveTokenPair(token1Id, token2Id) { - const token1 = getObj("graphic", token1Id); - const token2 = getObj("graphic", token2Id); + const token1 = getObj('graphic', token1Id); + const token2 = getObj('graphic', token2Id); if (!token1 || !token2) { return null; } @@ -89,8 +191,8 @@ function withLiveTokens(context, callback) { if (!livePair) { whisperSenderError( context.msg, - "Swap cancelled because one or both tokens are no longer available.", - "Swap Cancelled", + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled' ); return false; } @@ -136,7 +238,7 @@ function scheduleDestinationFx(pos1, pos2, destinationFx, delayMs) { * @returns {void} */ function sustainTravelFx(pos1, pos2, travelFx, durationMs, onComplete) { - if (travelFx === "none") { + if (travelFx === 'none') { onComplete(); return; } @@ -180,8 +282,8 @@ function animateTravel(token1, token2, pos1, pos2, durationMs, msg, onComplete) return; } - const token1Id = token1.get("_id"); - const token2Id = token2.get("_id"); + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); // Roll20 can coalesce very frequent token updates. Use paced, fixed steps so // travel visibly spans the configured duration. const maxTickMs = 120; @@ -230,22 +332,16 @@ function animateTravel(token1, token2, pos1, pos2, durationMs, msg, onComplete) * @param {Function} [onFailed] Optional callback executed when verification fails. * @returns {void} */ -export function performSwap( - token1, - token2, - pos1, - pos2, - msg, - onVerified, - onFailed, -) { - const token1Id = token1.get("_id"); - const token2Id = token2.get("_id"); - - if (!withLiveTokens({ token1Id, token2Id, msg }, ({ token1: liveToken1, token2: liveToken2 }) => { - liveToken1.set({ left: pos2.left, top: pos2.top }); - liveToken2.set({ left: pos1.left, top: pos1.top }); - })) { +export function performSwap(token1, token2, pos1, pos2, msg, onVerified, onFailed) { + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); + + if ( + !withLiveTokens({ token1Id, token2Id, msg }, ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ left: pos2.left, top: pos2.top }); + liveToken2.set({ left: pos1.left, top: pos1.top }); + }) + ) { return; } @@ -258,24 +354,24 @@ export function performSwap( if (!livePair) { whisperSenderError( msg, - "Swap cancelled because one or both tokens are no longer available.", - "Swap Cancelled", + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled' ); - if (typeof onFailed === "function") { + if (typeof onFailed === 'function') { onFailed(); } return; } if (hasVerifiedSwapPosition(livePair.token1, livePair.token2, pos1, pos2)) { - const token1Name = getSafeTokenName(livePair.token1, "Token 1"); - const token2Name = getSafeTokenName(livePair.token2, "Token 2"); + const token1Name = getSafeTokenName(livePair.token1, 'Token 1'); + const token2Name = getSafeTokenName(livePair.token2, 'Token 2'); whisperSender( msg, `Swap Successful!
${token1Name} ↔ ${token2Name}`, - "Success", + 'Success' ); - if (typeof onVerified === "function") { + if (typeof onVerified === 'function') { onVerified(); } return; @@ -283,8 +379,8 @@ export function performSwap( attempt += 1; if (attempt >= maxVerificationAttempts) { - whisperSenderError(msg, "Token swap failed verification."); - if (typeof onFailed === "function") { + whisperSenderError(msg, 'Token swap failed verification.'); + if (typeof onFailed === 'function') { onFailed(); } return; @@ -348,11 +444,11 @@ function runInvisibleTravelPhase(context) { } = context; const hideRenderBufferMs = 80; const revealRenderBufferMs = 120; - const token1Id = token1.get("_id"); - const token2Id = token2.get("_id"); + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); - const layer1 = token1.get("layer"); - const layer2 = token2.get("layer"); + const layer1 = token1.get('layer'); + const layer2 = token2.get('layer'); const revealThenFx = () => { withLiveTokens({ token1Id, token2Id, msg }, ({ token1: liveToken1, token2: liveToken2 }) => { @@ -369,12 +465,12 @@ function runInvisibleTravelPhase(context) { liveToken1.set({ left: pos2.left, top: pos2.top }); liveToken2.set({ left: pos1.left, top: pos1.top }); - const token1Name = getSafeTokenName(liveToken1, "Token 1"); - const token2Name = getSafeTokenName(liveToken2, "Token 2"); + const token1Name = getSafeTokenName(liveToken1, 'Token 1'); + const token2Name = getSafeTokenName(liveToken2, 'Token 2'); whisperSender( msg, `Swap Successful!
${token1Name} ↔ ${token2Name}`, - "Success", + 'Success' ); if (msBeforeDestinationFx > 0) { @@ -389,8 +485,8 @@ function runInvisibleTravelPhase(context) { // position-change flash, unlike baseOpacity which Roll20 ignores on move renders. if ( !withLiveTokens({ token1Id, token2Id, msg }, ({ token1: liveToken1, token2: liveToken2 }) => { - liveToken1.set({ layer: "gmlayer" }); - liveToken2.set({ layer: "gmlayer" }); + liveToken1.set({ layer: 'gmlayer' }); + liveToken2.set({ layer: 'gmlayer' }); }) ) { return; @@ -436,7 +532,7 @@ export function executeSwapPipeline(config, token1, token2, pos1, pos2, msg) { const msTravelTime = travelTime * 1000; const msSwapDelay = swapDelay * 1000; const msBeforeDestinationFx = (destinationDelay + destinationTime) * 1000; - const useInvisibleTravel = travelMode === "invisible"; + const useInvisibleTravel = travelMode === 'invisible'; spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); spawnPointFx(pos2.left, pos2.top, originFx, pos2.page);