diff --git a/SwapTokenPositions/2.0.0/SwapTokenPositions.js b/SwapTokenPositions/2.0.0/SwapTokenPositions.js new file mode 100644 index 000000000..8835bd317 --- /dev/null +++ b/SwapTokenPositions/2.0.0/SwapTokenPositions.js @@ -0,0 +1,1789 @@ +/** + * 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-04-25T01:23:35.563Z + */ +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 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_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', + }; + + 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 MANAGEMENT_FLAGS = [ + FLAG_SHOW_SETTINGS, + FLAG_CHECK_SETTINGS, + FLAG_RESET_SETTINGS, + FLAG_INSTALL_MACRO, + ]; + + const SILENT_MANAGEMENT_FLAGS = [ + FLAG_HELP, + FLAG_SHOW_SETTINGS, + FLAG_CHECK_SETTINGS, + FLAG_RESET_SETTINGS, + FLAG_INSTALL_MACRO, + ]; + + /** + * 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 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 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
`, + ].join(''); + whisperGM(settingsMsg, 'Persistent Settings', 'left'); + } + + /** + * 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_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).
', + '
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.
", + '
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(''); + + 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}`, + ); + } + } + + /** + * 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); + } + + /** + * 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; + } + + return false; + } + + /** + * 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; + } + + /** + * Main API command handler for !swap-tokens. + * + * @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); + const tokens = getSelectedTokens(msg); + + if (handleManagementCommands(msg, isGM)) { + return; + } + + 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 a2f14dd62..6e138fb97 100644 --- a/SwapTokenPositions/CHANGELOG.md +++ b/SwapTokenPositions/CHANGELOG.md @@ -2,16 +2,44 @@ All notable changes to the **SwapTokenPositions** script will be documented in this file. +## [2.0.0] - 2026-04-24 + +### Added + +- New staged FX pipeline with explicit origin, travel, and destination phases. +- New FX flags: `--origin-fx`, `--travel-fx`, `--destination-fx`. +- New timing flags: `--origin-time`, `--travel-time`, `--destination-time`, `--swap-delay`, `--destination-delay`. +- New travel visibility flag: `--travel-mode` with values `normal` and `invisible`. +- Preset system with `portal`, `lightning`, `shadow`, `fire`, `magic`, `transport`, and `none`. +- `--instant` flag to force immediate swap. +- Backward-compatibility parsing for legacy flags with deprecation warnings. +- Modular multi-file source structure under `src/`. +- Local build tooling (`rollup`) to generate single-file artifacts for Roll20. +- Build banner metadata in generated output, including build timestamp. +- Explicit same-page validation for selected tokens before swap. +- Delayed pipeline safety checks that cancel gracefully if tokens disappear mid-sequence. + +### Changed + +- Refactored internal architecture from a monolithic file to source modules with a generated bundle. +- Replaced the v1 mode-centric flow (`--mode` + repeated beam cycle) with a staged pipeline (`origin -> travel -> swap -> destination`) driven by stage FX and timing flags. + +### Deprecated + +- `--mode` (mapped for compatibility: `beams` -> `--preset lightning`, `transport` -> `--preset transport`) +- `--duration` (replaced by `--swap-delay`) +- `--beam-fx` (replaced by `--travel-fx`) +- `--burst-fx` (replaced by `--destination-fx`) + ## [1.0.0] - 2026-04-21 ### Added -- Complete modernization of the script architecture with a focus on maintainability. - Arcane-themed styled messaging for whispers and announcements. - Persistent state management for GM settings (saves between sessions). - One-time override support for duration, animation mode, and FX types. - New `--install-macro` command to automatically create a "SwapTokens" macro. -- "Beams" animation mode (renamed from legacy "bounce") with customizable beam FX. +- "Beams" and "transport" animation modes with customizable beam FX. - "Transport" animation mode for immediate magical relocation. - New `none` option for beam and burst FX to allow for silent, instantaneous swaps. - Strict selection validation with clear feedback on required token counts. diff --git a/SwapTokenPositions/DEVELOPERS.md b/SwapTokenPositions/DEVELOPERS.md new file mode 100644 index 000000000..abd647422 --- /dev/null +++ b/SwapTokenPositions/DEVELOPERS.md @@ -0,0 +1,126 @@ +# SwapTokenPositions Developer Guide + +This guide is for contributors who want to edit source files and regenerate the bundled script used by Roll20. + +## What You Need + +- Node.js 20.x LTS (recommended) +- npm (comes with Node.js) +- Git +- A code editor (VS Code recommended) + +Check your versions: + +```bash +node -v +npm -v +git --version +``` + +If `node` is missing or old, install/update from the official Node.js website. + +## Project Layout (Important) + +- `src/` is the source of truth for script logic. +- `SwapTokenPositions.js` is generated output. +- `/SwapTokenPositions.js` is also generated output for release/version tracking. +- `script.json` controls script metadata and the versioned output folder name. + +Do not hand-edit generated bundle files. + +## First-Time Setup + +From the `SwapTokenPositions` directory: + +```bash +npm install +``` + +This installs local dev dependencies (Rollup + Prettier) used by the build. + +## Build Commands + +### One-time build + +```bash +npm run build +``` + +This bundles `src/index.js` and writes: + +- `SwapTokenPositions.js` +- `/SwapTokenPositions.js` (version taken from `script.json`) + +### Watch mode (recommended while coding) + +```bash +npm run watch +``` + +Rebuilds automatically whenever files in `src/` change. + +## Typical Contributor Workflow + +1. Create a branch for your change. +2. Edit code in `src/`. +3. Run `npm run build`. +4. Verify generated output is updated. +5. Manually test in Roll20. +6. Commit both source changes and generated artifacts. + +## Manual Roll20 Test Loop + +1. Run `npm run build`. +2. Open `SwapTokenPositions.js`. +3. Copy the full generated file. +4. Paste into Roll20: Game Settings -> Mod (API) Scripts. +5. Save and restart sandbox. +6. Run smoke checks (`!swap-tokens`, `!swap-tokens --help`). +7. Use the full checklist in `TESTING.md` for complete validation. + +## Updating Version Metadata + +If behavior changes in a release-worthy way: + +1. Update `script.json` version. +2. Update `CHANGELOG.md`. +3. Run `npm run build`. +4. Confirm output appears in the new version folder. + +## Troubleshooting + +### `npm run build` fails + +- Run `npm install` again. +- Remove `node_modules` and reinstall: + +```bash +rm -rf node_modules package-lock.json +npm install +``` + +On Windows PowerShell, use: + +```powershell +Remove-Item -Recurse -Force node_modules +Remove-Item package-lock.json +npm install +``` + +### Build succeeds but Roll20 behavior is unchanged + +- Ensure the latest `SwapTokenPositions.js` content was pasted into Roll20. +- Save and restart the Roll20 sandbox. +- Confirm you are testing in the correct game. + +### Watch mode does not appear to rebuild + +- Make sure you started it from the `SwapTokenPositions` folder. +- Save the file you edited in `src/`. +- Stop and restart watch mode. + +## Notes for New Contributors + +- You do not need to understand Rollup internals to contribute. +- Most changes only require editing files in `src/` and running `npm run build`. +- If you are unsure what to test, start with `TESTING.md` baseline checks. diff --git a/SwapTokenPositions/README.md b/SwapTokenPositions/README.md index 473a3a297..dc38743e7 100644 --- a/SwapTokenPositions/README.md +++ b/SwapTokenPositions/README.md @@ -5,16 +5,39 @@ ## Features - **Seamless Swapping**: Select exactly two tokens on the same page and run `!swap-tokens` to switch their positions. -- **Animation Styles**: - - `beams`: Spawns arcane beams back and forth between the tokens before they swap. - - `transport`: Spawns vertical light columns and shimmer effects at both locations. -- **Customizable FX**: Choose from a wide variety of beam and burst effects. -- **Persistent Settings**: GMs can customize the global defaults (duration, mode, FX) and save them permanently. +- **Staged Animation Pipeline**: + - `origin`: Point FX at starting positions. + - `travel`: Beam FX and optional travel visibility behavior. + - `destination`: Point FX after swap completes. +- **Customizable FX**: Choose from a wide variety of point and beam effects. +- **Persistent Settings**: GMs can customize staged defaults (FX, travel mode, timing/delays) and save them permanently. - **One-Time Overrides**: Players and GMs can use command flags to customize a single swap without changing global defaults. - **Styled Feedback**: Professional arcane-themed message boxes for success, errors, and settings. - **Macro Installation**: Automatically create a global "SwapTokens" macro for your game. +- **Preset Support**: Includes `portal`, `lightning`, `shadow`, `fire`, `magic`, `transport`, and `none` presets. +- **Legacy Compatibility**: Supports deprecated `--duration`, `--beam-fx`, and `--burst-fx` flags with warnings. -## Commands +## Contributor Docs + +This README focuses on Roll20 command usage. For contributor-oriented details, use these docs: + +- [DEVELOPERS.md](DEVELOPERS.md) for setup, build, watch mode, troubleshooting, and contributor workflow. +- [TESTING.md](TESTING.md) for the manual Roll20 validation checklist. + +## v1 to v2 Migration Notes + +The v2 series keeps the same core command (`!swap-tokens`) but changes how animation is configured. + +- `--mode` (`beams|transport`) is still accepted as a deprecated legacy flag and maps to presets: + - `--mode beams` -> `--preset lightning` + - `--mode transport` -> `--preset transport` +- New commands should use `--preset` and staged flags directly. +- `--beam-fx` still works as a deprecated alias for `--travel-fx`. +- `--burst-fx` still works as a deprecated alias for `--destination-fx`. +- `--duration` still works as a deprecated alias for `--swap-delay`. +- v2 explicitly rejects cross-page token pairs. + +## Roll20 VTT Commands ### Basic Usage @@ -23,31 +46,48 @@ Swaps the two currently selected tokens using the default settings. ### Acceptable Parameters for Customization (Available to Everyone) -- `--duration <1-10>`: Seconds to play the animation before swapping. -- `--mode `: The animation style to use. - - Values: `beams`, `transport` -- `--beam-fx `: The beam FX type. - - Values: `none`, `beam-magic`, `beam-acid`, `beam-charm`, `beam-fire`, `beam-frost`, `beam-holy`, `beam-death` -- `--burst-fx `: The burst FX type. - - Values: `none`, `burst-holy`, `burst-magic`, `burst-fire`, `burst-acid`, `burst-frost`, `burst-smoke`, `explode-fire`, `explode-holy`, `burn-fire`, `burn-holy` +- `--help`: Displays the help menu. +- `--instant`: Skips all FX and timing and swaps immediately. +- `--preset `: Applies a preset. + - Values: `portal`, `lightning`, `shadow`, `fire`, `magic`, `transport`, `none` +- `--origin-fx `: Point FX at both origin positions. +- `--travel-fx `: Beam FX between positions during travel stage. +- `--destination-fx `: Point FX at both destination positions. +- `--travel-mode `: Visibility behavior during travel stage. + - Values: `normal`, `invisible` +- `--origin-time <0-10>`: Seconds to wait after origin FX. +- `--travel-time <0-10>`: Duration in seconds for the travel animation stage. +- `--destination-time <0-10>`: Additional wait before destination FX is shown. +- `--swap-delay <0-10>`: Extra delay between origin and travel stages. +- `--destination-delay <0-10>`: Extra delay before destination FX is shown. ### Examples of Customization -- `!swap-tokens --mode transport` Shows the tokens swapping using a Roll20 version of the transport FX. -- `!swap-tokens --mode beams` Shows the tokens swapping using the beams FX. -- `!swap-tokens --duration 5 --beam-fx beam-fire --mode beams` Shows the tokens swapping using the beams FX for 5 seconds with fire beams. -- `!swap-tokens --duration 2 --beam-fx beam-acid --mode beams` Shows the tokens swapping using the beams FX for 2 seconds with acid beams. -- `!swap-tokens --duration 10 --burst-fx burst-magic --mode transport` Shows the tokens swapping using a Roll20 version of the transport FX for 10 seconds with magic burst FX. -- `!swap-tokens --duration 3 --burst-fx explode-fire --mode transport` Shows the tokens swapping using a Roll20 version of the transport FX for 3 seconds with fire explode FX. -- `!swap-tokens --beam-fx none --burst-fx none` Swaps the two currently selected tokens without using any animation effects. +- `!swap-tokens --preset portal` Applies the portal preset for one swap. +- `!swap-tokens --preset transport` Applies a Star Trek-style transporter shimmer preset with hidden travel. +- `!swap-tokens --preset transport --travel-mode normal` Uses transport visuals but keeps tokens visible during travel. +- `!swap-tokens --preset lightning --travel-time 1` Applies lightning preset with explicit travel timing override. +- `!swap-tokens --origin-fx nova-magic --travel-fx beam-fire --destination-fx explode-fire` Uses custom FX for each stage. +- `!swap-tokens --origin-time 1 --swap-delay 0.5 --destination-delay 1` Uses explicit stage timing. +- `!swap-tokens --instant` Swaps immediately regardless of saved defaults. +- `!swap-tokens --beam-fx beam-fire --duration 2` Uses deprecated flags (still supported) and shows deprecation notices. ### Global Configuration (GM Only) -- `--save`: Commits any provided customization flags as the new global defaults. You must provide the customization flags you want to save, for example, just `--save --duration 5` will save the duration as the new default and keep the beam effect and swap mode as they are. +- `--save`: Commits provided customization flags as the new global defaults. - `--show-settings`: Displays the current persistent defaults in chat. +- `--check-settings`: Validates current persistent defaults and reports issues. - `--reset-settings`: Restores the script to its factory defaults. - `--install-macro`: Automatically creates a global "SwapTokens" macro in your campaign. -- `--help`: Displays the help menu. + +### Deprecated Flags + +The following flags are still supported for backward compatibility but are deprecated: + +- `--mode` (use `--preset`; `beams` maps to `lightning`, `transport` maps to `transport`) +- `--duration` (use `--swap-delay`) +- `--beam-fx` (use `--travel-fx`) +- `--burst-fx` (use `--destination-fx`) ## License diff --git a/SwapTokenPositions/SwapTokenPositions.js b/SwapTokenPositions/SwapTokenPositions.js index b34ca02c5..8835bd317 100644 --- a/SwapTokenPositions/SwapTokenPositions.js +++ b/SwapTokenPositions/SwapTokenPositions.js @@ -1,83 +1,198 @@ /** - * SwapTokenPositions - * Roll20 API Script to swap the positions of two selected tokens on the same page. - * - * Usage: Select exactly two tokens and run `!swap-tokens` in chat. - * - * - Shows a FX between tokens for a couple of seconds before swapping. - * - Swaps positions, verifies, and notifies GM. - * - * @author MidNiteShadow7 (https://app.roll20.net/users/16506286/midniteshadow7) - * @link https://app.roll20.net/forum/permalink/12727681/ - * - * @version 1.0.0 - * @lastUpdated 2026-04-24 - * @license MIT + * 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-04-25T01:23:35.563Z */ const SwapTokenPositionsMod = (() => { - "use strict"; - - /** - * Script variables and configuration parameters are defined at the top for easy customization. - * The script includes validation for FX types and colors, and provides help instructions. - * The main functionality is triggered by the `!swap-tokens` command in chat. - */ - // === Script version and last updated date === - const SWAP_TOKEN_POSITIONS_VERSION = "1.0.0"; - const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-21"; - - // === Brand Color Palette === - const COLOR_GLOW_PURPLE = "#B388FF"; - const COLOR_DEEP_ARCANE_PURPLE = "#3D1A78"; - const COLOR_BG_SOFT_BLACK = "#0A0A12"; - const COLOR_TEXT_ARCANE_SILVER = "#E6DFFF"; - const COLOR_TEXT_DIM_SILVER = "#B8AFCF"; - const COLOR_ACCENT_PINK = "#FF4D6D"; - const COLOR_ACCENT_BLUE = "#3D5AFE"; - - // UI Message Colors - const COLOR_ERROR_RED = "#D32F2F"; - const COLOR_ERROR_DARK = "#B71C1C"; - const COLOR_ERROR_LIGHT = "#FFCDD2"; - const COLOR_SUCCESS_GREEN = "#2E7D32"; - const COLOR_SUCCESS_DARK = "#1B5E20"; - const COLOR_SUCCESS_LIGHT = "#E8F5E9"; - - // === Script FX and color parameters (factory defaults) === - const SWAP_BEAM_DURATION_SECS = 2; // Default duration (seconds) - const DURATION_MIN = 1; - const DURATION_MAX = 10; - const SWAP_FX_TYPE = "beam-magic"; // Default beam FX - const SWAP_FINAL_FX_TYPE = "burst-magic"; // Default FX at new positions - const SWAP_MODE = "transport"; // Default swap mode ("beams" or "transport") - - // === Allowed beam and burst FX types and colors for validation === - const ALLOWED_BEAM_FX = [ - "none", - "beam-magic", - "beam-acid", - "beam-charm", - "beam-fire", - "beam-frost", - "beam-holy", - "beam-death", + '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 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_SWAP_MODES = ["beams", "transport"]; - const ALLOWED_BURST_FX = [ - "none", - "burst-holy", - "burst-magic", - "burst-fire", - "burst-acid", - "burst-frost", - "burst-smoke", - "explode-fire", - "explode-holy", - "burn-fire", - "burn-holy", + + const ALLOWED_TRAVEL_MODES = ['normal', 'invisible']; + + 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', ]; - // === Command Flags (Regex Constants) === + 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', + }; + const FLAG_HELP = /--help\b/i; const FLAG_SHOW_SETTINGS = /--show-settings\b/i; const FLAG_CHECK_SETTINGS = /--check-settings\b/i; @@ -85,18 +200,27 @@ const SwapTokenPositionsMod = (() => { const FLAG_SAVE = /--save\b/i; const FLAG_INSTALL_MACRO = /--install-macro\b/i; - const FLAG_DURATION = /--duration\b/i; - const FLAG_MODE = /--mode\b/i; - const FLAG_BEAM_FX = /--beam-fx\b/i; - const FLAG_BURST_FX = /--burst-fx\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; - // Grouped Flags for bulk testing const MANAGEMENT_FLAGS = [ - FLAG_HELP, FLAG_SHOW_SETTINGS, FLAG_CHECK_SETTINGS, FLAG_RESET_SETTINGS, - FLAG_SAVE, FLAG_INSTALL_MACRO, ]; @@ -108,872 +232,1558 @@ const SwapTokenPositionsMod = (() => { FLAG_INSTALL_MACRO, ]; - const OVERRIDE_FLAGS = [ - FLAG_DURATION, - FLAG_MODE, - FLAG_BEAM_FX, - FLAG_BURST_FX, - ]; - - const ALL_SCRIPT_FLAGS = [...MANAGEMENT_FLAGS, ...OVERRIDE_FLAGS]; - - /** - * Initializes the persistent state for SwapTokenPositions. - * Sets factory defaults for any settings not already stored in state. - * - * @returns {void} - */ - function initializeState() { - if (!state.SwapTokenPositions) { - state.SwapTokenPositions = {}; - } - const factoryDefaults = { - duration: SWAP_BEAM_DURATION_SECS, - beamFx: SWAP_FX_TYPE, - burstFx: SWAP_FINAL_FX_TYPE, - swapMode: SWAP_MODE, - }; - for (const [key, value] of Object.entries(factoryDefaults)) { - if (state.SwapTokenPositions[key] === undefined) { - state.SwapTokenPositions[key] = value; - } - } - } - - /** - * Returns the current effective settings from persistent state. - * - * @returns {object} - The current settings object. - */ - function getSettings() { - return state.SwapTokenPositions; - } - - /** - * Displays the current persistent settings to the GM as a styled whisper. - * - * @returns {void} - */ - function showSettings() { - const settings = getSettings(); - - const settingsMsg = [ - `Duration: ${settings.duration}s
`, - `Swap Mode: ${settings.swapMode}
`, - `Beam FX: ${settings.beamFx}
`, - `Burst FX: ${settings.burstFx}
`, - ].join(""); - whisperGM(settingsMsg, "Persistent Settings"); - } - - /** - * Resets all persistent settings back to factory defaults and confirms to the GM. - * - * @returns {void} - */ - function resetSettings() { - state.SwapTokenPositions = { - duration: SWAP_BEAM_DURATION_SECS, - beamFx: SWAP_FX_TYPE, - burstFx: SWAP_FINAL_FX_TYPE, - swapMode: SWAP_MODE, - }; - whisperGM( - "Settings reset to factory defaults.", - "Settings Reset", - ); - showSettings(); - } - - /** - * Creates a global macro for SwapTokenPositions if one doesn't already exist. - * The macro is named 'SwapTokens' and triggers the !swap-tokens command. - * - * @param {object} msgObj - The Roll20 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", - ); - } - /** - * Validates the current persistent settings against the allowed lists. - * Reports any issues to the GM and suggests a reset if necessary. + * Escapes HTML-sensitive characters for safe chat rendering. * - * @param {boolean} [silentOnSuccess=false] - If true, only reports errors. - * @returns {boolean} - True if all settings are valid, false otherwise. + * @param {string} value Text to escape. + * @returns {string} Escaped text. */ - function validateSettings(silentOnSuccess = false) { - const settings = getSettings(); - const errors = []; - - if (settings.duration < DURATION_MIN || settings.duration > DURATION_MAX) { - errors.push( - `Duration (${settings.duration}) is out of range (${DURATION_MIN}-${DURATION_MAX}).`, - ); - } - if (!ALLOWED_BEAM_FX.includes(settings.beamFx)) { - errors.push(`Beam FX '${settings.beamFx}' is no longer valid.`); - } - if (!ALLOWED_SWAP_MODES.includes(settings.swapMode)) { - errors.push(`Swap Mode '${settings.swapMode}' is no longer valid.`); - } - if (!ALLOWED_BURST_FX.includes(settings.burstFx)) { - errors.push(`Burst FX '${settings.burstFx}' is no longer valid.`); - } - - if (errors.length > 0) { - const errorMsg = [ - "Validation Issues Found:
", - errors.map((err) => `• ${err}`).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; + function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } /** - * Displays help instructions to the sender as a styled whisper. - * Lists usage, available command options, and a description of the script. + * Builds a safe display name for a token in chat output. * - * @param {object} msgObj - The Roll20 message object. - * @returns {void} + * @param {object} token Roll20 graphic token object. + * @param {string} fallback Fallback label when token has no name. + * @returns {string} Escaped token display name. */ - function showHelp(msgObj) { - const helpMsg = [ - `SwapTokenPositions v${SWAP_TOKEN_POSITIONS_VERSION}
`, - `Last Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}
`, - "
Basic Usage:
", - "!swap-tokens — Swap 2 selected tokens using current defaults.
", - "
One-Time Overrides (Everyone):
", - "Use these to customize a single swap (e.g. in a character macro).
", - `--duration <1-10> — Seconds to play FX before swapping.
`, - `--mode <type> — Style. Valid: ${ALLOWED_SWAP_MODES.join(", ")}
`, - `--beam-fx <type> — Beam FX. Valid: ${ALLOWED_BEAM_FX.join(", ")}
`, - `--burst-fx <type> — Burst FX. Valid: ${ALLOWED_BURST_FX.join(", ")}
`, - "
Global Configuration (GM Only):
", - "To change the script's permanent defaults, use flags with --save.
", - "--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.
", - "
Example (Set new global default):
", - "!swap-tokens --duration 5 --mode beams --save
", - ].join(""); - whisperSender(msgObj, helpMsg, "SwapTokenPositions Help", "left"); + function getSafeTokenName(token, fallback) { + const name = token.get('name'); + return escapeHtml(name?.trim() ? name : fallback); } /** - * Generates a styled message box using branding variables. + * Builds the standard styled chat message container. * - * @param {string} msg - The message to display inside the styled box. - * @param {string} [align="center"] - Text alignment ("left", "center", or "right"). - * @param {string} [header=""] - Optional header text for the top of the box. - * @returns {string} - The HTML string for the styled message box. + * @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"; + 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", + 'width:100%', + 'border-radius:4px', `box-shadow:1px 1px 1px ${COLOR_TEXT_DIM_SILVER}`, `text-align:${align}`, - "vertical-align:middle", - "margin:0px auto", + '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_BLUE} 0%,${COLOR_ACCENT_PINK} 100%)`, - "overflow:hidden", - ].join(";"); + `background-image:-webkit-linear-gradient(-45deg,${COLOR_ACCENT_PURPLE_DARK} 0%,${COLOR_ACCENT_PURPLE_LIGHT} 100%)`, + 'overflow:hidden', + ].join(';'); const headerHtml = header - ? `
${header}
` - : ""; + ? `
${headerLabel}
` + : ''; const contentHtml = `
${msg}
`; return `
${headerHtml}${contentHtml}
`; } /** - * Generates a styled error message box with red/danger branding. + * Builds a red error variant of the styled chat container. * - * @param {string} msg - The error message to display inside the styled box. - * @param {string} [header="Error"] - Header text for the error box. - * @param {string} [align="left"] - Text alignment ("left", "center", or "right"). - * @returns {string} - The HTML string for the styled error message box. + * @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") { + function generateStyledErrorMessage(msg, header = 'Error', align = 'left') { const mainStyle = [ - "width:100%", - "border-radius:4px", + 'width:100%', + 'border-radius:4px', `box-shadow:1px 1px 1px ${COLOR_ERROR_RED}`, `text-align:${align}`, - "vertical-align:middle", - "margin:0px auto", + '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(";"); + 'overflow:hidden', + ].join(';'); - const headerHtml = `
[!] ${header}
`; + const headerHtml = `
âš ī¸ ${header}
`; const contentHtml = `
${msg}
`; return `
${headerHtml}${contentHtml}
`; } /** - * Generates a styled success message box with green branding. + * Builds a green success variant of the styled chat container. * - * @param {string} msg - The success message to display inside the styled box. - * @param {string} [header="Success"] - Header text for the success box. - * @returns {string} - The HTML string for the styled success message box. + * @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") { + function generateStyledSuccessMessage(msg, header = 'Success') { const mainStyle = [ - "width:100%", - "border-radius:4px", + 'width:100%', + 'border-radius:4px', `box-shadow:1px 1px 1px ${COLOR_SUCCESS_GREEN}`, - "text-align:center", - "vertical-align:middle", - "margin:0px auto", + '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(";"); + 'overflow:hidden', + ].join(';'); - const headerHtml = `
✅ ${header}
`; + const headerHtml = `
✅ ${header}
`; const contentHtml = `
${msg}
`; return `
${headerHtml}${contentHtml}
`; } /** - * Sends a formatted whisper message to the GM using brand colors and styles. + * Whispers a styled message card to the GM. * - * @param {string} msg - The message to send. - * @param {string} [header=""] - Optional header text. - * @param {string} [align="center"] - Text alignment. + * @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( - "SwapTokenPositions", - `/w GM ${generateStyledMessage(msg, align, header)}`, - ); + function whisperGM(msg, header = '', align = 'center') { + sendChat(SCRIPT_NAME, `/w GM ${generateStyledMessage(msg, align, header)}`); } /** - * Sends a formatted whisper message to the message sender. + * Whispers a styled message card to the user that sent the command. * - * @param {object} msgObj - The Roll20 message object. - * @param {string} text - The message to send. - * @param {string} [header=""] - Optional header text. - * @param {string} [align="center"] - Text alignment. + * @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 p = getObj("player", msgObj.playerid); - const name = p ? p.get("_displayname") : msgObj.who; + function whisperSender(msgObj, text, header = '', align = 'center') { + const player = getObj('player', msgObj.playerid); + const name = player ? player.get('_displayname') : msgObj.who; sendChat( - "SwapTokenPositions", + SCRIPT_NAME, `/w "${name}" ${generateStyledMessage(text, align, header)}`, ); } /** - * Sends a formatted error whisper message to the message sender. + * Whispers an error-styled message card to the user that sent the command. * - * @param {object} msgObj - The Roll20 message object. - * @param {string} text - The error message to send. - * @param {string} [header="Error"] - Optional header text. - * @param {string} [align="left"] - Text alignment. + * @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 p = getObj("player", msgObj.playerid); - const name = p ? p.get("_displayname") : msgObj.who; + function whisperSenderError(msgObj, text, header = 'Error', align = 'left') { + const player = getObj('player', msgObj.playerid); + const name = player ? player.get('_displayname') : msgObj.who; sendChat( - "SwapTokenPositions", + SCRIPT_NAME, `/w "${name}" ${generateStyledErrorMessage(text, header, align)}`, ); } /** - * Sends a formatted chat announcement to all players using brand colors and styles. + * Whispers a success-styled message card to the GM. * - * @param {string} msg - The message to announce. - * @param {string} [header=""] - Optional header text. + * @param {string} text Success body as HTML. + * @param {string} [header="Success"] Optional header label. * @returns {void} */ - function announce(msg, header = "") { + function whisperGMSuccess(text, header = 'Success') { sendChat( - "SwapTokenPositions", - generateStyledMessage(msg, "center", header), + SCRIPT_NAME, + `/w GM ${generateStyledSuccessMessage(text, header)}`, ); } /** - * Sends a formatted success whisper message to the GM using the green success style. + * Whispers an error-styled message card to the GM. * - * @param {string} text - The success message to send. - * @param {string} [header="Success"] - Optional header text. + * @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 whisperGMSuccess(text, header = "Success") { + function whisperGMError(text, header = 'Error', align = 'left') { sendChat( - "SwapTokenPositions", - `/w GM ${generateStyledSuccessMessage(text, header)}`, + SCRIPT_NAME, + `/w GM ${generateStyledErrorMessage(text, header, align)}`, ); } /** - * Sends a formatted error whisper message to the GM using the red danger style. + * Parses a string flag and validates it against an allowed set. * - * @param {string} text - The error message to send. - * @param {string} [header="Error"] - Optional header text. - * @param {string} [align="left"] - Text alignment. - * @returns {void} + * @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 whisperGMError(text, header = "Error", align = "left") { - sendChat( - "SwapTokenPositions", - `/w GM ${generateStyledErrorMessage(text, header, align)}`, + 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] }; } /** - * Spawns a beam FX between two points, with validation. - * Falls back to default FX type if the provided type is invalid. + * Parses a numeric flag and validates it against an inclusive range. * - * @param {number} fromX - Start X coordinate. - * @param {number} fromY - Start Y coordinate. - * @param {number} toX - End X coordinate. - * @param {number} toY - End Y coordinate. - * @param {string} pageId - Page ID for the FX. - * @param {string} [fxType=SWAP_FX_TYPE] - Beam FX type (e.g. "beam-magic"). - * @returns {void} + * @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 spawnBeamFx(fromX, fromY, toX, toY, pageId, fxType = SWAP_FX_TYPE) { - if (fxType === "none") { - return; + 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 }; } - - if (!ALLOWED_BEAM_FX.includes(fxType)) { - whisperGMError( - `Invalid beam FX type: ${fxType}.

Using default: ${SWAP_FX_TYPE}`, - "FX Compatibility", - ); - fxType = SWAP_FX_TYPE; + const value = Number.parseFloat(match[1]); + if (!Number.isNaN(value) && value >= min && value <= max) { + return { found: true, valid: true, value }; } - - spawnFxBetweenPoints( - { x: fromX, y: fromY, pageid: pageId }, - { x: toX, y: toY, pageid: pageId }, - fxType, - ); + return { found: true, valid: false, value: null }; } /** - * Spawns a burst/final FX at a position, with validation. - * Falls back to default burst FX type if the provided type is invalid. + * Applies a parsed string flag result to config and update tracking. * - * @param {number} x - X coordinate. - * @param {number} y - Y coordinate. - * @param {string} fxType - Burst FX type (e.g. "burst-holy"). - * @param {string} pageId - Page ID. + * @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 spawnFinalFx(x, y, fxType, pageId) { - if (fxType === "none") { - return; + 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'); } + } - if (!ALLOWED_BURST_FX.includes(fxType)) { - whisperGMError( - `Invalid burst FX type: ${fxType}.

Using default: ${SWAP_FINAL_FX_TYPE}`, - "FX Compatibility", + /** + * 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', ); - fxType = SWAP_FINAL_FX_TYPE; } - - spawnFx(x, y, fxType, pageId); } /** - * Parses the --duration flag from the command content. + * Parses and applies a collection of string flags. * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {number} - The beam duration in seconds. + * @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 parseDuration(msgObj, updateTracker) { - const match = new RegExp( - String.raw`${FLAG_DURATION.source}\s+(\d+)`, - "i", - ).exec(msgObj.content); - if (!match) { - return getSettings().duration; - } - const requested = Number.parseInt(match[1], 10); - if (requested >= DURATION_MIN && requested <= DURATION_MAX) { - updateTracker.valid++; - return requested; + 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); } - updateTracker.invalid++; - whisperSenderError( - msgObj, - `Duration must be between ${DURATION_MIN} and ${DURATION_MAX} seconds.

Using default: ${getSettings().duration}s`, - "Invalid Input", - ); - return getSettings().duration; } /** - * Parses the --beam-fx flag from the command content. + * Parses and applies a collection of numeric flags. * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {string} - The beam FX type. + * @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 parseBeamFx(msgObj, updateTracker) { - const match = new RegExp( - String.raw`${FLAG_BEAM_FX.source}\s+(\S+)`, - "i", - ).exec(msgObj.content); - if (!match) { - return getSettings().beamFx; - } - if (ALLOWED_BEAM_FX.includes(match[1])) { - updateTracker.valid++; - return match[1]; + 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, + }); } - updateTracker.invalid++; - whisperSenderError( - msgObj, - `Invalid beam FX: ${match[1]}.

Valid: ${ALLOWED_BEAM_FX.join(", ")}

Using default: ${getSettings().beamFx}`, - "Invalid Input", - ); - return getSettings().beamFx; } /** - * Parses the --mode flag from the command content. + * Ensures persisted script settings exist and backfills missing keys with defaults. * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {string} - The swap mode ("beams" or "transport"). + * @returns {void} */ - function parseSwapMode(msgObj, updateTracker) { - const match = new RegExp(String.raw`${FLAG_MODE.source}\s+(\S+)`, "i").exec( - msgObj.content, - ); - if (!match) { - return getSettings().swapMode; + function initializeState() { + if (!state.SwapTokenPositions) { + state.SwapTokenPositions = {}; } - if (ALLOWED_SWAP_MODES.includes(match[1].toLowerCase())) { - updateTracker.valid++; - return match[1].toLowerCase(); + for (const [key, value] of Object.entries(FACTORY_DEFAULTS)) { + if (state.SwapTokenPositions[key] === undefined) { + state.SwapTokenPositions[key] = value; + } } - updateTracker.invalid++; - whisperSenderError( - msgObj, - `Invalid swap mode: ${match[1]}.

Valid: ${ALLOWED_SWAP_MODES.join(", ")}

Using default: ${getSettings().swapMode}`, - "Invalid Input", - ); - return getSettings().swapMode; } /** - * Parses the --burst-fx flag from the command content. + * Retrieves persisted script settings from Roll20 state. * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {string} - The burst FX type. + * @returns {object} Effective script settings object. */ - function parseBurstFx(msgObj, updateTracker) { - const match = new RegExp( - String.raw`${FLAG_BURST_FX.source}\s+(\S+)`, - "i", - ).exec(msgObj.content); - if (!match) { - return getSettings().burstFx; - } - if (ALLOWED_BURST_FX.includes(match[1])) { - updateTracker.valid++; - return match[1]; - } - updateTracker.invalid++; - whisperSenderError( - msgObj, - `Invalid burst FX: ${match[1]}.

Valid: ${ALLOWED_BURST_FX.join(", ")}

Using default: ${getSettings().burstFx}`, - "Invalid Input", - ); - return getSettings().burstFx; + function getSettings() { + return state.SwapTokenPositions; } /** - * Processes management commands like --help, --show-settings, etc. + * Renders the current persisted settings to GM chat. * - * @param {object} msg - The Roll20 message object. - * @param {boolean} isGM - Whether the sender is a GM. - * @returns {boolean} - True if a command was handled and we should exit. + * @returns {void} */ - function handleManagementCommands(msg, isGM) { - if (FLAG_HELP.test(msg.content)) { - showHelp(msg); - return true; - } + function showSettings() { + const settings = getSettings(); + 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
`, + ].join(''); + whisperGM(settingsMsg, 'Persistent Settings', 'left'); + } - const hasManagementFlag = MANAGEMENT_FLAGS.some((flag) => - flag.test(msg.content), + /** + * Resets persisted script settings to factory defaults. + * + * @returns {void} + */ + function resetSettings() { + state.SwapTokenPositions = { ...FACTORY_DEFAULTS }; + whisperGM( + 'Settings reset to factory defaults.', + 'Settings Reset', ); + showSettings(); + } - if (!isGM && hasManagementFlag) { - whisperSenderError( - msg, - "You do not have permission to use script management flags.", - "Access Denied", - ); - return true; - } + /** + * 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 (FLAG_SHOW_SETTINGS.test(msg.content)) { - showSettings(); - return true; + if (!ALLOWED_POINT_FX.includes(settings.originFx)) { + errors.push(`Origin FX '${settings.originFx}' is no longer valid.`); } - if (FLAG_CHECK_SETTINGS.test(msg.content)) { - validateSettings(); - return true; + if (!ALLOWED_TRAVEL_FX.includes(settings.travelFx)) { + errors.push(`Travel FX '${settings.travelFx}' is no longer valid.`); } - if (FLAG_RESET_SETTINGS.test(msg.content)) { - resetSettings(); - return true; + if (!ALLOWED_TRAVEL_MODES.includes(settings.travelMode)) { + errors.push(`Travel Mode '${settings.travelMode}' is no longer valid.`); } - if (FLAG_INSTALL_MACRO.test(msg.content)) { - installMacro(msg); - return true; + if (!ALLOWED_POINT_FX.includes(settings.destinationFx)) { + errors.push( + `Destination FX '${settings.destinationFx}' is no longer valid.`, + ); } - return false; - } + 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}).`); + } + } - /** - * Handles the persistent saving of settings if requested. - * - * @param {object} msg - The Roll20 message object. - * @param {boolean} isGM - Whether the sender is a GM. - * @param {object} tracker - The update tracker object. - * @param {object} values - The parsed override values. - * @returns {boolean} - True if we should exit after processing. - */ - function processPersistence(msg, isGM, tracker, values) { - if (!FLAG_SAVE.test(msg.content) || !isGM) { + 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 (tracker.valid > 0 && tracker.invalid === 0) { - state.SwapTokenPositions.duration = values.duration; - state.SwapTokenPositions.swapMode = values.mode; - state.SwapTokenPositions.beamFx = values.beamFx; - state.SwapTokenPositions.burstFx = values.burstFx; + if (!silentOnSuccess) { 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 --duration or --mode along with --save.", - "Nothing to Save", + 'All persistent settings are valid.', + 'Settings Validation', ); } return true; } /** - * Validates selection and retrieves the two tokens for swapping. + * Applies deprecated flags to the active config while emitting compatibility warnings. * - * @param {object} msg - The Roll20 message object. - * @returns {object[]|null} - Array of two token objects, or null if invalid. + * @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 getSelectedTokens(msg) { - const selectedCount = (msg.selected || []).length; + function applyLegacyFlags(msg, config, updateTracker) { + const content = msg.content; + const legacyModeToPreset = { + beams: 'lightning', + transport: 'transport', + }; - if (selectedCount !== 2) { - // Suppress error if this is a "silent" management command (help, reset, etc.) - // Note: --save is intentionally excluded from silent flags as it is used with move commands. - const isSilent = SILENT_MANAGEMENT_FLAGS.some((flag) => - flag.test(msg.content), - ); + const modeResult = parseStringFlag( + content, + FLAG_LEGACY_MODE, + Object.keys(legacyModeToPreset), + ); - if (!isSilent) { + 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, - `Please select exactly two tokens to perform a swap. (Currently selected: ${selectedCount})`, - "Selection Error", + `Invalid value for deprecated --mode: '${modeResult.value}'.

Valid: ${Object.keys(legacyModeToPreset).join(', ')}`, + 'Invalid Input', ); } - return null; } - const token1 = getObj("graphic", msg.selected[0]._id); - const token2 = getObj("graphic", msg.selected[1]._id); - - if (!token1 || !token2) { - whisperSenderError( + 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, - "One or both selected tokens could not be found.", + `${oldName} is deprecated. Use ${newName} instead.`, + 'Deprecated Flag', + 'left', ); - return null; - } + 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', + ); + } + } - return [token1, token2]; + 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', + ); + } + } } /** - * Handles the !swap-tokens API command. - * Parses command options, validates token selection, and executes the swap logic. + * Applies a preset configuration layer when the preset flag is present. * - * @param {object} msg - The Roll20 chat message object. + * @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} */ - const handleSwapTokens = (msg) => { - if (msg.type !== "api" || !/^!swap-tokens\b/i.test(msg.content)) { + 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', + ); + } + } - const isGM = playerIsGM(msg.playerid); + /** + * 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, + ); - // 1. Always validate tokens first (as requested for testing/visibility) - const tokens = getSelectedTokens(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, + ); - // 2. Handle Management Commands (Help, Reset, etc.) - if (handleManagementCommands(msg, isGM)) { + 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).
', + '
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.
", + '
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(''); + + 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}`, + ); + } + } - // 3. Exit if tokens were invalid and no management command was handled - if (!tokens) { + /** + * 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}`, + ); + } + } - // 4. Parse Overrides - const updateTracker = { valid: 0, invalid: 0 }; - const overrides = { - duration: parseDuration(msg, updateTracker), - mode: parseSwapMode(msg, updateTracker), - beamFx: parseBeamFx(msg, updateTracker), - burstFx: parseBurstFx(msg, updateTracker), + /** + * 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); }; - // 3. Handle Persistence (--save) - if (processPersistence(msg, isGM, updateTracker, overrides)) { + if (delayMs > 0) { + setTimeout(spawn, delayMs); return; } - // 4. Feedback for one-time overrides - if (updateTracker.valid > 0 && !FLAG_SAVE.test(msg.content)) { - const overrideDetails = [ - `Duration: ${overrides.duration}s`, - `Mode: ${overrides.mode}`, - `Beam: ${overrides.beamFx}`, - `Burst: ${overrides.burstFx}`, - ].join("
"); - whisperSender(msg, overrideDetails, "Override Active"); + 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; } - const [token1, token2] = tokens; - const position1 = { - left: token1.get("left"), - top: token1.get("top"), - page: token1.get("pageid"), + 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); }; - const position2 = { - left: token2.get("left"), - top: token2.get("top"), - page: token2.get("pageid"), + + 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); }; - const bounceInterval = 250; - const maxBounces = Math.max( - 1, - Math.floor((overrides.duration * 1000) / bounceInterval), - ); - let bounceCount = 0; - - /** - * Finalizes the token swap by updating coordinates on the Roll20 objects. - * Verifies the swap was successful and triggers the final arrival FX. - * - * @returns {void} - */ - function swapPositions() { - token1.set({ left: position2.left, top: position2.top }); - token2.set({ left: position1.left, top: position1.top }); - - const isVerified = - token1.get("left") === position2.left && - token2.get("left") === position1.left; - - if (isVerified) { - spawnFinalFx( - position2.left, - position2.top, - overrides.burstFx, - position2.page, - ); - spawnFinalFx( - position1.left, - position1.top, - overrides.burstFx, - position1.page, + 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!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, - "Success", + `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 { - whisperSenderError(msg, "Token swap failed verification."); + 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; } - /** - * Executes the 'beams' animation style. - * Recursively spawns beams back and forth between tokens until the duration expires. - * - * @returns {void} - */ - function doBeams() { - if (bounceCount >= maxBounces) { - swapPositions(); + setTimeout(() => { + sustainTravelFx(pos1, pos2, travelFx, msTravelTime, () => {}); + + const msBeforeHiddenSwap = msTravelTime + msSwapDelay; + if (msBeforeHiddenSwap > 0) { + setTimeout(doMove, msBeforeHiddenSwap); return; } - const from = bounceCount % 2 === 0 ? position1 : position2; - const to = bounceCount % 2 === 0 ? position2 : position1; - - spawnBeamFx( - from.left, - from.top, - to.left, - to.top, - from.page, - overrides.beamFx, - ); - bounceCount++; - setTimeout(doBeams, bounceInterval); - } - - /** - * Executes the 'transport' animation style. - * Spawns vertical light columns and simultaneous shimmer bursts at both locations. - * - * @returns {void} - */ - function doTransport() { - if (bounceCount >= maxBounces) { - swapPositions(); + 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; } - [position1, position2].forEach((pos) => { - if (overrides.beamFx !== "none") { - spawnFxBetweenPoints( - { x: pos.left, y: pos.top - 70, pageid: pos.page }, - { x: pos.left, y: pos.top + 70, pageid: pos.page }, - overrides.beamFx, - ); - } - if (overrides.burstFx !== "none") { - spawnFx(pos.left, pos.top, overrides.burstFx, pos.page); - } + + runNormalTravelPhase({ + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msTravelTime, + msSwapDelay, + msBeforeDestinationFx, }); - bounceCount++; - setTimeout(doTransport, bounceInterval); - } + }, msBeforeTravel); + } - // Bypass animation if all FX are disabled - if (overrides.beamFx === "none" && overrides.burstFx === "none") { - swapPositions(); + /** + * 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; } - if (overrides.mode === "beams") { - doBeams(); + 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; + } + + return false; + } + + /** + * 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 { - doTransport(); + whisperGMError( + 'No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.', + 'Nothing to Save', + ); } - }; + return true; + } /** - * Initializes persistent state, validates settings, and logs the ready message. - * Called once when the API sandbox is ready. + * Main API command handler for !swap-tokens. * + * @param {object} msg Roll20 chat message object. * @returns {void} */ - const checkInstall = () => { + function handleSwapTokens(msg) { + 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; + } + + 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); // Silent check on load + validateSettings(true); log( - `-=> SwapTokenPositions v${SWAP_TOKEN_POSITIONS_VERSION} [Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}] <=-`, + `-=> ${SCRIPT_NAME} v${SWAP_TOKEN_POSITIONS_VERSION} [Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}] <=-`, ); whisperGM( `MOD READY (v${SWAP_TOKEN_POSITIONS_VERSION})`, - "Script Ready", + 'Script Ready', ); - }; - - /** - * Registers all Roll20 event handlers for the script. - * - * @returns {void} - */ - const registerEventHandlers = () => { - on("chat:message", handleSwapTokens); - }; - - on("ready", () => { - checkInstall(); - registerEventHandlers(); + on('chat:message', handleSwapTokens); }); - - return {}; })(); diff --git a/SwapTokenPositions/TESTING.md b/SwapTokenPositions/TESTING.md new file mode 100644 index 000000000..093a056e8 --- /dev/null +++ b/SwapTokenPositions/TESTING.md @@ -0,0 +1,271 @@ +# SwapTokenPositions Manual Testing + +This document provides a manual test plan for validating `SwapTokenPositions` in a live Roll20 VTT game before One-Click publication. + +## Test Environment Setup + +1. Build the script locally: + +```bash +npm run build +``` + +2. In Roll20, open your game: + - Go to **Game Settings -> Mod (API) Scripts**. + - Open `SwapTokenPositions.js` from this repo. + - Copy/paste the full generated file into the Roll20 script editor. + - Save and restart the sandbox. + +3. Prepare a map page with: + - At least 2 graphic tokens on the same page. + - At least 1 additional token on a different page (for cross-page sanity checks). + - One GM account and one non-GM player account (or equivalent test users). + +4. Open chat as GM and player so whispers and permission behavior can be verified. + +## Baseline Sanity Checks + +1. **Script ready message** + - Action: Restart sandbox. + - Expected: + - API console logs script version and updated date. + - GM receives the styled `MOD READY` whisper. + +2. **Help command** + - Action: Run `!swap-tokens --help` as GM and as player. + - Expected: + - Sender receives full help output. + - No token selection required. + +## Selection and Input Validation + +1. **No selection** + - Action: Run `!swap-tokens` with no selected token. + - Expected: Sender gets `Selection Error` asking for exactly two tokens. + +2. **One token selected** + - Action: Select one token and run `!swap-tokens`. + - Expected: Same `Selection Error`. + +3. **Three+ tokens selected** + - Action: Select three tokens and run `!swap-tokens`. + - Expected: Same `Selection Error`. + +4. **Broken selection reference** + - Action: Select two tokens, delete one, then run command quickly. + - Expected: Error that one or both tokens could not be found. + +## Core Swap Behavior + +1. **Immediate swap with defaults** + - Action: Select two tokens and run `!swap-tokens`. + - Expected: + - Tokens swap positions. + - Sender receives `Swap Successful!` message. + +2. **Force instant mode** + - Action: `!swap-tokens --instant` + - Expected: + - Immediate swap regardless of saved defaults. + - No staged FX delay. + +3. **No-FX/no-timing config path** + - Action: `!swap-tokens --origin-fx none --travel-fx none --destination-fx none --origin-time 0 --travel-time 0 --swap-delay 0 --destination-delay 0` + - Expected: Immediate swap; no FX shown. + +## Preset and Override Tests + +1. **Portal preset** + - Action: `!swap-tokens --preset portal` + - Expected: + - Staged FX appears (origin, travel beam, destination). + - Swap occurs after preset timing. + +2. **Lightning preset** + - Action: `!swap-tokens --preset lightning` + - Expected: Fast travel beam effect and quick swap. + +3. **Preset + explicit override precedence** + - Action: `!swap-tokens --preset portal --travel-time 3` + - Expected: + - Preset applies. + - Explicit `--travel-time 3` overrides preset travel timing. + - Sender sees `Override Active` whisper values. + +4. **Custom stage FX** + - Action: `!swap-tokens --origin-fx nova-magic --travel-fx beam-fire --destination-fx explode-fire` + - Expected: + - Matching stage FX at each phase. + - Successful swap and confirmation whisper. + +## Travel Mode Validation + +1. **Invisible travel mode behavior** + - Action: `!swap-tokens --travel-mode invisible --origin-time 0 --travel-time 1 --destination-delay 0` + - Expected: + - Tokens are hidden during travel phase. + - Tokens are restored and visible after swap completes. + +2. **Normal travel mode behavior** + - Action: `!swap-tokens --travel-mode normal --origin-time 0 --travel-time 1 --destination-delay 0` + - Expected: + - Tokens remain visible throughout travel phase. + - Swap completes without visibility flicker. + +3. **Preset default + override** + - Action: `!swap-tokens --preset transport` + - Expected: + - Transport preset uses `travel-mode invisible` by default. + - Action: `!swap-tokens --preset transport --travel-mode normal` + - Expected: + - Explicit travel mode override is honored. + +4. **Invalid travel mode value** + - Action: `!swap-tokens --travel-mode phase` + - Expected: + - Invalid input whisper for travel mode. + - Script remains stable and does not crash. + +## Timing and Range Validation + +1. **Boundary minimum values** + - Action: Run with all timing fields set to `0`. + - Expected: Accepted; command runs successfully. + +2. **Boundary maximum values** + - Action: Run with `--origin-time 10 --travel-time 10 --destination-time 10 --swap-delay 10 --destination-delay 10`. + - Expected: Accepted; long staged delays occur. + +3. **Out-of-range numeric values** + - Action: Try `--swap-delay 11` and `--origin-time -1`. + - Expected: + - Invalid input whisper for each invalid value. + - Script does not crash. + +## Deprecated Flag Warnings + +1. **Deprecated mode flag warning + mapping (beams)** + - Action: `!swap-tokens --mode beams` + - Expected: + - Sender sees deprecation warning indicating `--mode` is deprecated and mapped to a preset. + - Command still functions. + +2. **Deprecated mode flag warning + mapping (transport)** + - Action: `!swap-tokens --mode transport` + - Expected: + - Sender sees deprecation warning indicating `--mode` is deprecated and mapped to a preset. + - Command still functions. + +3. **Deprecated beam flag warning** + - Action: `!swap-tokens --beam-fx beam-fire` + - Expected: + - Sender sees deprecation warning: use `--travel-fx`. + - Command still functions. + +4. **Deprecated burst flag warning** + - Action: `!swap-tokens --burst-fx burst-holy` + - Expected: + - Sender sees deprecation warning: use `--destination-fx`. + - Command still functions. + +5. **Deprecated duration flag warning** + - Action: `!swap-tokens --duration 2` + - Expected: + - Sender sees deprecation warning: use `--swap-delay`. + - Command still functions. + +6. **Deprecated invalid values** + - Action: `!swap-tokens --beam-fx not-a-real-fx --duration 99` + - Expected: + - Deprecation warnings still appear. + - Invalid input messages are shown. + - Script remains stable. + +## GM-Only Management Commands + +Run these as GM unless otherwise specified. + +1. `!swap-tokens --show-settings` + - Expected: GM sees styled persistent settings report. + +2. `!swap-tokens --check-settings` + - Expected: Validation success (or issues list if state is malformed). + +3. `!swap-tokens --reset-settings` + - Expected: Confirmation plus settings display with factory defaults. + +4. `!swap-tokens --install-macro` + - Expected: + - First run creates global `SwapTokens` macro. + - Second run reports `Macro Exists`. + +5. Non-GM permission checks + - Action: As player, run `--show-settings`, `--check-settings`, `--reset-settings`, and `--install-macro`. + - Expected: `Access Denied` message for each. + +6. Player `--save` permission check + - Action: As player, select two tokens and run `!swap-tokens --save`. + - Expected: + - `Access Denied` whisper explaining they cannot set game defaults. + - Swap still proceeds normally. + +7. Player `--help` access + - Action: As player, run `!swap-tokens --help`. + - Expected: Full help output whispered to the player. No `Access Denied`. + +## Persistence Tests (`--save`) + +1. **Save valid defaults** + - Action: `!swap-tokens --preset portal --save` (GM). + - Expected: + - `Configuration` success message. + - `--show-settings` reflects saved values. + +2. **Save with mixed valid/invalid** + - Action: `!swap-tokens --origin-fx nova-magic --travel-time 99 --save` (GM). + - Expected: + - Save is rejected with `Save Failed`. + - Previous settings remain unchanged. + +3. **Save with no configurable flags** + - Action: `!swap-tokens --save` (GM). + - Expected: `Nothing to Save` message. + +## Regression and Stability Checks + +1. Run 10+ swaps in sequence with mixed presets and overrides. + - Expected: No crashes, no sandbox instability. + +2. Restart sandbox and rerun `--show-settings`. + - Expected: Saved settings persist across restart. + +3. **Delete token during delayed pipeline** + - Action: + - Start a delayed swap (for example `!swap-tokens --preset portal` or `!swap-tokens --travel-time 3`). + - Before swap completion, delete one selected token. + - Expected: + - Script does not crash. + - Sender receives `Swap Cancelled` (or equivalent missing-token error) instead of silent failure. + - Later swaps with valid tokens still work. + +4. **Archive/switch page during delayed pipeline** + - Action: + - Start a delayed swap with timing (`--travel-time`, `--swap-delay`, or preset with delay). + - Before completion, move to another page as GM and/or archive/remove the active page token context. + - Expected: + - Script remains stable with no sandbox errors. + - If tokens become unavailable, swap is cancelled gracefully with feedback. + - If tokens remain valid, swap completes normally. + +5. Verify command still works after reset and save cycles. + - Expected: Behavior remains consistent. + +## Exit Criteria + +All tests pass when: + +1. Core swap works reliably with valid input. +2. All management commands behave correctly by role. +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. diff --git a/SwapTokenPositions/package-lock.json b/SwapTokenPositions/package-lock.json new file mode 100644 index 000000000..a32e69f09 --- /dev/null +++ b/SwapTokenPositions/package-lock.json @@ -0,0 +1,449 @@ +{ + "name": "swap-token-positions", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swap-token-positions", + "version": "2.0.0", + "devDependencies": { + "prettier": "^3.8.3", + "rollup": "^4.52.5" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + } + } +} diff --git a/SwapTokenPositions/package.json b/SwapTokenPositions/package.json new file mode 100644 index 000000000..7148f6dce --- /dev/null +++ b/SwapTokenPositions/package.json @@ -0,0 +1,14 @@ +{ + "name": "swap-token-positions", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rollup -c", + "watch": "rollup -c -w" + }, + "devDependencies": { + "prettier": "^3.8.3", + "rollup": "^4.52.5" + } +} diff --git a/SwapTokenPositions/rollup.config.mjs b/SwapTokenPositions/rollup.config.mjs new file mode 100644 index 000000000..7ca51fe98 --- /dev/null +++ b/SwapTokenPositions/rollup.config.mjs @@ -0,0 +1,105 @@ +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 scriptName = scriptJson.name; +const scriptFile = scriptJson.script; +const buildVersion = scriptJson.version; + +const banner = [ + "/**", + " * 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"); + +/** + * Formats generated JavaScript chunks after Rollup has applied banner/intro/outro. + * + * @returns {import("rollup").Plugin} Rollup output plugin. + */ +function formatOutputPlugin() { + return { + name: "format-output", + /** + * Formats each emitted chunk with Prettier. + * + * @param {import("rollup").NormalizedOutputOptions} options Finalized output options. + * @param {import("rollup").OutputBundle} bundle Emitted output bundle. + * @returns {Promise} + */ + async generateBundle(options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== "chunk") { + continue; + } + + output.code = await prettierFormat(output.code, { + parser: "babel", + singleQuote: true, + trailingComma: "all", + }); + } + }, + }; +} + +/** @type {import("rollup").RollupOptions} */ +export default { + input: path.join(__dirname, "src", "index.js"), + plugins: [ + { + name: "inject-build-metadata", + /** + * Replaces metadata placeholders in constants with build-time values. + * + * @param {string} code Module source code. + * @param {string} id Absolute module id. + * @returns {{code: string, map: null} | null} + */ + transform(code, id) { + 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), + map: null, + }; + }, + }, + ], + output: [ + { + file: path.join(__dirname, `${scriptJson.name}.js`), + format: "es", + banner, + intro: `const ${scriptName}Mod = (() => {\n 'use strict';`, + outro: "})();", + plugins: [formatOutputPlugin()], + }, + { + file: path.join(__dirname, scriptJson.version, `${scriptJson.name}.js`), + format: "es", + banner, + intro: `const ${scriptName}Mod = (() => {\n 'use strict';`, + outro: "})();", + plugins: [formatOutputPlugin()], + }, + ], +}; diff --git a/SwapTokenPositions/script.json b/SwapTokenPositions/script.json index 8dd9dee6c..bc5e4ddfd 100644 --- a/SwapTokenPositions/script.json +++ b/SwapTokenPositions/script.json @@ -1,66 +1,186 @@ -{ - "name": "SwapTokenPositions", - "script": "SwapTokenPositions.js", - "version": "1.0.0", - "description": "Allows GMs and players to quickly swap the positions of two selected tokens on the same page.\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", - "useroptions": [ - { - "name": "duration", - "type": "number", - "default": "2", - "description": "The duration of the animation in seconds (1-10)." - }, - { - "name": "mode", - "type": "select", - "options": ["beams", "transport"], - "default": "transport", - "description": "The animation style to use." - }, - { - "name": "beam-fx", - "type": "select", - "options": [ - "none", - "beam-magic", - "beam-acid", - "beam-charm", - "beam-fire", - "beam-frost", - "beam-holy", - "beam-death" - ], - "default": "beam-magic", - "description": "The default beam FX type." - }, - { - "name": "burst-fx", - "type": "select", - "options": [ - "none", - "burst-holy", - "burst-magic", - "burst-fire", - "burst-acid", - "burst-frost", - "burst-smoke", - "explode-fire", - "explode-holy", - "burn-fire", - "burn-holy" - ], - "default": "burst-magic", - "description": "The default burst FX type." - } - ], - "dependencies": [], - "modifies": { - "state.SwapTokenPositions": "read,write", - "graphic.left": "read,write", - "graphic.top": "read,write" - }, - "conflicts": [], - "previousversions": [] -} +{ + "name": "SwapTokenPositions", + "script": "SwapTokenPositions.js", + "version": "2.0.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", + "useroptions": [ + { + "name": "origin-fx", + "type": "select", + "options": [ + "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" + ], + "default": "none", + "description": "Point FX used at both origin positions before travel stage." + }, + { + "name": "travel-fx", + "type": "select", + "options": [ + "none", + "beam-magic", + "beam-acid", + "beam-charm", + "beam-fire", + "beam-frost", + "beam-holy", + "beam-death", + "beam-energy", + "beam-lightning" + ], + "default": "none", + "description": "Beam FX used during travel stage between the two positions." + }, + { + "name": "destination-fx", + "type": "select", + "options": [ + "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" + ], + "default": "none", + "description": "Point FX used at both destination positions after swap." + }, + { + "name": "origin-time", + "type": "number", + "default": "0", + "description": "Seconds to wait after origin FX before continuing (0-10)." + }, + { + "name": "travel-time", + "type": "number", + "default": "0", + "description": "Travel animation duration in seconds (0-10)." + }, + { + "name": "travel-mode", + "type": "select", + "options": [ + "normal", + "invisible" + ], + "default": "normal", + "description": "Controls whether tokens remain visible during travel stage." + }, + { + "name": "destination-time", + "type": "number", + "default": "0", + "description": "Additional wait before destination FX is shown in seconds (0-10)." + }, + { + "name": "swap-delay", + "type": "number", + "default": "0", + "description": "Additional delay between origin and travel stages in seconds (0-10)." + }, + { + "name": "destination-delay", + "type": "number", + "default": "0", + "description": "Additional delay before destination FX is shown in seconds (0-10)." + } + ], + "dependencies": [], + "modifies": { + "state.SwapTokenPositions": "read,write", + "graphic.left": "read,write", + "graphic.top": "read,write" + }, + "conflicts": [], + "previousversions": [ + "1.0.0" + ] +} diff --git a/SwapTokenPositions/src/commands.js b/SwapTokenPositions/src/commands.js new file mode 100644 index 000000000..b893ea0fb --- /dev/null +++ b/SwapTokenPositions/src/commands.js @@ -0,0 +1,204 @@ +import { + FLAG_HELP, + FLAG_INSTANT, + FLAG_INSTALL_MACRO, + FLAG_SAVE, + FLAG_SHOW_SETTINGS, + FLAG_CHECK_SETTINGS, + FLAG_RESET_SETTINGS, + 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"; + +/** + * Creates a shared SwapTokens macro for the game when one does not already exist. + * + * @param {object} msgObj Roll20 chat message object. + * @returns {void} + */ +export 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. + */ +export 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; + } + + return false; +} + +/** + * 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. + */ +export 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; +} + +/** + * Main API command handler for !swap-tokens. + * + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ +export function handleSwapTokens(msg) { + 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; + } + + 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); +} diff --git a/SwapTokenPositions/src/config.js b/SwapTokenPositions/src/config.js new file mode 100644 index 000000000..2e6ecdf1b --- /dev/null +++ b/SwapTokenPositions/src/config.js @@ -0,0 +1,235 @@ +import { + ALLOWED_POINT_FX, + ALLOWED_PRESETS, + ALLOWED_TRAVEL_FX, + ALLOWED_TRAVEL_MODES, + DELAY_MAX, + DELAY_MIN, + FLAG_DESTINATION_DELAY, + FLAG_DESTINATION_FX, + FLAG_DESTINATION_TIME, + FLAG_LEGACY_BEAM_FX, + FLAG_LEGACY_BURST_FX, + FLAG_LEGACY_DURATION, + FLAG_LEGACY_MODE, + FLAG_ORIGIN_FX, + FLAG_ORIGIN_TIME, + FLAG_PRESET, + FLAG_SWAP_DELAY, + FLAG_TRAVEL_FX, + FLAG_TRAVEL_MODE, + FLAG_TRAVEL_TIME, + FX_PRESETS, + TIME_MAX, + TIME_MIN, +} from "./constants.js"; +import { whisperSender, whisperSenderError } from "./messages.js"; +import { + parseFloatFlag, + parseStringFlag, + processNumericFlags, + processStringFlags, +} from "./parsers.js"; +import { getSettings } from "./state.js"; + +/** + * 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} + */ +export 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} + */ +export 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. + */ +export 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; +} diff --git a/SwapTokenPositions/src/constants.js b/SwapTokenPositions/src/constants.js new file mode 100644 index 000000000..e5702bbf9 --- /dev/null +++ b/SwapTokenPositions/src/constants.js @@ -0,0 +1,225 @@ +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; +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", +]; + +export const ALLOWED_TRAVEL_MODES = ["normal", "invisible"]; + +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", +]; + +export 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", + }, +}; + +export const ALLOWED_PRESETS = Object.keys(FX_PRESETS); + +export const FACTORY_DEFAULTS = { + originFx: "none", + travelFx: "none", + destinationFx: "none", + originTime: 0, + travelTime: 0, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0, + travelMode: "normal", +}; + +export const FLAG_HELP = /--help\b/i; +export const FLAG_SHOW_SETTINGS = /--show-settings\b/i; +export const FLAG_CHECK_SETTINGS = /--check-settings\b/i; +export const FLAG_RESET_SETTINGS = /--reset-settings\b/i; +export const FLAG_SAVE = /--save\b/i; +export const FLAG_INSTALL_MACRO = /--install-macro\b/i; + +export const FLAG_INSTANT = /--instant\b/i; +export const FLAG_PRESET = /--preset\b/i; +export const FLAG_ORIGIN_FX = /--origin-fx\b/i; +export const FLAG_TRAVEL_FX = /--travel-fx\b/i; +export const FLAG_DESTINATION_FX = /--destination-fx\b/i; +export const FLAG_ORIGIN_TIME = /--origin-time\b/i; +export const FLAG_TRAVEL_TIME = /--travel-time\b/i; +export const FLAG_TRAVEL_MODE = /--travel-mode\b/i; +export const FLAG_DESTINATION_TIME = /--destination-time\b/i; +export const FLAG_SWAP_DELAY = /--swap-delay\b/i; +export const FLAG_DESTINATION_DELAY = /--destination-delay\b/i; + +export const FLAG_LEGACY_BEAM_FX = /--beam-fx\b/i; +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 MANAGEMENT_FLAGS = [ + FLAG_SHOW_SETTINGS, + FLAG_CHECK_SETTINGS, + FLAG_RESET_SETTINGS, + FLAG_INSTALL_MACRO, +]; + +export const SILENT_MANAGEMENT_FLAGS = [ + FLAG_HELP, + FLAG_SHOW_SETTINGS, + FLAG_CHECK_SETTINGS, + FLAG_RESET_SETTINGS, + FLAG_INSTALL_MACRO, +]; diff --git a/SwapTokenPositions/src/effects.js b/SwapTokenPositions/src/effects.js new file mode 100644 index 000000000..4dc2c879e --- /dev/null +++ b/SwapTokenPositions/src/effects.js @@ -0,0 +1,42 @@ +/** + * 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} + */ +export 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} + */ +export 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}`); + } +} diff --git a/SwapTokenPositions/src/help.js b/SwapTokenPositions/src/help.js new file mode 100644 index 000000000..979ddfe70 --- /dev/null +++ b/SwapTokenPositions/src/help.js @@ -0,0 +1,64 @@ +import { + ALLOWED_PRESETS, + DELAY_MAX, + DELAY_MIN, + SWAP_TOKEN_POSITIONS_LAST_UPDATED, + SWAP_TOKEN_POSITIONS_VERSION, + TIME_MAX, + TIME_MIN, +} from "./constants.js"; +import { whisperSender } from "./messages.js"; + +/** + * Sends full command and option help text to the invoking player. + * + * @param {object} msgObj Roll20 chat message object. + * @returns {void} + */ +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:
", + `--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.
", + "
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(""); + + whisperSender(msgObj, helpMsg, "SwapTokenPositions Help", "left"); +} diff --git a/SwapTokenPositions/src/index.js b/SwapTokenPositions/src/index.js new file mode 100644 index 000000000..0f447b2f2 --- /dev/null +++ b/SwapTokenPositions/src/index.js @@ -0,0 +1,27 @@ +import { + SCRIPT_NAME, + SWAP_TOKEN_POSITIONS_LAST_UPDATED, + SWAP_TOKEN_POSITIONS_VERSION, +} from "./constants.js"; +import { handleSwapTokens } from "./commands.js"; +import { whisperGM } from "./messages.js"; +import { initializeState, validateSettings } from "./state.js"; + +/** + * 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/src/messages.js b/SwapTokenPositions/src/messages.js new file mode 100644 index 000000000..9e236555a --- /dev/null +++ b/SwapTokenPositions/src/messages.js @@ -0,0 +1,213 @@ +import { + COLOR_ACCENT_PURPLE_LIGHT, + COLOR_ACCENT_PURPLE_DARK, + COLOR_BG_SOFT_BLACK, + COLOR_ERROR_DARK, + COLOR_ERROR_LIGHT, + COLOR_ERROR_RED, + COLOR_ERROR_BG_LIGHT, + COLOR_HEADER_PURPLE_LIGHT, + COLOR_INFO_LIGHT, + COLOR_INFO_DARK, + COLOR_SUCCESS_DARK, + COLOR_SUCCESS_GREEN, + COLOR_SUCCESS_LIGHT, + COLOR_SUCCESS_BG_LIGHT, + COLOR_TEXT_ARCANE_SILVER, + COLOR_TEXT_DIM_SILVER, + SCRIPT_NAME, +} from "./constants.js"; + +/** + * Escapes HTML-sensitive characters for safe chat rendering. + * + * @param {string} value Text to escape. + * @returns {string} Escaped text. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export 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} + */ +export 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} + */ +export 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} + */ +export 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} + */ +export 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} + */ +export function whisperGMError(text, header = "Error", align = "left") { + sendChat( + SCRIPT_NAME, + `/w GM ${generateStyledErrorMessage(text, header, align)}`, + ); +} diff --git a/SwapTokenPositions/src/parsers.js b/SwapTokenPositions/src/parsers.js new file mode 100644 index 000000000..4c587101e --- /dev/null +++ b/SwapTokenPositions/src/parsers.js @@ -0,0 +1,135 @@ +import { whisperSenderError } from "./messages.js"; + +/** + * 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. + */ +export 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. + */ +export 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} + */ +export 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} + */ +export 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} + */ +export 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} + */ +export 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 }); + } +} diff --git a/SwapTokenPositions/src/state.js b/SwapTokenPositions/src/state.js new file mode 100644 index 000000000..9952f37e2 --- /dev/null +++ b/SwapTokenPositions/src/state.js @@ -0,0 +1,135 @@ +import { + ALLOWED_POINT_FX, + ALLOWED_TRAVEL_FX, + ALLOWED_TRAVEL_MODES, + DELAY_MAX, + DELAY_MIN, + FACTORY_DEFAULTS, + TIME_MAX, + TIME_MIN, +} from "./constants.js"; +import { whisperGM, whisperGMError, whisperGMSuccess } from "./messages.js"; + +/** + * Ensures persisted script settings exist and backfills missing keys with defaults. + * + * @returns {void} + */ +export 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. + */ +export function getSettings() { + return state.SwapTokenPositions; +} + +/** + * Renders the current persisted settings to GM chat. + * + * @returns {void} + */ +export function showSettings() { + const settings = getSettings(); + 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
`, + ].join(""); + whisperGM(settingsMsg, "Persistent Settings", "left"); +} + +/** + * Resets persisted script settings to factory defaults. + * + * @returns {void} + */ +export 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. + */ +export function validateSettings(silentOnSuccess = false) { + const settings = getSettings(); + const errors = []; + + 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; +} diff --git a/SwapTokenPositions/src/swap.js b/SwapTokenPositions/src/swap.js new file mode 100644 index 000000000..cbf4de7d9 --- /dev/null +++ b/SwapTokenPositions/src/swap.js @@ -0,0 +1,474 @@ +import { SILENT_MANAGEMENT_FLAGS } from "./constants.js"; +import { spawnPointFx, spawnTravelFx } from "./effects.js"; +import { getSafeTokenName, whisperSender, whisperSenderError } from "./messages.js"; + +/** + * 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. + */ +export 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} + */ +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; + } + + 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", + ); + 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"); + whisperSender( + msg, + `Swap Successful!
${token1Name} ↔ ${token2Name}`, + "Success", + ); + if (typeof onVerified === "function") { + onVerified(); + } + return; + } + + attempt += 1; + if (attempt >= maxVerificationAttempts) { + whisperSenderError(msg, "Token swap failed verification."); + if (typeof onFailed === "function") { + onFailed(); + } + 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 hideRenderBufferMs = 80; + 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} + */ +export 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); +}