From 5a887db31779f3f1006651753eecd11ef6a8b4e4 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Thu, 23 Apr 2026 13:27:29 +0100 Subject: [PATCH 01/15] feat(SwapTokenPositions): v2 modular rewrite + new FX pipelines - Refactor SwapTokenPositions into a modular source architecture and generated bundle workflow. - Add configurable multi-phase FX flow with origin, travel, and destination stages plus timing controls. - Introduce new command flags, presets, instant mode, and settings validation support. - Keep backward compatibility for legacy flags with deprecation warnings and migration guidance. - Update metadata and documentation for the v2 command model, changelog, and testing notes. Signed-off-by: Steve Roberts Co-authored-by: Copilot --- .../2.0.0/SwapTokenPositions.js | 1184 ++++++++++ SwapTokenPositions/CHANGELOG.md | 27 + SwapTokenPositions/README.md | 104 +- SwapTokenPositions/SwapTokenPositions.js | 1931 +++++++++-------- SwapTokenPositions/TESTING.md | 213 ++ SwapTokenPositions/package-lock.json | 432 ++++ SwapTokenPositions/package.json | 13 + SwapTokenPositions/rollup.config.mjs | 34 + SwapTokenPositions/script.json | 242 ++- SwapTokenPositions/src/commands.js | 205 ++ SwapTokenPositions/src/config.js | 194 ++ SwapTokenPositions/src/constants.js | 197 ++ SwapTokenPositions/src/effects.js | 42 + SwapTokenPositions/src/help.js | 61 + SwapTokenPositions/src/index.js | 27 + SwapTokenPositions/src/messages.js | 176 ++ SwapTokenPositions/src/parsers.js | 131 ++ SwapTokenPositions/src/state.js | 130 ++ SwapTokenPositions/src/swap.js | 102 + 19 files changed, 4514 insertions(+), 931 deletions(-) create mode 100644 SwapTokenPositions/2.0.0/SwapTokenPositions.js create mode 100644 SwapTokenPositions/TESTING.md create mode 100644 SwapTokenPositions/package-lock.json create mode 100644 SwapTokenPositions/package.json create mode 100644 SwapTokenPositions/rollup.config.mjs create mode 100644 SwapTokenPositions/src/commands.js create mode 100644 SwapTokenPositions/src/config.js create mode 100644 SwapTokenPositions/src/constants.js create mode 100644 SwapTokenPositions/src/effects.js create mode 100644 SwapTokenPositions/src/help.js create mode 100644 SwapTokenPositions/src/index.js create mode 100644 SwapTokenPositions/src/messages.js create mode 100644 SwapTokenPositions/src/parsers.js create mode 100644 SwapTokenPositions/src/state.js create mode 100644 SwapTokenPositions/src/swap.js diff --git a/SwapTokenPositions/2.0.0/SwapTokenPositions.js b/SwapTokenPositions/2.0.0/SwapTokenPositions.js new file mode 100644 index 000000000..741d284ff --- /dev/null +++ b/SwapTokenPositions/2.0.0/SwapTokenPositions.js @@ -0,0 +1,1184 @@ +/** + * GENERATED FILE - DO NOT EDIT DIRECTLY. + * Source files live under src/ and are bundled with `npm run build`. + * Built: 2026-04-23T12:12:21.095Z + */ +(function () { + 'use strict'; + + const SCRIPT_NAME = "SwapTokenPositions"; + const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; + const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-23"; + + const COLOR_GLOW_PURPLE = "#B388FF"; + 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"; + + 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"; + + 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_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, + }, + lightning: { + originFx: "none", + travelFx: "beam-lightning", + destinationFx: "burst-energy", + originTime: 0, + travelTime: 0.3, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0.3, + }, + shadow: { + originFx: "splatter-dark", + travelFx: "none", + destinationFx: "splatter-dark", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + fire: { + originFx: "explode-fire", + travelFx: "none", + destinationFx: "explode-fire", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + magic: { + originFx: "nova-magic", + travelFx: "none", + destinationFx: "burst-magic", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + none: { + originFx: "none", + travelFx: "none", + destinationFx: "none", + originTime: 0, + travelTime: 0, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0, + }, + }; + + 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, + }; + + 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_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 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, + ]; + + /** + * 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 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_BLUE} 0%,${COLOR_ACCENT_PINK} 100%)`, + "overflow:hidden", + ].join(";"); + + const headerHtml = header + ? `
${header}
` + : ""; + 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 lower = match[1].toLowerCase(); + if (allowedValues.includes(lower)) { + return { found: true, valid: true, value: lower }; + } + 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}
`, + `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_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 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_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.
", + "--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}> — Wait (s) after Travel FX before continuing.
`, + `--destination-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, + "
Delays:
", + `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, + `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Travel stage and swap.
`, + "
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).
", + "• 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 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; + } + + return [token1, token2]; + } + + /** + * Swaps token coordinates, verifies the result, and spawns destination FX. + * + * @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 {string} destinationFx FX to spawn at destination points. + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ + function performSwap(token1, token2, pos1, pos2, destinationFx, msg) { + token1.set({ left: pos2.left, top: pos2.top }); + token2.set({ left: pos1.left, top: pos1.top }); + + const isVerified = token1.get("left") === pos2.left && token2.get("left") === pos1.left; + + if (isVerified) { + spawnPointFx(pos2.left, pos2.top, destinationFx, pos2.page); + spawnPointFx(pos1.left, pos1.top, destinationFx, pos1.page); + whisperSender( + msg, + `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, + "Success", + ); + } else { + whisperSenderError(msg, "Token swap failed verification."); + } + } + + /** + * 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, + destinationFx, + originTime, + travelTime, + swapDelay, + destinationDelay, + } = config; + + const msBeforeTravel = (originTime + swapDelay) * 1000; + const msBeforeSwap = (travelTime + destinationDelay) * 1000; + + spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); + spawnPointFx(pos2.left, pos2.top, originFx, pos2.page); + + setTimeout(() => { + spawnTravelFx(pos1, pos2, travelFx); + + setTimeout(() => { + performSwap(token1, token2, pos1, pos2, destinationFx, msg); + }, msBeforeSwap); + }, 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, "none", msg); + return; + } + + const updateTracker = { valid: 0, invalid: 0 }; + const config = buildSwapConfig(msg, updateTracker); + + if (processPersistence(msg, isGM, updateTracker, config)) { + return; + } + + if (updateTracker.valid > 0 && (!FLAG_SAVE.test(msg.content) || !isGM)) { + const overrideDetails = [ + `Origin FX: ${config.originFx}`, + `Travel FX: ${config.travelFx}`, + `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, "none", 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..f6ed1b0f1 100644 --- a/SwapTokenPositions/CHANGELOG.md +++ b/SwapTokenPositions/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to the **SwapTokenPositions** script will be documented in this file. +## [2.0.0] - 2026-04-23 + +### 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`. +- Preset system with `portal`, `lightning`, `shadow`, `fire`, `magic`, and `none`. +- `--instant` flag to force immediate swap. +- `--check-settings` validation command for persistent defaults. +- 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. + +### Changed + +- Refactored internal architecture from a monolithic file to source modules with a generated bundle. +- Updated root `SwapTokenPositions.js` and versioned `2.0.0/SwapTokenPositions.js` to generated artifacts. +- Updated script metadata and developer documentation to reflect version 2 command model. + +### Deprecated + +- `--duration` (replaced by `--swap-delay`) +- `--beam-fx` (replaced by `--travel-fx`) +- `--burst-fx` (replaced by `--destination-fx`) + ## [1.0.0] - 2026-04-21 ### Added diff --git a/SwapTokenPositions/README.md b/SwapTokenPositions/README.md index 473a3a297..50d779dab 100644 --- a/SwapTokenPositions/README.md +++ b/SwapTokenPositions/README.md @@ -13,8 +13,66 @@ - **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`, and `none` presets. +- **Legacy Compatibility**: Supports deprecated `--duration`, `--beam-fx`, and `--burst-fx` flags with warnings. -## Commands +## Development + +This mod now uses a multi-file source layout for maintenance, but Roll20 still requires a single bundled script for manual testing and publication. + +### Source of Truth + +As the script was nearly 1.2k lines in a single file, the source code has been refactored into multiple modules under the `src/` directory. This allows for better organization and maintainability. + +- Edit files in `src/`. +- Do not hand-edit generated bundles; they are build artifacts. + +### Build Setup + +From the `SwapTokenPositions` folder: + +```bash +npm install +npm run build +``` + +The build writes the same bundled script to both of these files: + +- `SwapTokenPositions.js` +- `/SwapTokenPositions.js` where `` comes from `script.json`. + +### Watch Mode + +For active development: + +```bash +npm run watch +``` + +This rebuilds the bundle whenever a source file changes. + +Roll20 does not load files from `src/` directly. Only the generated single-file bundle should be pasted into the Roll20 mod area. + +### Contributor Workflow + +When making changes to this mod: + +1. Edit the source files under `src/`. +2. Update script metadata in `script.json` if necessary (e.g., version, description). +3. Update documentation in `README.md` and `TESTING.md` as needed to reflect new features or changes. +4. Run `npm run build`. +5. Verify the generated `SwapTokenPositions.js` bundle works in Roll20. +6. Commit the source changes and the regenerated build artifacts together. + +### Manual Roll20 Testing + +1. Run `npm run build`. +2. Open `SwapTokenPositions.js`. +3. Copy the entire generated file. +4. Paste it into the Roll20 Mod Scripts editor for your game. +5. Complete the detailed test plan: [TESTING.md](TESTING.md) + +## Roll20 VTT Commands ### Basic Usage @@ -23,31 +81,43 @@ 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`, `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. +- `--origin-time <0-10>`: Seconds to wait after origin FX. +- `--travel-time <0-10>`: Seconds to wait after travel FX. +- `--destination-time <0-10>`: Stored destination timing value. +- `--swap-delay <0-10>`: Extra delay between origin and travel stages. +- `--destination-delay <0-10>`: Extra delay between travel stage and swap. ### 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 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: + +- `--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 7084d6009..741d284ff 100644 --- a/SwapTokenPositions/SwapTokenPositions.js +++ b/SwapTokenPositions/SwapTokenPositions.js @@ -1,859 +1,930 @@ /** - * 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-21 - * @license MIT + * GENERATED FILE - DO NOT EDIT DIRECTLY. + * Source files live under src/ and are bundled with `npm run build`. + * Built: 2026-04-23T12:12:21.095Z */ -"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", -]; -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", -]; - -// === Command Flags (Regex Constants) === -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_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; - -// Grouped Flags for bulk testing -const MANAGEMENT_FLAGS = [ - FLAG_HELP, - FLAG_SHOW_SETTINGS, - FLAG_CHECK_SETTINGS, - FLAG_RESET_SETTINGS, - FLAG_SAVE, - FLAG_INSTALL_MACRO, -]; - -const SILENT_MANAGEMENT_FLAGS = [ - FLAG_HELP, - FLAG_SHOW_SETTINGS, - FLAG_CHECK_SETTINGS, - FLAG_RESET_SETTINGS, - 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, +(function () { + 'use strict'; + + const SCRIPT_NAME = "SwapTokenPositions"; + const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; + const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-23"; + + const COLOR_GLOW_PURPLE = "#B388FF"; + 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"; + + 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"; + + 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_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, + }, + lightning: { + originFx: "none", + travelFx: "beam-lightning", + destinationFx: "burst-energy", + originTime: 0, + travelTime: 0.3, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0.3, + }, + shadow: { + originFx: "splatter-dark", + travelFx: "none", + destinationFx: "splatter-dark", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + fire: { + originFx: "explode-fire", + travelFx: "none", + destinationFx: "explode-fire", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + magic: { + originFx: "nova-magic", + travelFx: "none", + destinationFx: "burst-magic", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + none: { + originFx: "none", + travelFx: "none", + destinationFx: "none", + originTime: 0, + travelTime: 0, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0, + }, }; - 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, + 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, }; - 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; + 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_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 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, + ]; + + /** + * 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 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_BLUE} 0%,${COLOR_ACCENT_PINK} 100%)`, + "overflow:hidden", + ].join(";"); + + const headerHtml = header + ? `
${header}
` + : ""; + const contentHtml = `
${msg}
`; + + return `
${headerHtml}${contentHtml}
`; } - createObj("macro", { - name: macroName, - action: "!swap-tokens", - playerid: msgObj.playerid, - isvisibleto: "all", - }); + /** + * 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}
`; + } - whisperGMSuccess( - `Global macro '${macroName}' has been created and is visible to all players.`, - "Macro Installed", - ); -} + /** + * 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}
`; + } -/** - * Validates the current persistent settings against the allowed lists. - * Reports any issues to the GM and suggests a reset if necessary. - * - * @param {boolean} [silentOnSuccess=false] - If true, only reports errors. - * @returns {boolean} - True if all settings are valid, false otherwise. - */ -function validateSettings(silentOnSuccess = false) { - const settings = getSettings(); - const errors = []; + /** + * 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)}`); + } - if (settings.duration < DURATION_MIN || settings.duration > DURATION_MAX) { - errors.push( - `Duration (${settings.duration}) is out of range (${DURATION_MIN}-${DURATION_MAX}).`, + /** + * 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)}`, ); } - 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.`); + + /** + * 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)}`, + ); } - 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; + /** + * 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)}`); } - if (!silentOnSuccess) { - whisperGMSuccess( - "All persistent settings are valid.", - "Settings Validation", + /** + * 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)}`, ); } - return true; -} - -/** - * Displays help instructions to the sender as a styled whisper. - * Lists usage, available command options, and a description of the script. - * - * @param {object} msgObj - The Roll20 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 — 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"); -} - -/** - * Generates a styled message box using branding variables. - * - * @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. - */ -function generateStyledMessage(msg, align = "center", header = "") { - const padding = align === "center" ? "3px 0px" : "3px 8px"; - 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_BLUE} 0%,${COLOR_ACCENT_PINK} 100%)`, - "overflow:hidden", - ].join(";"); - - const headerHtml = header - ? `
${header}
` - : ""; - const contentHtml = `
${msg}
`; - - return `
${headerHtml}${contentHtml}
`; -} - -/** - * Generates a styled error message box with red/danger branding. - * - * @param {string} msg - The error message to display inside the styled box. - * @param {string} [header="Error"] - Header text for the error box. - * @returns {string} - The HTML string for the styled error message box. - */ -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}
`; -} -/** - * Generates a styled success message box with green branding. - * - * @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. - */ -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}
`; -} - -/** - * Sends a formatted whisper message to the GM using brand colors and styles. - * - * @param {string} msg - The message to send. - * @param {string} [header=""] - Optional header text. - * @param {string} [align="center"] - Text alignment. - * @returns {void} - */ -function whisperGM(msg, header = "", align = "center") { - sendChat( - "SwapTokenPositions", - `/w GM ${generateStyledMessage(msg, align, header)}`, - ); -} - -/** - * Sends a formatted whisper message to the message sender. - * - * @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. - * @returns {void} - */ -function whisperSender(msgObj, text, header = "", align = "center") { - const p = getObj("player", msgObj.playerid); - const name = p ? p.get("_displayname") : msgObj.who; - sendChat( - "SwapTokenPositions", - `/w "${name}" ${generateStyledMessage(text, align, header)}`, - ); -} + /** + * 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 lower = match[1].toLowerCase(); + if (allowedValues.includes(lower)) { + return { found: true, valid: true, value: lower }; + } + return { found: true, valid: false, value: match[1] }; + } -/** - * Sends a formatted error whisper message to the message sender. - * - * @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. - * @returns {void} - */ -function whisperSenderError(msgObj, text, header = "Error", align = "left") { - const p = getObj("player", msgObj.playerid); - const name = p ? p.get("_displayname") : msgObj.who; - sendChat( - "SwapTokenPositions", - `/w "${name}" ${generateStyledErrorMessage(text, header, align)}`, - ); -} + /** + * 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 }; + } -/** - * Sends a formatted chat announcement to all players using brand colors and styles. - * - * @param {string} msg - The message to announce. - * @param {string} [header=""] - Optional header text. - * @returns {void} - */ -function announce(msg, header = "") { - sendChat("SwapTokenPositions", generateStyledMessage(msg, "center", header)); -} + /** + * 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"); + } + } -/** - * Sends a formatted success whisper message to the GM using the green success style. - * - * @param {string} text - The success message to send. - * @param {string} [header="Success"] - Optional header text. - * @returns {void} - */ -function whisperGMSuccess(text, header = "Success") { - sendChat( - "SwapTokenPositions", - `/w GM ${generateStyledSuccessMessage(text, header)}`, - ); -} + /** + * 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", + ); + } + } -/** - * Sends a formatted error whisper message to the GM using the red danger style. - * - * @param {string} text - The error message to send. - * @param {string} [header="Error"] - Optional header text. - * @param {string} [align="left"] - Text alignment. - * @returns {void} - */ -function whisperGMError(text, header = "Error", align = "left") { - sendChat( - "SwapTokenPositions", - `/w GM ${generateStyledErrorMessage(text, header, align)}`, - ); -} + /** + * 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); + } + } -/** - * Spawns a beam FX between two points, with validation. - * Falls back to default FX type if the provided type is invalid. - * - * @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} - */ -function spawnBeamFx(fromX, fromY, toX, toY, pageId, fxType = SWAP_FX_TYPE) { - if (fxType === "none") { - return; + /** + * 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 }); + } } - if (!ALLOWED_BEAM_FX.includes(fxType)) { - whisperGMError( - `Invalid beam FX type: ${fxType}.

Using default: ${SWAP_FX_TYPE}`, - "FX Compatibility", - ); - fxType = SWAP_FX_TYPE; + /** + * 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; + } + } } - spawnFxBetweenPoints( - { x: fromX, y: fromY, pageid: pageId }, - { x: toX, y: toY, pageid: pageId }, - fxType, - ); -} + /** + * Retrieves persisted script settings from Roll20 state. + * + * @returns {object} Effective script settings object. + */ + function getSettings() { + return state.SwapTokenPositions; + } -/** - * Spawns a burst/final FX at a position, with validation. - * Falls back to default burst FX type if the provided type is invalid. - * - * @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. - * @returns {void} - */ -function spawnFinalFx(x, y, fxType, pageId) { - if (fxType === "none") { - return; + /** + * 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}
`, + `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"); } - if (!ALLOWED_BURST_FX.includes(fxType)) { - whisperGMError( - `Invalid burst FX type: ${fxType}.

Using default: ${SWAP_FINAL_FX_TYPE}`, - "FX Compatibility", + /** + * Resets persisted script settings to factory defaults. + * + * @returns {void} + */ + function resetSettings() { + state.SwapTokenPositions = { ...FACTORY_DEFAULTS }; + whisperGM( + "Settings reset to factory defaults.", + "Settings Reset", ); - fxType = SWAP_FINAL_FX_TYPE; + showSettings(); } - spawnFx(x, y, fxType, pageId); -} - -/** - * Parses the --duration flag from the command content. - * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {number} - The beam duration in seconds. - */ -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; - } - 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. - * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {string} - The beam FX type. - */ -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]; - } - 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. - * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {string} - The swap mode ("beams" or "transport"). - */ -function parseSwapMode(msgObj, updateTracker) { - const match = new RegExp(String.raw`${FLAG_MODE.source}\s+(\S+)`, "i").exec( - msgObj.content, - ); - if (!match) { - return getSettings().swapMode; - } - if (ALLOWED_SWAP_MODES.includes(match[1].toLowerCase())) { - updateTracker.valid++; - return match[1].toLowerCase(); - } - 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. - * - * @param {object} msgObj - The Roll20 message object. - * @param {object} updateTracker - Object to track valid/invalid updates. - * @returns {string} - The burst FX type. - */ -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; -} + /** + * 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 = []; -/** - * Processes management commands like --help, --show-settings, etc. - * - * @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. - */ -function handleManagementCommands(msg, isGM) { - if (FLAG_HELP.test(msg.content)) { - showHelp(msg); - return true; - } + 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_POINT_FX.includes(settings.destinationFx)) { + errors.push(`Destination FX '${settings.destinationFx}' is no longer valid.`); + } - const hasManagementFlag = MANAGEMENT_FLAGS.some((flag) => - flag.test(msg.content), - ); + 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 (!isGM && hasManagementFlag) { - whisperSenderError( - msg, - "You do not have permission to use script management flags.", - "Access Denied", - ); - return true; - } + 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 (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); + if (!silentOnSuccess) { + whisperGMSuccess("All persistent settings are valid.", "Settings Validation"); + } return true; } - return false; -} - -/** - * 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) { - return false; - } + /** + * 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 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", + ); + } + } - 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; - 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", - ); + 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", + ); + } + } } - return true; -} - -/** - * Validates selection and retrieves the two tokens for swapping. - * - * @param {object} msg - The Roll20 message object. - * @returns {object[]|null} - Array of two token objects, or null if invalid. - */ -function getSelectedTokens(msg) { - const selectedCount = (msg.selected || []).length; - - 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), - ); - if (!isSilent) { + /** + * 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, - `Please select exactly two tokens to perform a swap. (Currently selected: ${selectedCount})`, - "Selection Error", + `Invalid preset: '${presetResult.value}'.

Valid presets: ${ALLOWED_PRESETS.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(msg, "One or both selected tokens could not be found."); - return null; + /** + * 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_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; } - return [token1, token2]; -} + /** + * 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.
", + "--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}> — Wait (s) after Travel FX before continuing.
`, + `--destination-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, + "
Delays:
", + `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, + `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Travel stage and swap.
`, + "
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).
", + "• 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 portal --travel-time 3
", + "!swap-tokens --origin-fx nova-magic --swap-delay 1 --destination-fx burst-holy
", + "!swap-tokens --preset lightning --save
", + ].join(""); -/** - * Handles the !swap-tokens API command. - * Parses command options, validates token selection, and executes the swap logic. - * - * @param {object} msg - The Roll20 chat message object. - * @returns {void} - */ -const handleSwapTokens = (msg) => { - if (msg.type !== "api" || !/^!swap-tokens\b/i.test(msg.content)) { - return; + whisperSender(msgObj, helpMsg, "SwapTokenPositions Help", "left"); } - const isGM = playerIsGM(msg.playerid); - - // 1. Always validate tokens first (as requested for testing/visibility) - const tokens = getSelectedTokens(msg); - - // 2. Handle Management Commands (Help, Reset, etc.) - if (handleManagementCommands(msg, isGM)) { - return; + /** + * 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) { - return; + /** + * 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), - }; - - // 3. Handle Persistence (--save) - if (processPersistence(msg, isGM, updateTracker, overrides)) { - return; - } + /** + * 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; + } - // 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"); - } + const token1 = getObj("graphic", msg.selected[0]._id); + const token2 = getObj("graphic", msg.selected[1]._id); - const [token1, token2] = tokens; - const position1 = { - left: token1.get("left"), - top: token1.get("top"), - page: token1.get("pageid"), - }; - const position2 = { - left: token2.get("left"), - top: token2.get("top"), - page: token2.get("pageid"), - }; + if (!token1 || !token2) { + whisperSenderError(msg, "One or both selected tokens could not be found."); + return null; + } - const bounceInterval = 250; - const maxBounces = Math.max( - 1, - Math.floor((overrides.duration * 1000) / bounceInterval), - ); - let bounceCount = 0; + return [token1, token2]; + } /** - * Finalizes the token swap by updating coordinates on the Roll20 objects. - * Verifies the swap was successful and triggers the final arrival FX. + * Swaps token coordinates, verifies the result, and spawns destination FX. * + * @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 {string} destinationFx FX to spawn at destination points. + * @param {object} msg Roll20 chat message object. * @returns {void} */ - function swapPositions() { - token1.set({ left: position2.left, top: position2.top }); - token2.set({ left: position1.left, top: position1.top }); + function performSwap(token1, token2, pos1, pos2, destinationFx, msg) { + token1.set({ left: pos2.left, top: pos2.top }); + token2.set({ left: pos1.left, top: pos1.top }); - const isVerified = - token1.get("left") === position2.left && - token2.get("left") === position1.left; + const isVerified = token1.get("left") === pos2.left && token2.get("left") === pos1.left; if (isVerified) { - spawnFinalFx( - position2.left, - position2.top, - overrides.burstFx, - position2.page, - ); - spawnFinalFx( - position1.left, - position1.top, - overrides.burstFx, - position1.page, - ); + spawnPointFx(pos2.left, pos2.top, destinationFx, pos2.page); + spawnPointFx(pos1.left, pos1.top, destinationFx, pos1.page); whisperSender( msg, `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, @@ -865,85 +936,249 @@ const handleSwapTokens = (msg) => { } /** - * Executes the 'beams' animation style. - * Recursively spawns beams back and forth between tokens until the duration expires. + * 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, + destinationFx, + originTime, + travelTime, + swapDelay, + destinationDelay, + } = config; + + const msBeforeTravel = (originTime + swapDelay) * 1000; + const msBeforeSwap = (travelTime + destinationDelay) * 1000; + + spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); + spawnPointFx(pos2.left, pos2.top, originFx, pos2.page); + + setTimeout(() => { + spawnTravelFx(pos1, pos2, travelFx); + + setTimeout(() => { + performSwap(token1, token2, pos1, pos2, destinationFx, msg); + }, msBeforeSwap); + }, 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 doBeams() { - if (bounceCount >= maxBounces) { - swapPositions(); + 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; } - 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, + 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", ); - bounceCount++; - setTimeout(doBeams, bounceInterval); } /** - * Executes the 'transport' animation style. - * Spawns vertical light columns and simultaneous shimmer bursts at both locations. + * Handles management flags such as help, settings, reset, and macro install. * - * @returns {void} + * @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 doTransport() { - if (bounceCount >= maxBounces) { - swapPositions(); - return; + function handleManagementCommands(msg, isGM) { + if (FLAG_HELP.test(msg.content)) { + showHelp(msg); + return true; } - [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); - } - }); - bounceCount++; - setTimeout(doTransport, bounceInterval); + + 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; } - // Bypass animation if all FX are disabled - if (overrides.beamFx === "none" && overrides.burstFx === "none") { - swapPositions(); - return; + /** + * 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; } - if (overrides.mode === "beams") { - doBeams(); - } else { - doTransport(); + /** + * 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, "none", msg); + return; + } + + const updateTracker = { valid: 0, invalid: 0 }; + const config = buildSwapConfig(msg, updateTracker); + + if (processPersistence(msg, isGM, updateTracker, config)) { + return; + } + + if (updateTracker.valid > 0 && (!FLAG_SAVE.test(msg.content) || !isGM)) { + const overrideDetails = [ + `Origin FX: ${config.originFx}`, + `Travel FX: ${config.travelFx}`, + `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, "none", msg); + return; + } + + executeSwapPipeline(config, token1, token2, pos1, pos2, msg); } -}; -/** - * Registers the API command handler and initializes persistent state when the script is ready. - * - * @returns {void} - */ -on("ready", () => { - initializeState(); - validateSettings(true); // Silent check on load - log( - `-=> SwapTokenPositions 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); -}); + /** + * 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/TESTING.md b/SwapTokenPositions/TESTING.md new file mode 100644 index 000000000..c0801b41f --- /dev/null +++ b/SwapTokenPositions/TESTING.md @@ -0,0 +1,213 @@ +# 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. + +## 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 beam flag warning** + - Action: `!swap-tokens --beam-fx beam-fire` + - Expected: + - Sender sees deprecation warning: use `--travel-fx`. + - Command still functions. + +2. **Deprecated burst flag warning** + - Action: `!swap-tokens --burst-fx burst-holy` + - Expected: + - Sender sees deprecation warning: use `--destination-fx`. + - Command still functions. + +3. **Deprecated duration flag warning** + - Action: `!swap-tokens --duration 2` + - Expected: + - Sender sees deprecation warning: use `--swap-delay`. + - Command still functions. + +4. **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. 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..7ef8001ff --- /dev/null +++ b/SwapTokenPositions/package-lock.json @@ -0,0 +1,432 @@ +{ + "name": "swap-token-positions", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swap-token-positions", + "version": "2.0.0", + "devDependencies": { + "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/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..c00fd78b9 --- /dev/null +++ b/SwapTokenPositions/package.json @@ -0,0 +1,13 @@ +{ + "name": "swap-token-positions", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rollup -c", + "watch": "rollup -c -w" + }, + "devDependencies": { + "rollup": "^4.52.5" + } +} diff --git a/SwapTokenPositions/rollup.config.mjs b/SwapTokenPositions/rollup.config.mjs new file mode 100644 index 000000000..4e47ce341 --- /dev/null +++ b/SwapTokenPositions/rollup.config.mjs @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +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 banner = [ + "/**", + " * GENERATED FILE - DO NOT EDIT DIRECTLY.", + " * Source files live under src/ and are bundled with `npm run build`.", + ` * Built: ${buildTimestamp}`, + " */", +].join("\n"); + +export default { + input: path.join(__dirname, "src", "index.js"), + output: [ + { + file: path.join(__dirname, `${scriptJson.name}.js`), + format: "iife", + banner, + }, + { + file: path.join(__dirname, scriptJson.version, `${scriptJson.name}.js`), + format: "iife", + banner, + }, + ], +}; diff --git a/SwapTokenPositions/script.json b/SwapTokenPositions/script.json index 8dd9dee6c..4c0c4e694 100644 --- a/SwapTokenPositions/script.json +++ b/SwapTokenPositions/script.json @@ -1,66 +1,176 @@ -{ - "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": "Seconds to wait after travel FX before continuing (0-10)." + }, + { + "name": "destination-time", + "type": "number", + "default": "0", + "description": "Stored destination timing value 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 between travel and swap 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..343ecc2da --- /dev/null +++ b/SwapTokenPositions/src/commands.js @@ -0,0 +1,205 @@ +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, "none", msg); + return; + } + + const updateTracker = { valid: 0, invalid: 0 }; + const config = buildSwapConfig(msg, updateTracker); + + if (processPersistence(msg, isGM, updateTracker, config)) { + return; + } + + if (updateTracker.valid > 0 && (!FLAG_SAVE.test(msg.content) || !isGM)) { + const overrideDetails = [ + `Origin FX: ${config.originFx}`, + `Travel FX: ${config.travelFx}`, + `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, "none", 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..7cc1e4260 --- /dev/null +++ b/SwapTokenPositions/src/config.js @@ -0,0 +1,194 @@ +import { + ALLOWED_POINT_FX, + ALLOWED_PRESETS, + ALLOWED_TRAVEL_FX, + 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_ORIGIN_FX, + FLAG_ORIGIN_TIME, + FLAG_PRESET, + FLAG_SWAP_DELAY, + FLAG_TRAVEL_FX, + 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 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_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..e4ea849af --- /dev/null +++ b/SwapTokenPositions/src/constants.js @@ -0,0 +1,197 @@ +export const SCRIPT_NAME = "SwapTokenPositions"; +export const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; +export const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-23"; + +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_PINK = "#FF4D6D"; +export const COLOR_ACCENT_BLUE = "#3D5AFE"; + +export const COLOR_ERROR_RED = "#D32F2F"; +export const COLOR_ERROR_DARK = "#B71C1C"; +export const COLOR_ERROR_LIGHT = "#FFCDD2"; +export const COLOR_SUCCESS_GREEN = "#2E7D32"; +export const COLOR_SUCCESS_DARK = "#1B5E20"; +export const COLOR_SUCCESS_LIGHT = "#E8F5E9"; + +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_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, + }, + lightning: { + originFx: "none", + travelFx: "beam-lightning", + destinationFx: "burst-energy", + originTime: 0, + travelTime: 0.3, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0.3, + }, + shadow: { + originFx: "splatter-dark", + travelFx: "none", + destinationFx: "splatter-dark", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + fire: { + originFx: "explode-fire", + travelFx: "none", + destinationFx: "explode-fire", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + magic: { + originFx: "nova-magic", + travelFx: "none", + destinationFx: "burst-magic", + originTime: 0.5, + travelTime: 0, + destinationTime: 0, + swapDelay: 0.5, + destinationDelay: 0.5, + }, + none: { + originFx: "none", + travelFx: "none", + destinationFx: "none", + originTime: 0, + travelTime: 0, + destinationTime: 0, + swapDelay: 0, + destinationDelay: 0, + }, +}; + +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, +}; + +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_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 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..f4f693fb0 --- /dev/null +++ b/SwapTokenPositions/src/help.js @@ -0,0 +1,61 @@ +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.
", + "--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}> — Wait (s) after Travel FX before continuing.
`, + `--destination-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, + "
Delays:
", + `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, + `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Travel stage and swap.
`, + "
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).
", + "• 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 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..89636339e --- /dev/null +++ b/SwapTokenPositions/src/messages.js @@ -0,0 +1,176 @@ +import { + COLOR_ACCENT_BLUE, + COLOR_ACCENT_PINK, + COLOR_BG_SOFT_BLACK, + COLOR_ERROR_DARK, + COLOR_ERROR_LIGHT, + COLOR_ERROR_RED, + COLOR_GLOW_PURPLE, + COLOR_SUCCESS_DARK, + COLOR_SUCCESS_GREEN, + COLOR_SUCCESS_LIGHT, + COLOR_TEXT_ARCANE_SILVER, + COLOR_TEXT_DIM_SILVER, + SCRIPT_NAME, +} from "./constants.js"; + +/** + * 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 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_BLUE} 0%,${COLOR_ACCENT_PINK} 100%)`, + "overflow:hidden", + ].join(";"); + + const headerHtml = header + ? `
${header}
` + : ""; + 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..2792e8203 --- /dev/null +++ b/SwapTokenPositions/src/parsers.js @@ -0,0 +1,131 @@ +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 lower = match[1].toLowerCase(); + if (allowedValues.includes(lower)) { + return { found: true, valid: true, value: lower }; + } + 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..1ef06c9ae --- /dev/null +++ b/SwapTokenPositions/src/state.js @@ -0,0 +1,130 @@ +import { + ALLOWED_POINT_FX, + ALLOWED_TRAVEL_FX, + 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}
`, + `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_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..6fc724177 --- /dev/null +++ b/SwapTokenPositions/src/swap.js @@ -0,0 +1,102 @@ +import { SILENT_MANAGEMENT_FLAGS } from "./constants.js"; +import { spawnPointFx, spawnTravelFx } from "./effects.js"; +import { 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; + } + + return [token1, token2]; +} + +/** + * Swaps token coordinates, verifies the result, and spawns destination FX. + * + * @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 {string} destinationFx FX to spawn at destination points. + * @param {object} msg Roll20 chat message object. + * @returns {void} + */ +export function performSwap(token1, token2, pos1, pos2, destinationFx, msg) { + token1.set({ left: pos2.left, top: pos2.top }); + token2.set({ left: pos1.left, top: pos1.top }); + + const isVerified = token1.get("left") === pos2.left && token2.get("left") === pos1.left; + + if (isVerified) { + spawnPointFx(pos2.left, pos2.top, destinationFx, pos2.page); + spawnPointFx(pos1.left, pos1.top, destinationFx, pos1.page); + whisperSender( + msg, + `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, + "Success", + ); + } else { + whisperSenderError(msg, "Token swap failed verification."); + } +} + +/** + * 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, + destinationFx, + originTime, + travelTime, + swapDelay, + destinationDelay, + } = config; + + const msBeforeTravel = (originTime + swapDelay) * 1000; + const msBeforeSwap = (travelTime + destinationDelay) * 1000; + + spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); + spawnPointFx(pos2.left, pos2.top, originFx, pos2.page); + + setTimeout(() => { + spawnTravelFx(pos1, pos2, travelFx); + + setTimeout(() => { + performSwap(token1, token2, pos1, pos2, destinationFx, msg); + }, msBeforeSwap); + }, msBeforeTravel); +} From 2fc698a06c6d605932abf36b38a4dfa7c32d41ee Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 03:51:06 +0100 Subject: [PATCH 02/15] feat(SwapTokenPositions): add travel-mode and transport preset - Add new travel visibility control via --travel-mode with normal and invisible options. - Add transport preset and update preset defaults for better Roll20 FX compatibility (including lightning/shadow adjustments). - Rework swap execution flow: - split phase timing into clearer origin, travel/swap, and destination windows - support invisible travel by temporarily moving tokens to GM layer, then revealing after swap - add swap verification retries before final success/failure handling - schedule destination FX after reveal/timing delays - Harden string flag parsing by normalizing quotes and trailing punctuation in flag values. - Extend config/state/help plumbing so travel mode is parsed, validated, persisted, and shown in override/settings output. - Update docs and metadata to reflect the new command model and test coverage (README, CHANGELOG, TESTING, script.json). - Update build metadata injection in Rollup/constants so build name/script/version/date are injected from script metadata. Signed-off-by: Steve Roberts Co-authored-by: Copilot --- .../2.0.0/SwapTokenPositions.js | 1184 ----------------- SwapTokenPositions/CHANGELOG.md | 8 +- SwapTokenPositions/README.md | 19 +- SwapTokenPositions/TESTING.md | 28 + SwapTokenPositions/rollup.config.mjs | 29 +- SwapTokenPositions/script.json | 10 + SwapTokenPositions/src/commands.js | 9 +- SwapTokenPositions/src/config.js | 8 + SwapTokenPositions/src/constants.js | 36 +- SwapTokenPositions/src/help.js | 3 + SwapTokenPositions/src/parsers.js | 10 +- SwapTokenPositions/src/state.js | 5 + SwapTokenPositions/src/swap.js | 205 ++- 13 files changed, 326 insertions(+), 1228 deletions(-) delete mode 100644 SwapTokenPositions/2.0.0/SwapTokenPositions.js diff --git a/SwapTokenPositions/2.0.0/SwapTokenPositions.js b/SwapTokenPositions/2.0.0/SwapTokenPositions.js deleted file mode 100644 index 741d284ff..000000000 --- a/SwapTokenPositions/2.0.0/SwapTokenPositions.js +++ /dev/null @@ -1,1184 +0,0 @@ -/** - * GENERATED FILE - DO NOT EDIT DIRECTLY. - * Source files live under src/ and are bundled with `npm run build`. - * Built: 2026-04-23T12:12:21.095Z - */ -(function () { - 'use strict'; - - const SCRIPT_NAME = "SwapTokenPositions"; - const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; - const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-23"; - - const COLOR_GLOW_PURPLE = "#B388FF"; - 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"; - - 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"; - - 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_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, - }, - lightning: { - originFx: "none", - travelFx: "beam-lightning", - destinationFx: "burst-energy", - originTime: 0, - travelTime: 0.3, - destinationTime: 0, - swapDelay: 0, - destinationDelay: 0.3, - }, - shadow: { - originFx: "splatter-dark", - travelFx: "none", - destinationFx: "splatter-dark", - originTime: 0.5, - travelTime: 0, - destinationTime: 0, - swapDelay: 0.5, - destinationDelay: 0.5, - }, - fire: { - originFx: "explode-fire", - travelFx: "none", - destinationFx: "explode-fire", - originTime: 0.5, - travelTime: 0, - destinationTime: 0, - swapDelay: 0.5, - destinationDelay: 0.5, - }, - magic: { - originFx: "nova-magic", - travelFx: "none", - destinationFx: "burst-magic", - originTime: 0.5, - travelTime: 0, - destinationTime: 0, - swapDelay: 0.5, - destinationDelay: 0.5, - }, - none: { - originFx: "none", - travelFx: "none", - destinationFx: "none", - originTime: 0, - travelTime: 0, - destinationTime: 0, - swapDelay: 0, - destinationDelay: 0, - }, - }; - - 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, - }; - - 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_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 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, - ]; - - /** - * 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 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_BLUE} 0%,${COLOR_ACCENT_PINK} 100%)`, - "overflow:hidden", - ].join(";"); - - const headerHtml = header - ? `
${header}
` - : ""; - 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 lower = match[1].toLowerCase(); - if (allowedValues.includes(lower)) { - return { found: true, valid: true, value: lower }; - } - 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}
`, - `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_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 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_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.
", - "--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}> — Wait (s) after Travel FX before continuing.
`, - `--destination-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, - "
Delays:
", - `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, - `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Travel stage and swap.
`, - "
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).
", - "• 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 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; - } - - return [token1, token2]; - } - - /** - * Swaps token coordinates, verifies the result, and spawns destination FX. - * - * @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 {string} destinationFx FX to spawn at destination points. - * @param {object} msg Roll20 chat message object. - * @returns {void} - */ - function performSwap(token1, token2, pos1, pos2, destinationFx, msg) { - token1.set({ left: pos2.left, top: pos2.top }); - token2.set({ left: pos1.left, top: pos1.top }); - - const isVerified = token1.get("left") === pos2.left && token2.get("left") === pos1.left; - - if (isVerified) { - spawnPointFx(pos2.left, pos2.top, destinationFx, pos2.page); - spawnPointFx(pos1.left, pos1.top, destinationFx, pos1.page); - whisperSender( - msg, - `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, - "Success", - ); - } else { - whisperSenderError(msg, "Token swap failed verification."); - } - } - - /** - * 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, - destinationFx, - originTime, - travelTime, - swapDelay, - destinationDelay, - } = config; - - const msBeforeTravel = (originTime + swapDelay) * 1000; - const msBeforeSwap = (travelTime + destinationDelay) * 1000; - - spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); - spawnPointFx(pos2.left, pos2.top, originFx, pos2.page); - - setTimeout(() => { - spawnTravelFx(pos1, pos2, travelFx); - - setTimeout(() => { - performSwap(token1, token2, pos1, pos2, destinationFx, msg); - }, msBeforeSwap); - }, 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, "none", msg); - return; - } - - const updateTracker = { valid: 0, invalid: 0 }; - const config = buildSwapConfig(msg, updateTracker); - - if (processPersistence(msg, isGM, updateTracker, config)) { - return; - } - - if (updateTracker.valid > 0 && (!FLAG_SAVE.test(msg.content) || !isGM)) { - const overrideDetails = [ - `Origin FX: ${config.originFx}`, - `Travel FX: ${config.travelFx}`, - `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, "none", 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 f6ed1b0f1..c3ea74fb1 100644 --- a/SwapTokenPositions/CHANGELOG.md +++ b/SwapTokenPositions/CHANGELOG.md @@ -2,14 +2,15 @@ All notable changes to the **SwapTokenPositions** script will be documented in this file. -## [2.0.0] - 2026-04-23 +## [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`. -- Preset system with `portal`, `lightning`, `shadow`, `fire`, `magic`, and `none`. +- 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. - `--check-settings` validation command for persistent defaults. - Backward-compatibility parsing for legacy flags with deprecation warnings. @@ -20,8 +21,7 @@ All notable changes to the **SwapTokenPositions** script will be documented in t ### Changed - Refactored internal architecture from a monolithic file to source modules with a generated bundle. -- Updated root `SwapTokenPositions.js` and versioned `2.0.0/SwapTokenPositions.js` to generated artifacts. -- Updated script metadata and developer documentation to reflect version 2 command model. +- Updated generated bundle artifacts used for Roll20 deployment. ### Deprecated diff --git a/SwapTokenPositions/README.md b/SwapTokenPositions/README.md index 50d779dab..5ab7db5d1 100644 --- a/SwapTokenPositions/README.md +++ b/SwapTokenPositions/README.md @@ -5,15 +5,16 @@ ## 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`, and `none` presets. +- **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. ## Development @@ -84,10 +85,12 @@ Swaps the two currently selected tokens using the default settings. - `--help`: Displays the help menu. - `--instant`: Skips all FX and timing and swaps immediately. - `--preset `: Applies a preset. - - Values: `portal`, `lightning`, `shadow`, `fire`, `magic`, `none` + - 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>`: Seconds to wait after travel FX. - `--destination-time <0-10>`: Stored destination timing value. @@ -97,6 +100,8 @@ Swaps the two currently selected tokens using the default settings. ### Examples of Customization - `!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. diff --git a/SwapTokenPositions/TESTING.md b/SwapTokenPositions/TESTING.md index c0801b41f..33ba14765 100644 --- a/SwapTokenPositions/TESTING.md +++ b/SwapTokenPositions/TESTING.md @@ -98,6 +98,34 @@ npm run build - 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** diff --git a/SwapTokenPositions/rollup.config.mjs b/SwapTokenPositions/rollup.config.mjs index 4e47ce341..f235643f3 100644 --- a/SwapTokenPositions/rollup.config.mjs +++ b/SwapTokenPositions/rollup.config.mjs @@ -8,17 +8,42 @@ 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 = [ "/**", - " * GENERATED FILE - DO NOT EDIT DIRECTLY.", - " * Source files live under src/ and are bundled with `npm run build`.", + " * NOTE: GENERATED FILE - DO NOT EDIT DIRECTLY.", + " * NOTE: Source files live under src/ and are bundled with `npm run build`.", + " * ------------------------------------------------", + ` * Name: ${scriptName}`, + ` * Script: ${scriptFile}`, ` * Built: ${buildTimestamp}`, " */", ].join("\n"); export default { input: path.join(__dirname, "src", "index.js"), + plugins: [ + { + name: "inject-build-metadata", + 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`), diff --git a/SwapTokenPositions/script.json b/SwapTokenPositions/script.json index 4c0c4e694..837f40898 100644 --- a/SwapTokenPositions/script.json +++ b/SwapTokenPositions/script.json @@ -144,6 +144,16 @@ "default": "0", "description": "Seconds to wait after travel FX before continuing (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", diff --git a/SwapTokenPositions/src/commands.js b/SwapTokenPositions/src/commands.js index 343ecc2da..b893ea0fb 100644 --- a/SwapTokenPositions/src/commands.js +++ b/SwapTokenPositions/src/commands.js @@ -162,21 +162,20 @@ export function handleSwapTokens(msg) { }; if (FLAG_INSTANT.test(msg.content)) { - performSwap(token1, token2, pos1, pos2, "none", msg); + performSwap(token1, token2, pos1, pos2, msg); return; } const updateTracker = { valid: 0, invalid: 0 }; const config = buildSwapConfig(msg, updateTracker); - if (processPersistence(msg, isGM, updateTracker, config)) { - return; - } + 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`, @@ -197,7 +196,7 @@ export function handleSwapTokens(msg) { config.destinationDelay === 0; if (hasNoFx && hasNoTiming) { - performSwap(token1, token2, pos1, pos2, "none", msg); + performSwap(token1, token2, pos1, pos2, msg); return; } diff --git a/SwapTokenPositions/src/config.js b/SwapTokenPositions/src/config.js index 7cc1e4260..209407181 100644 --- a/SwapTokenPositions/src/config.js +++ b/SwapTokenPositions/src/config.js @@ -2,6 +2,7 @@ import { ALLOWED_POINT_FX, ALLOWED_PRESETS, ALLOWED_TRAVEL_FX, + ALLOWED_TRAVEL_MODES, DELAY_MAX, DELAY_MIN, FLAG_DESTINATION_DELAY, @@ -15,6 +16,7 @@ import { FLAG_PRESET, FLAG_SWAP_DELAY, FLAG_TRAVEL_FX, + FLAG_TRAVEL_MODE, FLAG_TRAVEL_TIME, FX_PRESETS, TIME_MAX, @@ -156,6 +158,12 @@ export function buildSwapConfig(msg, updateTracker) { 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", diff --git a/SwapTokenPositions/src/constants.js b/SwapTokenPositions/src/constants.js index e4ea849af..aa30cc2d4 100644 --- a/SwapTokenPositions/src/constants.js +++ b/SwapTokenPositions/src/constants.js @@ -1,6 +1,7 @@ -export const SCRIPT_NAME = "SwapTokenPositions"; -export const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; -export const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-23"; +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"; @@ -34,6 +35,8 @@ export const ALLOWED_TRAVEL_FX = [ "beam-lightning", ]; +export const ALLOWED_TRAVEL_MODES = ["normal", "invisible"]; + export const ALLOWED_POINT_FX = [ "none", "nova-magic", @@ -93,26 +96,29 @@ export const FX_PRESETS = { destinationTime: 0.5, swapDelay: 0.5, destinationDelay: 1, + travelMode: "normal", }, lightning: { originFx: "none", - travelFx: "beam-lightning", - destinationFx: "burst-energy", + travelFx: "beam-holy", + destinationFx: "burst-holy", originTime: 0, travelTime: 0.3, destinationTime: 0, swapDelay: 0, destinationDelay: 0.3, + travelMode: "normal", }, shadow: { - originFx: "splatter-dark", + originFx: "burst-smoke", travelFx: "none", - destinationFx: "splatter-dark", + destinationFx: "burst-smoke", originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, + travelMode: "normal", }, fire: { originFx: "explode-fire", @@ -123,6 +129,7 @@ export const FX_PRESETS = { destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, + travelMode: "normal", }, magic: { originFx: "nova-magic", @@ -133,6 +140,18 @@ export const FX_PRESETS = { 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", @@ -143,6 +162,7 @@ export const FX_PRESETS = { destinationTime: 0, swapDelay: 0, destinationDelay: 0, + travelMode: "normal", }, }; @@ -157,6 +177,7 @@ export const FACTORY_DEFAULTS = { destinationTime: 0, swapDelay: 0, destinationDelay: 0, + travelMode: "normal", }; export const FLAG_HELP = /--help\b/i; @@ -173,6 +194,7 @@ 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; diff --git a/SwapTokenPositions/src/help.js b/SwapTokenPositions/src/help.js index f4f693fb0..a426c0e13 100644 --- a/SwapTokenPositions/src/help.js +++ b/SwapTokenPositions/src/help.js @@ -27,6 +27,7 @@ export function showHelp(msgObj) { "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.
`, @@ -42,6 +43,7 @@ export function showHelp(msgObj) { "• 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):
", @@ -52,6 +54,7 @@ export function showHelp(msgObj) { "
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
", diff --git a/SwapTokenPositions/src/parsers.js b/SwapTokenPositions/src/parsers.js index 2792e8203..4c587101e 100644 --- a/SwapTokenPositions/src/parsers.js +++ b/SwapTokenPositions/src/parsers.js @@ -13,9 +13,13 @@ export function parseStringFlag(content, flagRegex, allowedValues) { if (!match) { return { found: false, valid: false, value: null }; } - const lower = match[1].toLowerCase(); - if (allowedValues.includes(lower)) { - return { found: true, valid: true, value: lower }; + 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] }; } diff --git a/SwapTokenPositions/src/state.js b/SwapTokenPositions/src/state.js index 1ef06c9ae..9952f37e2 100644 --- a/SwapTokenPositions/src/state.js +++ b/SwapTokenPositions/src/state.js @@ -1,6 +1,7 @@ import { ALLOWED_POINT_FX, ALLOWED_TRAVEL_FX, + ALLOWED_TRAVEL_MODES, DELAY_MAX, DELAY_MIN, FACTORY_DEFAULTS, @@ -44,6 +45,7 @@ export function showSettings() { 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
`, @@ -84,6 +86,9 @@ export function validateSettings(silentOnSuccess = false) { 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.`); } diff --git a/SwapTokenPositions/src/swap.js b/SwapTokenPositions/src/swap.js index 6fc724177..72973e9b8 100644 --- a/SwapTokenPositions/src/swap.js +++ b/SwapTokenPositions/src/swap.js @@ -35,33 +35,181 @@ export function getSelectedTokens(msg) { } /** - * Swaps token coordinates, verifies the result, and spawns destination FX. + * 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 + ); +} + +/** + * 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(); +} + +/** + * 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, destinationFx, msg) { +export function performSwap( + token1, + token2, + pos1, + pos2, + msg, + onVerified, + onFailed, +) { token1.set({ left: pos2.left, top: pos2.top }); token2.set({ left: pos1.left, top: pos1.top }); - const isVerified = token1.get("left") === pos2.left && token2.get("left") === pos1.left; + const maxVerificationAttempts = 8; + const verificationRetryMs = 50; + let attempt = 0; + + const verifyThenFinalize = () => { + if (hasVerifiedSwapPosition(token1, token2, pos1, pos2)) { + whisperSender( + msg, + `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, + "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, + msBeforeSwap, + msBeforeDestinationFx, + } = context; + + spawnTravelFx(pos1, pos2, travelFx); + + setTimeout(() => { + performSwap(token1, token2, pos1, pos2, msg, () => { + scheduleDestinationFx(pos1, pos2, destinationFx, msBeforeDestinationFx); + }); + }, msBeforeSwap); +} + +function runInvisibleTravelPhase(context) { + const { + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msBeforeSwap, + msBeforeDestinationFx, + } = context; + const hideRenderBufferMs = 80; + const revealRenderBufferMs = 120; + + const layer1 = token1.get("layer"); + const layer2 = token2.get("layer"); + + const revealThenFx = () => { + // Restore layer — tokens appear at their new positions with no render artifact. + token1.set({ layer: layer1 }); + token2.set({ layer: layer2 }); + setTimeout(() => scheduleDestinationFx(pos1, pos2, destinationFx, 0), revealRenderBufferMs); + }; + + const doMove = () => { + // Tokens are on the GM layer so the position change is invisible to players. + token1.set({ left: pos2.left, top: pos2.top }); + token2.set({ left: pos1.left, top: pos1.top }); - if (isVerified) { - spawnPointFx(pos2.left, pos2.top, destinationFx, pos2.page); - spawnPointFx(pos1.left, pos1.top, destinationFx, pos1.page); whisperSender( msg, - `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, + `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, "Success", ); - } else { - whisperSenderError(msg, "Token swap failed verification."); - } + + 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. + token1.set({ layer: "gmlayer" }); + token2.set({ layer: "gmlayer" }); + + setTimeout(() => { + spawnTravelFx(pos1, pos2, travelFx); + + if (msBeforeSwap > 0) { + setTimeout(doMove, msBeforeSwap); + } else { + doMove(); + } + }, hideRenderBufferMs); } /** @@ -79,24 +227,49 @@ export function executeSwapPipeline(config, token1, token2, pos1, pos2, msg) { const { originFx, travelFx, + travelMode, destinationFx, originTime, travelTime, swapDelay, destinationDelay, + destinationTime, } = config; - const msBeforeTravel = (originTime + swapDelay) * 1000; - const msBeforeSwap = (travelTime + destinationDelay) * 1000; + const msBeforeTravel = originTime * 1000; + const msBeforeSwap = (travelTime + 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(() => { - spawnTravelFx(pos1, pos2, travelFx); + if (useInvisibleTravel) { + runInvisibleTravelPhase({ + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msBeforeSwap, + msBeforeDestinationFx, + }); + return; + } - setTimeout(() => { - performSwap(token1, token2, pos1, pos2, destinationFx, msg); - }, msBeforeSwap); + runNormalTravelPhase({ + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msBeforeSwap, + msBeforeDestinationFx, + }); }, msBeforeTravel); } From 8785124abe89676f8eeb3588e132ddc348e72e52 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 09:15:49 +0100 Subject: [PATCH 03/15] feat(SwapTokenPositions): improve token name handling Signed-off-by: Steve Roberts --- SwapTokenPositions/src/messages.js | 27 +++++++++++++++++++++++++++ SwapTokenPositions/src/swap.js | 10 +++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/SwapTokenPositions/src/messages.js b/SwapTokenPositions/src/messages.js index 89636339e..3d461c8f7 100644 --- a/SwapTokenPositions/src/messages.js +++ b/SwapTokenPositions/src/messages.js @@ -14,6 +14,33 @@ import { 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. * diff --git a/SwapTokenPositions/src/swap.js b/SwapTokenPositions/src/swap.js index 72973e9b8..e69f2e925 100644 --- a/SwapTokenPositions/src/swap.js +++ b/SwapTokenPositions/src/swap.js @@ -1,6 +1,6 @@ import { SILENT_MANAGEMENT_FLAGS } from "./constants.js"; import { spawnPointFx, spawnTravelFx } from "./effects.js"; -import { whisperSender, whisperSenderError } from "./messages.js"; +import { getSafeTokenName, whisperSender, whisperSenderError } from "./messages.js"; /** * Validates selection and resolves the two tokens targeted for swapping. @@ -105,9 +105,11 @@ export function performSwap( const verifyThenFinalize = () => { if (hasVerifiedSwapPosition(token1, token2, pos1, pos2)) { + const token1Name = getSafeTokenName(token1, "Token 1"); + const token2Name = getSafeTokenName(token2, "Token 2"); whisperSender( msg, - `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, + `Swap Successful!
${token1Name} ↔ ${token2Name}`, "Success", ); if (typeof onVerified === "function") { @@ -183,9 +185,11 @@ function runInvisibleTravelPhase(context) { token1.set({ left: pos2.left, top: pos2.top }); token2.set({ left: pos1.left, top: pos1.top }); + const token1Name = getSafeTokenName(token1, "Token 1"); + const token2Name = getSafeTokenName(token2, "Token 2"); whisperSender( msg, - `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, + `Swap Successful!
${token1Name} ↔ ${token2Name}`, "Success", ); From e04f144d218d26661c6726e56590fdf89e6a1313 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 09:16:10 +0100 Subject: [PATCH 04/15] chore: add a check to ensure tokens are on the same page Signed-off-by: Steve Roberts --- SwapTokenPositions/src/swap.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/SwapTokenPositions/src/swap.js b/SwapTokenPositions/src/swap.js index e69f2e925..fae8f81e6 100644 --- a/SwapTokenPositions/src/swap.js +++ b/SwapTokenPositions/src/swap.js @@ -31,6 +31,15 @@ export function getSelectedTokens(msg) { 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]; } From e86831feebdf1821348cc4688f324edab095c69c Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 10:27:22 +0100 Subject: [PATCH 05/15] feat: add checks that tokens still exist when performing FX Signed-off-by: Steve Roberts --- SwapTokenPositions/TESTING.md | 20 +++++- SwapTokenPositions/src/swap.js | 121 ++++++++++++++++++++++++++------- 2 files changed, 114 insertions(+), 27 deletions(-) diff --git a/SwapTokenPositions/TESTING.md b/SwapTokenPositions/TESTING.md index 33ba14765..fe6c526e7 100644 --- a/SwapTokenPositions/TESTING.md +++ b/SwapTokenPositions/TESTING.md @@ -227,7 +227,25 @@ Run these as GM unless otherwise specified. 2. Restart sandbox and rerun `--show-settings`. - Expected: Saved settings persist across restart. -3. Verify command still works after reset and save cycles. +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 diff --git a/SwapTokenPositions/src/swap.js b/SwapTokenPositions/src/swap.js index fae8f81e6..081b02fb0 100644 --- a/SwapTokenPositions/src/swap.js +++ b/SwapTokenPositions/src/swap.js @@ -61,6 +61,43 @@ function hasVerifiedSwapPosition(token1, token2, pos1, pos2) { ); } +/** + * 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. * @@ -105,17 +142,37 @@ export function performSwap( onVerified, onFailed, ) { - token1.set({ left: pos2.left, top: pos2.top }); - token2.set({ left: pos1.left, top: pos1.top }); + 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 = () => { - if (hasVerifiedSwapPosition(token1, token2, pos1, pos2)) { - const token1Name = getSafeTokenName(token1, "Token 1"); - const token2Name = getSafeTokenName(token2, "Token 2"); + 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}`, @@ -178,41 +235,53 @@ function runInvisibleTravelPhase(context) { } = 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 = () => { - // Restore layer — tokens appear at their new positions with no render artifact. - token1.set({ layer: layer1 }); - token2.set({ layer: layer2 }); - setTimeout(() => scheduleDestinationFx(pos1, pos2, destinationFx, 0), revealRenderBufferMs); + 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 = () => { - // Tokens are on the GM layer so the position change is invisible to players. - token1.set({ left: pos2.left, top: pos2.top }); - token2.set({ left: pos1.left, top: pos1.top }); + 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(token1, "Token 1"); - const token2Name = getSafeTokenName(token2, "Token 2"); - whisperSender( - msg, - `Swap Successful!
${token1Name} ↔ ${token2Name}`, - "Success", - ); + 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(); - } + 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. - token1.set({ layer: "gmlayer" }); - token2.set({ layer: "gmlayer" }); + if ( + !withLiveTokens({ token1Id, token2Id, msg }, ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ layer: "gmlayer" }); + liveToken2.set({ layer: "gmlayer" }); + }) + ) { + return; + } setTimeout(() => { spawnTravelFx(pos1, pos2, travelFx); From 885ccf2c6ab0df8a7957d31157285d603611751f Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 12:06:54 +0100 Subject: [PATCH 06/15] feat(SwapTokenPositions): Reestablished the persistent FX for travel travel time Signed-off-by: Steve Roberts --- SwapTokenPositions/README.md | 2 +- SwapTokenPositions/src/help.js | 2 +- SwapTokenPositions/src/swap.js | 147 +++++++++++++++++++++++++++++---- 3 files changed, 134 insertions(+), 17 deletions(-) diff --git a/SwapTokenPositions/README.md b/SwapTokenPositions/README.md index 5ab7db5d1..4cfa3e21d 100644 --- a/SwapTokenPositions/README.md +++ b/SwapTokenPositions/README.md @@ -92,7 +92,7 @@ Swaps the two currently selected tokens using the default settings. - `--travel-mode `: Visibility behavior during travel stage. - Values: `normal`, `invisible` - `--origin-time <0-10>`: Seconds to wait after origin FX. -- `--travel-time <0-10>`: Seconds to wait after travel FX. +- `--travel-time <0-10>`: Duration in seconds for the travel animation stage. - `--destination-time <0-10>`: Stored destination timing value. - `--swap-delay <0-10>`: Extra delay between origin and travel stages. - `--destination-delay <0-10>`: Extra delay between travel stage and swap. diff --git a/SwapTokenPositions/src/help.js b/SwapTokenPositions/src/help.js index a426c0e13..c5bff28b9 100644 --- a/SwapTokenPositions/src/help.js +++ b/SwapTokenPositions/src/help.js @@ -31,7 +31,7 @@ export function showHelp(msgObj) { "--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}> — Wait (s) after Travel FX before continuing.
`, + `--travel-time <${TIME_MIN}-${TIME_MAX}> — Duration (s) of the travel animation stage.
`, `--destination-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, "
Delays:
", `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, diff --git a/SwapTokenPositions/src/swap.js b/SwapTokenPositions/src/swap.js index 081b02fb0..cbf4de7d9 100644 --- a/SwapTokenPositions/src/swap.js +++ b/SwapTokenPositions/src/swap.js @@ -121,6 +121,103 @@ function scheduleDestinationFx(pos1, pos2, destinationFx, delayMs) { 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. * @@ -208,17 +305,32 @@ function runNormalTravelPhase(context) { travelFx, destinationFx, msg, - msBeforeSwap, + msTravelTime, + msSwapDelay, msBeforeDestinationFx, } = context; - spawnTravelFx(pos1, pos2, travelFx); - - setTimeout(() => { + const runSwap = () => { performSwap(token1, token2, pos1, pos2, msg, () => { scheduleDestinationFx(pos1, pos2, destinationFx, msBeforeDestinationFx); }); - }, msBeforeSwap); + }; + + 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) { @@ -230,7 +342,8 @@ function runInvisibleTravelPhase(context) { travelFx, destinationFx, msg, - msBeforeSwap, + msTravelTime, + msSwapDelay, msBeforeDestinationFx, } = context; const hideRenderBufferMs = 80; @@ -284,14 +397,15 @@ function runInvisibleTravelPhase(context) { } setTimeout(() => { - spawnTravelFx(pos1, pos2, travelFx); + sustainTravelFx(pos1, pos2, travelFx, msTravelTime, () => {}); - if (msBeforeSwap > 0) { - setTimeout(doMove, msBeforeSwap); - } else { - doMove(); + const msBeforeHiddenSwap = msTravelTime + msSwapDelay; + if (msBeforeHiddenSwap > 0) { + setTimeout(doMove, msBeforeHiddenSwap); + return; } - }, hideRenderBufferMs); + doMove(); + }); } /** @@ -319,7 +433,8 @@ export function executeSwapPipeline(config, token1, token2, pos1, pos2, msg) { } = config; const msBeforeTravel = originTime * 1000; - const msBeforeSwap = (travelTime + swapDelay) * 1000; + const msTravelTime = travelTime * 1000; + const msSwapDelay = swapDelay * 1000; const msBeforeDestinationFx = (destinationDelay + destinationTime) * 1000; const useInvisibleTravel = travelMode === "invisible"; @@ -336,7 +451,8 @@ export function executeSwapPipeline(config, token1, token2, pos1, pos2, msg) { travelFx, destinationFx, msg, - msBeforeSwap, + msTravelTime, + msSwapDelay, msBeforeDestinationFx, }); return; @@ -350,7 +466,8 @@ export function executeSwapPipeline(config, token1, token2, pos1, pos2, msg) { travelFx, destinationFx, msg, - msBeforeSwap, + msTravelTime, + msSwapDelay, msBeforeDestinationFx, }); }, msBeforeTravel); From 3ba34230fd3a78141bb9c66ea50d5935e20a5c35 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 14:13:14 +0100 Subject: [PATCH 07/15] build(SwapTokenPosition): rebuilt v2 script after changes to source files. Signed-off-by: Steve Roberts --- .../2.0.0/SwapTokenPositions.js | 1616 +++++++++++++++++ SwapTokenPositions/SwapTokenPositions.js | 508 +++++- 2 files changed, 2086 insertions(+), 38 deletions(-) create mode 100644 SwapTokenPositions/2.0.0/SwapTokenPositions.js diff --git a/SwapTokenPositions/2.0.0/SwapTokenPositions.js b/SwapTokenPositions/2.0.0/SwapTokenPositions.js new file mode 100644 index 000000000..6f6d9476f --- /dev/null +++ b/SwapTokenPositions/2.0.0/SwapTokenPositions.js @@ -0,0 +1,1616 @@ +/** + * 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-24T13:11:59.434Z + */ +(function () { + 'use strict'; + + const SCRIPT_NAME = "SwapTokenPositions"; + const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; + const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-24T13:11:59.434Z"; + + const COLOR_GLOW_PURPLE = "#B388FF"; + 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"; + + 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"; + + 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 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 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_BLUE} 0%,${COLOR_ACCENT_PINK} 100%)`, + "overflow:hidden", + ].join(";"); + + const headerHtml = header + ? `
${header}
` + : ""; + 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 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}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, + "
Delays:
", + `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, + `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Travel stage and swap.
`, + "
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/SwapTokenPositions.js b/SwapTokenPositions/SwapTokenPositions.js index 741d284ff..6f6d9476f 100644 --- a/SwapTokenPositions/SwapTokenPositions.js +++ b/SwapTokenPositions/SwapTokenPositions.js @@ -1,14 +1,17 @@ /** - * GENERATED FILE - DO NOT EDIT DIRECTLY. - * Source files live under src/ and are bundled with `npm run build`. - * Built: 2026-04-23T12:12:21.095Z + * 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-24T13:11:59.434Z */ (function () { 'use strict'; const SCRIPT_NAME = "SwapTokenPositions"; const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; - const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-23"; + const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-24T13:11:59.434Z"; const COLOR_GLOW_PURPLE = "#B388FF"; const COLOR_BG_SOFT_BLACK = "#0A0A12"; @@ -42,6 +45,8 @@ "beam-lightning", ]; + const ALLOWED_TRAVEL_MODES = ["normal", "invisible"]; + const ALLOWED_POINT_FX = [ "none", "nova-magic", @@ -101,26 +106,29 @@ destinationTime: 0.5, swapDelay: 0.5, destinationDelay: 1, + travelMode: "normal", }, lightning: { originFx: "none", - travelFx: "beam-lightning", - destinationFx: "burst-energy", + travelFx: "beam-holy", + destinationFx: "burst-holy", originTime: 0, travelTime: 0.3, destinationTime: 0, swapDelay: 0, destinationDelay: 0.3, + travelMode: "normal", }, shadow: { - originFx: "splatter-dark", + originFx: "burst-smoke", travelFx: "none", - destinationFx: "splatter-dark", + destinationFx: "burst-smoke", originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, + travelMode: "normal", }, fire: { originFx: "explode-fire", @@ -131,6 +139,7 @@ destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, + travelMode: "normal", }, magic: { originFx: "nova-magic", @@ -141,6 +150,18 @@ 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", @@ -151,6 +172,7 @@ destinationTime: 0, swapDelay: 0, destinationDelay: 0, + travelMode: "normal", }, }; @@ -165,6 +187,7 @@ destinationTime: 0, swapDelay: 0, destinationDelay: 0, + travelMode: "normal", }; const FLAG_HELP = /--help\b/i; @@ -181,6 +204,7 @@ 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; @@ -204,6 +228,33 @@ 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. * @@ -378,9 +429,13 @@ if (!match) { return { found: false, valid: false, value: null }; } - const lower = match[1].toLowerCase(); - if (allowedValues.includes(lower)) { - return { found: true, valid: true, value: lower }; + 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] }; } @@ -530,6 +585,7 @@ 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
`, @@ -570,6 +626,9 @@ 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.`); } @@ -742,6 +801,12 @@ 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", @@ -797,10 +862,11 @@ "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}> — Wait (s) after Travel FX before continuing.
`, + `--travel-time <${TIME_MIN}-${TIME_MAX}> — Duration (s) of the travel animation stage.
`, `--destination-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, "
Delays:
", `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`, @@ -812,6 +878,7 @@ "• 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):
", @@ -822,6 +889,7 @@ "
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
", @@ -902,37 +970,374 @@ 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]; } /** - * Swaps token coordinates, verifies the result, and spawns destination FX. + * 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 performSwap(token1, token2, pos1, pos2, destinationFx, msg) { - token1.set({ left: pos2.left, top: pos2.top }); - token2.set({ left: pos1.left, top: pos1.top }); + function animateTravel(token1, token2, pos1, pos2, durationMs, msg, onComplete) { + if (durationMs <= 0) { + onComplete(); + return; + } - const isVerified = token1.get("left") === pos2.left && token2.get("left") === pos1.left; + 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 (isVerified) { - spawnPointFx(pos2.left, pos2.top, destinationFx, pos2.page); - spawnPointFx(pos1.left, pos1.top, destinationFx, pos1.page); - whisperSender( - msg, - `Swap Successful!
${token1.get("name") || "Token 1"} ↔ ${token2.get("name") || "Token 2"}`, - "Success", - ); - } else { - whisperSenderError(msg, "Token swap failed verification."); + 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(); + }); } /** @@ -950,25 +1355,53 @@ const { originFx, travelFx, + travelMode, destinationFx, originTime, travelTime, swapDelay, destinationDelay, + destinationTime, } = config; - const msBeforeTravel = (originTime + swapDelay) * 1000; - const msBeforeSwap = (travelTime + destinationDelay) * 1000; + 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(() => { - spawnTravelFx(pos1, pos2, travelFx); + if (useInvisibleTravel) { + runInvisibleTravelPhase({ + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msTravelTime, + msSwapDelay, + msBeforeDestinationFx, + }); + return; + } - setTimeout(() => { - performSwap(token1, token2, pos1, pos2, destinationFx, msg); - }, msBeforeSwap); + runNormalTravelPhase({ + token1, + token2, + pos1, + pos2, + travelFx, + destinationFx, + msg, + msTravelTime, + msSwapDelay, + msBeforeDestinationFx, + }); }, msBeforeTravel); } @@ -1120,21 +1553,20 @@ }; if (FLAG_INSTANT.test(msg.content)) { - performSwap(token1, token2, pos1, pos2, "none", msg); + performSwap(token1, token2, pos1, pos2, msg); return; } const updateTracker = { valid: 0, invalid: 0 }; const config = buildSwapConfig(msg, updateTracker); - if (processPersistence(msg, isGM, updateTracker, config)) { - return; - } + 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`, @@ -1155,7 +1587,7 @@ config.destinationDelay === 0; if (hasNoFx && hasNoTiming) { - performSwap(token1, token2, pos1, pos2, "none", msg); + performSwap(token1, token2, pos1, pos2, msg); return; } From aa208375ae99e35ce69d500204138775be2bf863 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 14:13:57 +0100 Subject: [PATCH 08/15] fix(SwapTokenPositions): Corrected description of user option Signed-off-by: Steve Roberts --- SwapTokenPositions/script.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SwapTokenPositions/script.json b/SwapTokenPositions/script.json index 837f40898..c4d6d8ee5 100644 --- a/SwapTokenPositions/script.json +++ b/SwapTokenPositions/script.json @@ -142,7 +142,7 @@ "name": "travel-time", "type": "number", "default": "0", - "description": "Seconds to wait after travel FX before continuing (0-10)." + "description": "Travel animation duration in seconds (0-10)." }, { "name": "travel-mode", From 212ea0d72e37155955dce4bb0e8532d6d12944ea Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 18:03:36 +0100 Subject: [PATCH 09/15] feat(SwapTokenPositions): wrap the code in a named scope to prevent collisions. Signed-off-by: Steve Roberts --- SwapTokenPositions/package-lock.json | 17 ++++++++++ SwapTokenPositions/package.json | 1 + SwapTokenPositions/rollup.config.mjs | 51 ++++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/SwapTokenPositions/package-lock.json b/SwapTokenPositions/package-lock.json index 7ef8001ff..a32e69f09 100644 --- a/SwapTokenPositions/package-lock.json +++ b/SwapTokenPositions/package-lock.json @@ -8,6 +8,7 @@ "name": "swap-token-positions", "version": "2.0.0", "devDependencies": { + "prettier": "^3.8.3", "rollup": "^4.52.5" } }, @@ -383,6 +384,22 @@ "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", diff --git a/SwapTokenPositions/package.json b/SwapTokenPositions/package.json index c00fd78b9..7148f6dce 100644 --- a/SwapTokenPositions/package.json +++ b/SwapTokenPositions/package.json @@ -8,6 +8,7 @@ "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 index f235643f3..ac0cebdde 100644 --- a/SwapTokenPositions/rollup.config.mjs +++ b/SwapTokenPositions/rollup.config.mjs @@ -1,6 +1,7 @@ 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); @@ -23,11 +24,51 @@ const banner = [ " */", ].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) { + void options; + 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; @@ -47,13 +88,19 @@ export default { output: [ { file: path.join(__dirname, `${scriptJson.name}.js`), - format: "iife", + format: "es", banner, + intro: `const ${scriptName}Mod = (() => {\n 'use strict';`, + outro: "})();", + plugins: [formatOutputPlugin()], }, { file: path.join(__dirname, scriptJson.version, `${scriptJson.name}.js`), - format: "iife", + format: "es", banner, + intro: `const ${scriptName}Mod = (() => {\n 'use strict';`, + outro: "})();", + plugins: [formatOutputPlugin()], }, ], }; From 4f407e4f80f046e79a3cffce52c50f43fd324e48 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 18:09:57 +0100 Subject: [PATCH 10/15] fix(formatOutputPlugin): remove unused variable in generateBundle function Signed-off-by: Steve Roberts Co-authored-by: Copilot --- SwapTokenPositions/rollup.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/SwapTokenPositions/rollup.config.mjs b/SwapTokenPositions/rollup.config.mjs index ac0cebdde..7ca51fe98 100644 --- a/SwapTokenPositions/rollup.config.mjs +++ b/SwapTokenPositions/rollup.config.mjs @@ -40,7 +40,6 @@ function formatOutputPlugin() { * @returns {Promise} */ async generateBundle(options, bundle) { - void options; for (const output of Object.values(bundle)) { if (output.type !== "chunk") { continue; From 04a5d41d2a65d3a0c551d64dd28620deeb362bc0 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 18:10:30 +0100 Subject: [PATCH 11/15] feat(SwapTokenPositions): add support for deprecated --mode flag with compatibility warnings Signed-off-by: Steve Roberts --- SwapTokenPositions/src/config.js | 33 +++++++++++++++++++++++++++++ SwapTokenPositions/src/constants.js | 1 + 2 files changed, 34 insertions(+) diff --git a/SwapTokenPositions/src/config.js b/SwapTokenPositions/src/config.js index 209407181..2e6ecdf1b 100644 --- a/SwapTokenPositions/src/config.js +++ b/SwapTokenPositions/src/config.js @@ -11,6 +11,7 @@ import { FLAG_LEGACY_BEAM_FX, FLAG_LEGACY_BURST_FX, FLAG_LEGACY_DURATION, + FLAG_LEGACY_MODE, FLAG_ORIGIN_FX, FLAG_ORIGIN_TIME, FLAG_PRESET, @@ -41,6 +42,38 @@ import { getSettings } from "./state.js"; */ 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, diff --git a/SwapTokenPositions/src/constants.js b/SwapTokenPositions/src/constants.js index aa30cc2d4..6b9c9790f 100644 --- a/SwapTokenPositions/src/constants.js +++ b/SwapTokenPositions/src/constants.js @@ -202,6 +202,7 @@ 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, From b61eacde32f9cc629376af7423f414c2b7cc4a99 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 18:13:19 +0100 Subject: [PATCH 12/15] feat(SwapTokenPositions): update README and other docs, including the addition of a developer guide for contributor setup and workflow Co-authored-by: Copilot --- SwapTokenPositions/CHANGELOG.md | 9 ++- SwapTokenPositions/DEVELOPERS.md | 126 +++++++++++++++++++++++++++++++ SwapTokenPositions/README.md | 69 +++++------------ SwapTokenPositions/TESTING.md | 20 ++++- 4 files changed, 164 insertions(+), 60 deletions(-) create mode 100644 SwapTokenPositions/DEVELOPERS.md diff --git a/SwapTokenPositions/CHANGELOG.md b/SwapTokenPositions/CHANGELOG.md index c3ea74fb1..6e138fb97 100644 --- a/SwapTokenPositions/CHANGELOG.md +++ b/SwapTokenPositions/CHANGELOG.md @@ -12,19 +12,21 @@ All notable changes to the **SwapTokenPositions** script will be documented in t - 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. -- `--check-settings` validation command for persistent defaults. - 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. -- Updated generated bundle artifacts used for Roll20 deployment. +- 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`) @@ -33,12 +35,11 @@ All notable changes to the **SwapTokenPositions** script will be documented in t ### 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 4cfa3e21d..dc38743e7 100644 --- a/SwapTokenPositions/README.md +++ b/SwapTokenPositions/README.md @@ -17,61 +17,25 @@ - **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. -## Development +## Contributor Docs -This mod now uses a multi-file source layout for maintenance, but Roll20 still requires a single bundled script for manual testing and publication. +This README focuses on Roll20 command usage. For contributor-oriented details, use these docs: -### Source of Truth +- [DEVELOPERS.md](DEVELOPERS.md) for setup, build, watch mode, troubleshooting, and contributor workflow. +- [TESTING.md](TESTING.md) for the manual Roll20 validation checklist. -As the script was nearly 1.2k lines in a single file, the source code has been refactored into multiple modules under the `src/` directory. This allows for better organization and maintainability. +## v1 to v2 Migration Notes -- Edit files in `src/`. -- Do not hand-edit generated bundles; they are build artifacts. +The v2 series keeps the same core command (`!swap-tokens`) but changes how animation is configured. -### Build Setup - -From the `SwapTokenPositions` folder: - -```bash -npm install -npm run build -``` - -The build writes the same bundled script to both of these files: - -- `SwapTokenPositions.js` -- `/SwapTokenPositions.js` where `` comes from `script.json`. - -### Watch Mode - -For active development: - -```bash -npm run watch -``` - -This rebuilds the bundle whenever a source file changes. - -Roll20 does not load files from `src/` directly. Only the generated single-file bundle should be pasted into the Roll20 mod area. - -### Contributor Workflow - -When making changes to this mod: - -1. Edit the source files under `src/`. -2. Update script metadata in `script.json` if necessary (e.g., version, description). -3. Update documentation in `README.md` and `TESTING.md` as needed to reflect new features or changes. -4. Run `npm run build`. -5. Verify the generated `SwapTokenPositions.js` bundle works in Roll20. -6. Commit the source changes and the regenerated build artifacts together. - -### Manual Roll20 Testing - -1. Run `npm run build`. -2. Open `SwapTokenPositions.js`. -3. Copy the entire generated file. -4. Paste it into the Roll20 Mod Scripts editor for your game. -5. Complete the detailed test plan: [TESTING.md](TESTING.md) +- `--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 @@ -93,9 +57,9 @@ Swaps the two currently selected tokens using the default settings. - 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>`: Stored destination timing value. +- `--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 between travel stage and swap. +- `--destination-delay <0-10>`: Extra delay before destination FX is shown. ### Examples of Customization @@ -120,6 +84,7 @@ Swaps the two currently selected tokens using the default settings. 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`) diff --git a/SwapTokenPositions/TESTING.md b/SwapTokenPositions/TESTING.md index fe6c526e7..093a056e8 100644 --- a/SwapTokenPositions/TESTING.md +++ b/SwapTokenPositions/TESTING.md @@ -144,25 +144,37 @@ npm run build ## Deprecated Flag Warnings -1. **Deprecated beam flag warning** +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. -2. **Deprecated burst flag warning** +4. **Deprecated burst flag warning** - Action: `!swap-tokens --burst-fx burst-holy` - Expected: - Sender sees deprecation warning: use `--destination-fx`. - Command still functions. -3. **Deprecated duration flag warning** +5. **Deprecated duration flag warning** - Action: `!swap-tokens --duration 2` - Expected: - Sender sees deprecation warning: use `--swap-delay`. - Command still functions. -4. **Deprecated invalid values** +6. **Deprecated invalid values** - Action: `!swap-tokens --beam-fx not-a-real-fx --duration 99` - Expected: - Deprecation warnings still appear. From e3ec022ccb88b5f6b30ff4b669410dedf736ec4f Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Fri, 24 Apr 2026 18:15:23 +0100 Subject: [PATCH 13/15] fix(SwapTokenPositions): update help text for clarity on destination timing and delays Signed-off-by: Steve Roberts --- SwapTokenPositions/script.json | 4 ++-- SwapTokenPositions/src/help.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SwapTokenPositions/script.json b/SwapTokenPositions/script.json index c4d6d8ee5..bc5e4ddfd 100644 --- a/SwapTokenPositions/script.json +++ b/SwapTokenPositions/script.json @@ -158,7 +158,7 @@ "name": "destination-time", "type": "number", "default": "0", - "description": "Stored destination timing value in seconds (0-10)." + "description": "Additional wait before destination FX is shown in seconds (0-10)." }, { "name": "swap-delay", @@ -170,7 +170,7 @@ "name": "destination-delay", "type": "number", "default": "0", - "description": "Additional delay between travel and swap in seconds (0-10)." + "description": "Additional delay before destination FX is shown in seconds (0-10)." } ], "dependencies": [], diff --git a/SwapTokenPositions/src/help.js b/SwapTokenPositions/src/help.js index c5bff28b9..979ddfe70 100644 --- a/SwapTokenPositions/src/help.js +++ b/SwapTokenPositions/src/help.js @@ -32,10 +32,10 @@ export function showHelp(msgObj) { "
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}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, + `--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 between Travel stage and swap.
`, + `--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).
", From 6e6f5952a58eddfc0f91ccb82356fe0db3a10f6a Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sat, 25 Apr 2026 02:18:47 +0100 Subject: [PATCH 14/15] refactor(SwapTokenPositions): update color constants and improve message styling Signed-off-by: Steve Roberts Co-authored-by: Copilot --- SwapTokenPositions/src/constants.js | 9 +++++++-- SwapTokenPositions/src/messages.js | 24 +++++++++++++++++------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/SwapTokenPositions/src/constants.js b/SwapTokenPositions/src/constants.js index 6b9c9790f..e5702bbf9 100644 --- a/SwapTokenPositions/src/constants.js +++ b/SwapTokenPositions/src/constants.js @@ -7,15 +7,20 @@ 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_PINK = "#FF4D6D"; -export const COLOR_ACCENT_BLUE = "#3D5AFE"; +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; diff --git a/SwapTokenPositions/src/messages.js b/SwapTokenPositions/src/messages.js index 3d461c8f7..9e236555a 100644 --- a/SwapTokenPositions/src/messages.js +++ b/SwapTokenPositions/src/messages.js @@ -1,14 +1,18 @@ import { - COLOR_ACCENT_BLUE, - COLOR_ACCENT_PINK, + COLOR_ACCENT_PURPLE_LIGHT, + COLOR_ACCENT_PURPLE_DARK, COLOR_BG_SOFT_BLACK, COLOR_ERROR_DARK, COLOR_ERROR_LIGHT, COLOR_ERROR_RED, - COLOR_GLOW_PURPLE, + 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, @@ -51,6 +55,12 @@ export function getSafeTokenName(token, fallback) { */ 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", @@ -60,12 +70,12 @@ export function generateStyledMessage(msg, align = "center", header = "") { "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%)`, + `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}
`; @@ -95,7 +105,7 @@ export function generateStyledErrorMessage(msg, header = "Error", align = "left" "overflow:hidden", ].join(";"); - const headerHtml = `
[!] ${header}
`; + const headerHtml = `
⚠️ ${header}
`; const contentHtml = `
${msg}
`; return `
${headerHtml}${contentHtml}
`; @@ -122,7 +132,7 @@ export function generateStyledSuccessMessage(msg, header = "Success") { "overflow:hidden", ].join(";"); - const headerHtml = `
✅ ${header}
`; + const headerHtml = `
✅ ${header}
`; const contentHtml = `
${msg}
`; return `
${headerHtml}${contentHtml}
`; From b4ec1917c3d143c6d97599674cf1385ef6d7e49b Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Sat, 25 Apr 2026 02:25:39 +0100 Subject: [PATCH 15/15] chore(SwapTokenPositions): current build generated Signed-off-by: Steve Roberts --- .../2.0.0/SwapTokenPositions.js | 919 +++++++++++------- SwapTokenPositions/SwapTokenPositions.js | 919 +++++++++++------- 2 files changed, 1092 insertions(+), 746 deletions(-) diff --git a/SwapTokenPositions/2.0.0/SwapTokenPositions.js b/SwapTokenPositions/2.0.0/SwapTokenPositions.js index 6f6d9476f..8835bd317 100644 --- a/SwapTokenPositions/2.0.0/SwapTokenPositions.js +++ b/SwapTokenPositions/2.0.0/SwapTokenPositions.js @@ -4,28 +4,31 @@ * ------------------------------------------------ * Name: SwapTokenPositions * Script: SwapTokenPositions.js - * Built: 2026-04-24T13:11:59.434Z + * Built: 2026-04-25T01:23:35.563Z */ -(function () { +const SwapTokenPositionsMod = (() => { 'use strict'; - const SCRIPT_NAME = "SwapTokenPositions"; - const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; - const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-24T13:11:59.434Z"; - - const COLOR_GLOW_PURPLE = "#B388FF"; - 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"; - - 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"; + 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; @@ -33,161 +36,161 @@ 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", + '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_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", + '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", + originFx: 'nova-magic', + travelFx: 'beam-magic', + destinationFx: 'burst-holy', originTime: 1, travelTime: 1, destinationTime: 0.5, swapDelay: 0.5, destinationDelay: 1, - travelMode: "normal", + travelMode: 'normal', }, lightning: { - originFx: "none", - travelFx: "beam-holy", - destinationFx: "burst-holy", + originFx: 'none', + travelFx: 'beam-holy', + destinationFx: 'burst-holy', originTime: 0, travelTime: 0.3, destinationTime: 0, swapDelay: 0, destinationDelay: 0.3, - travelMode: "normal", + travelMode: 'normal', }, shadow: { - originFx: "burst-smoke", - travelFx: "none", - destinationFx: "burst-smoke", + originFx: 'burst-smoke', + travelFx: 'none', + destinationFx: 'burst-smoke', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, fire: { - originFx: "explode-fire", - travelFx: "none", - destinationFx: "explode-fire", + originFx: 'explode-fire', + travelFx: 'none', + destinationFx: 'explode-fire', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, magic: { - originFx: "nova-magic", - travelFx: "none", - destinationFx: "burst-magic", + originFx: 'nova-magic', + travelFx: 'none', + destinationFx: 'burst-magic', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, transport: { - originFx: "glow-magic", - travelFx: "none", - destinationFx: "glow-magic", + originFx: 'glow-magic', + travelFx: 'none', + destinationFx: 'glow-magic', originTime: 0.55, travelTime: 0, destinationTime: 0, swapDelay: 0.15, destinationDelay: 0.05, - travelMode: "invisible", + travelMode: 'invisible', }, none: { - originFx: "none", - travelFx: "none", - destinationFx: "none", + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', originTime: 0, travelTime: 0, destinationTime: 0, swapDelay: 0, destinationDelay: 0, - travelMode: "normal", + travelMode: 'normal', }, }; const ALLOWED_PRESETS = Object.keys(FX_PRESETS); const FACTORY_DEFAULTS = { - originFx: "none", - travelFx: "none", - destinationFx: "none", + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', originTime: 0, travelTime: 0, destinationTime: 0, swapDelay: 0, destinationDelay: 0, - travelMode: "normal", + travelMode: 'normal', }; const FLAG_HELP = /--help\b/i; @@ -212,6 +215,7 @@ 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, @@ -236,11 +240,11 @@ */ function escapeHtml(value) { return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } /** @@ -251,7 +255,7 @@ * @returns {string} Escaped token display name. */ function getSafeTokenName(token, fallback) { - const name = token.get("name"); + const name = token.get('name'); return escapeHtml(name?.trim() ? name : fallback); } @@ -263,24 +267,34 @@ * @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}
`; @@ -294,22 +308,22 @@ * @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}
`; @@ -322,21 +336,21 @@ * @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}
`; @@ -350,7 +364,7 @@ * @param {"left"|"center"|"right"} [align="center"] Content alignment. * @returns {void} */ - function whisperGM(msg, header = "", align = "center") { + function whisperGM(msg, header = '', align = 'center') { sendChat(SCRIPT_NAME, `/w GM ${generateStyledMessage(msg, align, header)}`); } @@ -363,9 +377,9 @@ * @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; + 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)}`, @@ -381,9 +395,9 @@ * @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; + 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)}`, @@ -397,8 +411,11 @@ * @param {string} [header="Success"] Optional header label. * @returns {void} */ - function whisperGMSuccess(text, header = "Success") { - sendChat(SCRIPT_NAME, `/w GM ${generateStyledSuccessMessage(text, header)}`); + function whisperGMSuccess(text, header = 'Success') { + sendChat( + SCRIPT_NAME, + `/w GM ${generateStyledSuccessMessage(text, header)}`, + ); } /** @@ -409,7 +426,7 @@ * @param {"left"|"center"|"right"} [align="left"] Content alignment. * @returns {void} */ - function whisperGMError(text, header = "Error", align = "left") { + function whisperGMError(text, header = 'Error', align = 'left') { sendChat( SCRIPT_NAME, `/w GM ${generateStyledErrorMessage(text, header, align)}`, @@ -425,14 +442,16 @@ * @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); + const match = new RegExp(String.raw`${flagRegex.source}\s+(\S+)`, 'i').exec( + content, + ); if (!match) { return { found: false, valid: false, value: null }; } const normalized = match[1] .trim() - .replaceAll(/(^['"]|['"]$)/g, "") - .replaceAll(/[.,;]+$/g, "") + .replaceAll(/(^['"]|['"]$)/g, '') + .replaceAll(/[.,;]+$/g, '') .toLowerCase(); if (allowedValues.includes(normalized)) { return { found: true, valid: true, value: normalized }; @@ -450,7 +469,10 @@ * @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); + const match = new RegExp( + String.raw`${flagRegex.source}\s+([\d.]+)`, + 'i', + ).exec(content); if (!match) { return { found: false, valid: false, value: null }; } @@ -472,13 +494,20 @@ * @param {string} errorMsg Error message shown when invalid. * @returns {void} */ - function applyStringFlagResult(result, key, config, updateTracker, msg, errorMsg) { + 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"); + whisperSenderError(msg, errorMsg, 'Invalid Input'); } } @@ -494,7 +523,15 @@ * @param {{min:number,max:number}} range Allowed numeric range. * @returns {void} */ - function applyNumericFlagResult(result, key, config, updateTracker, msg, label, range) { + function applyNumericFlagResult( + result, + key, + config, + updateTracker, + msg, + label, + range, + ) { if (result.valid) { config[key] = result.value; updateTracker.valid++; @@ -503,7 +540,7 @@ whisperSenderError( msg, `Invalid ${label}: must be between ${range.min} and ${range.max} seconds.`, - "Invalid Input", + 'Invalid Input', ); } } @@ -518,13 +555,19 @@ * @param {object} msg Roll20 chat message object. * @returns {void} */ - function processStringFlags(content, flagConfigs, config, updateTracker, msg) { + 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(", ")}`; + const errorMsg = `Invalid ${label}: '${result.value}'.

Valid: ${allowed.join(', ')}`; applyStringFlagResult(result, key, config, updateTracker, msg, errorMsg); } } @@ -540,13 +583,23 @@ * @param {object} msg Roll20 chat message object. * @returns {void} */ - function processNumericFlags(content, flagConfigs, parseFunc, config, updateTracker, msg) { + 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 }); + applyNumericFlagResult(result, key, config, updateTracker, msg, label, { + min, + max, + }); } } @@ -592,8 +645,8 @@ `Destination Time: ${settings.destinationTime}s
`, `Swap Delay: ${settings.swapDelay}s
`, `Destination Delay: ${settings.destinationDelay}s
`, - ].join(""); - whisperGM(settingsMsg, "Persistent Settings", "left"); + ].join(''); + whisperGM(settingsMsg, 'Persistent Settings', 'left'); } /** @@ -604,8 +657,8 @@ function resetSettings() { state.SwapTokenPositions = { ...FACTORY_DEFAULTS }; whisperGM( - "Settings reset to factory defaults.", - "Settings Reset", + 'Settings reset to factory defaults.', + 'Settings Reset', ); showSettings(); } @@ -630,22 +683,24 @@ 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.`); + 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: '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", + key: 'destinationTime', + label: 'Destination Time', min: TIME_MIN, max: TIME_MAX, }, - { key: "swapDelay", label: "Swap Delay", min: DELAY_MIN, max: DELAY_MAX }, + { key: 'swapDelay', label: 'Swap Delay', min: DELAY_MIN, max: DELAY_MAX }, { - key: "destinationDelay", - label: "Destination Delay", + key: 'destinationDelay', + label: 'Destination Delay', min: DELAY_MIN, max: DELAY_MAX, }, @@ -653,23 +708,26 @@ for (const { key, label, min, max } of timingFields) { const value = settings[key]; - if (typeof value !== "number" || value < min || value > max) { + 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"); + '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"); + whisperGMSuccess( + 'All persistent settings are valid.', + 'Settings Validation', + ); } return true; } @@ -684,20 +742,52 @@ */ 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", + key: 'travelFx', allowed: ALLOWED_TRAVEL_FX, - oldName: "--beam-fx", - newName: "--travel-fx", + oldName: '--beam-fx', + newName: '--travel-fx', }, { flag: FLAG_LEGACY_BURST_FX, - key: "destinationFx", + key: 'destinationFx', allowed: ALLOWED_POINT_FX, - oldName: "--burst-fx", - newName: "--destination-fx", + oldName: '--burst-fx', + newName: '--destination-fx', }, ]; @@ -709,8 +799,8 @@ whisperSender( msg, `${oldName} is deprecated. Use ${newName} instead.`, - "Deprecated Flag", - "left", + 'Deprecated Flag', + 'left', ); if (result.valid) { config[key] = result.value; @@ -719,19 +809,24 @@ updateTracker.invalid++; whisperSenderError( msg, - `Invalid value for deprecated ${oldName}: '${result.value}'.

Valid: ${allowed.join(", ")}`, - "Invalid Input", + `Invalid value for deprecated ${oldName}: '${result.value}'.

Valid: ${allowed.join(', ')}`, + 'Invalid Input', ); } } - const durationResult = parseFloatFlag(content, FLAG_LEGACY_DURATION, DELAY_MIN, DELAY_MAX); + 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", + '--duration is deprecated. Use --swap-delay instead.', + 'Deprecated Flag', + 'left', ); if (durationResult.valid) { config.swapDelay = durationResult.value; @@ -741,7 +836,7 @@ whisperSenderError( msg, `Invalid value for deprecated --duration: must be between ${DELAY_MIN} and ${DELAY_MAX} seconds.`, - "Invalid Input", + 'Invalid Input', ); } } @@ -768,8 +863,8 @@ updateTracker.invalid++; whisperSenderError( msg, - `Invalid preset: '${presetResult.value}'.

Valid presets: ${ALLOWED_PRESETS.join(", ")}`, - "Invalid Input", + `Invalid preset: '${presetResult.value}'.

Valid presets: ${ALLOWED_PRESETS.join(', ')}`, + 'Invalid Input', ); } } @@ -791,55 +886,87 @@ const fxFlags = [ { flag: FLAG_ORIGIN_FX, - key: "originFx", + key: 'originFx', allowed: ALLOWED_POINT_FX, - label: "Origin FX", + label: 'Origin FX', }, { flag: FLAG_TRAVEL_FX, - key: "travelFx", + key: 'travelFx', allowed: ALLOWED_TRAVEL_FX, - label: "Travel FX", + label: 'Travel FX', }, { flag: FLAG_TRAVEL_MODE, - key: "travelMode", + key: 'travelMode', allowed: ALLOWED_TRAVEL_MODES, - label: "Travel Mode", + label: 'Travel Mode', }, { flag: FLAG_DESTINATION_FX, - key: "destinationFx", + key: 'destinationFx', allowed: ALLOWED_POINT_FX, - label: "Destination 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_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", + key: 'destinationTime', + label: 'Destination Time', min: TIME_MIN, max: TIME_MAX, }, ]; - processNumericFlags(content, timeFlags, parseFloatFlag, config, updateTracker, msg); + 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_SWAP_DELAY, + key: 'swapDelay', + label: 'Swap Delay', + min: DELAY_MIN, + max: DELAY_MAX, + }, { flag: FLAG_DESTINATION_DELAY, - key: "destinationDelay", - label: "Destination Delay", + key: 'destinationDelay', + label: 'Destination Delay', min: DELAY_MIN, max: DELAY_MAX, }, ]; - processNumericFlags(content, delayFlags, parseFloatFlag, config, updateTracker, msg); + processNumericFlags( + content, + delayFlags, + parseFloatFlag, + config, + updateTracker, + msg, + ); return config; } @@ -854,48 +981,48 @@ const helpMsg = [ `SwapTokenPositions v${SWAP_TOKEN_POSITIONS_VERSION}
`, `Last Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}
`, - "
Basic Usage:
", - "!swap-tokens — Instant swap of 2 selected tokens.
", - "!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
", - "!swap-tokens --help — Show this help message (available to all players).
", - "
FX Stages:
", - "Pipeline order: Origin FX → Travel FX → Swap → Destination FX.
", - "--origin-fx <type> — FX at both original positions before movement.
", - "--travel-fx <type> — FX between tokens during transition.
", - "--travel-mode <normal|invisible> — Keep tokens visible during travel or hide them until reveal.
", - "--destination-fx <type> — FX at both new positions after swap.
", - "
Stage Timing:
", + '
Basic Usage:
', + '!swap-tokens — Instant swap of 2 selected tokens.
', + '!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
', + '!swap-tokens --help — Show this help message (available to all players).
', + '
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}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, - "
Delays:
", + `--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 between Travel stage and swap.
`, - "
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.
", + `--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"); + '
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'); } /** @@ -908,13 +1035,15 @@ * @returns {void} */ function spawnPointFx(x, y, fxType, pageId) { - if (fxType === "none") { + if (fxType === 'none') { return; } try { spawnFx(x, y, fxType, pageId); } catch (error) { - log(`SwapTokenPositions: Point FX failed, but swap will continue: ${error.message}`); + log( + `SwapTokenPositions: Point FX failed, but swap will continue: ${error.message}`, + ); } } @@ -927,7 +1056,7 @@ * @returns {void} */ function spawnTravelFx(pos1, pos2, fxType) { - if (fxType === "none") { + if (fxType === 'none') { return; } try { @@ -937,7 +1066,9 @@ fxType, ); } catch (error) { - log(`SwapTokenPositions: Travel FX failed, but swap will continue: ${error.message}`); + log( + `SwapTokenPositions: Travel FX failed, but swap will continue: ${error.message}`, + ); } } @@ -951,30 +1082,35 @@ const selectedCount = (msg.selected || []).length; if (selectedCount !== 2) { - const isSilent = SILENT_MANAGEMENT_FLAGS.some((flag) => flag.test(msg.content)); + 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", + 'Selection Error', ); } return null; } - const token1 = getObj("graphic", msg.selected[0]._id); - const token2 = getObj("graphic", msg.selected[1]._id); + const token1 = getObj('graphic', msg.selected[0]._id); + const token2 = getObj('graphic', msg.selected[1]._id); if (!token1 || !token2) { - whisperSenderError(msg, "One or both selected tokens could not be found."); + whisperSenderError( + msg, + 'One or both selected tokens could not be found.', + ); return null; } - if (token1.get("pageid") !== token2.get("pageid")) { + if (token1.get('pageid') !== token2.get('pageid')) { whisperSenderError( msg, - "Please select two tokens on the same page to perform a swap.", - "Selection Error", + 'Please select two tokens on the same page to perform a swap.', + 'Selection Error', ); return null; } @@ -993,10 +1129,10 @@ */ function hasVerifiedSwapPosition(token1, token2, pos1, pos2) { return ( - token1.get("left") === pos2.left && - token1.get("top") === pos2.top && - token2.get("left") === pos1.left && - token2.get("top") === pos1.top + token1.get('left') === pos2.left && + token1.get('top') === pos2.top && + token2.get('left') === pos1.left && + token2.get('top') === pos1.top ); } @@ -1008,8 +1144,8 @@ * @returns {{token1:object, token2:object}|null} Live tokens or null when missing. */ function getLiveTokenPair(token1Id, token2Id) { - const token1 = getObj("graphic", token1Id); - const token2 = getObj("graphic", token2Id); + const token1 = getObj('graphic', token1Id); + const token2 = getObj('graphic', token2Id); if (!token1 || !token2) { return null; } @@ -1028,8 +1164,8 @@ if (!livePair) { whisperSenderError( context.msg, - "Swap cancelled because one or both tokens are no longer available.", - "Swap Cancelled", + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled', ); return false; } @@ -1075,7 +1211,7 @@ * @returns {void} */ function sustainTravelFx(pos1, pos2, travelFx, durationMs, onComplete) { - if (travelFx === "none") { + if (travelFx === 'none') { onComplete(); return; } @@ -1113,14 +1249,22 @@ * @param {Function} onComplete Callback after animation reaches the destination. * @returns {void} */ - function animateTravel(token1, token2, pos1, pos2, durationMs, msg, onComplete) { + function animateTravel( + token1, + token2, + pos1, + pos2, + durationMs, + msg, + onComplete, + ) { if (durationMs <= 0) { onComplete(); return; } - const token1Id = token1.get("_id"); - const token2Id = token2.get("_id"); + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); // Roll20 can coalesce very frequent token updates. Use paced, fixed steps so // travel visibly spans the configured duration. const maxTickMs = 120; @@ -1138,10 +1282,13 @@ 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 }); - }) + !withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ left: nextToken1Left, top: nextToken1Top }); + liveToken2.set({ left: nextToken2Left, top: nextToken2Top }); + }, + ) ) { return; } @@ -1169,22 +1316,19 @@ * @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"); + 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 }); - })) { + 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; } @@ -1197,21 +1341,23 @@ if (!livePair) { whisperSenderError( msg, - "Swap cancelled because one or both tokens are no longer available.", - "Swap Cancelled", + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled', ); return; } - if (hasVerifiedSwapPosition(livePair.token1, livePair.token2, pos1, pos2)) { - const token1Name = getSafeTokenName(livePair.token1, "Token 1"); - const token2Name = getSafeTokenName(livePair.token2, "Token 2"); + 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", + 'Success', ); - if (typeof onVerified === "function") { + if (typeof onVerified === 'function') { onVerified(); } return; @@ -1219,7 +1365,7 @@ attempt += 1; if (attempt >= maxVerificationAttempts) { - whisperSenderError(msg, "Token swap failed verification."); + whisperSenderError(msg, 'Token swap failed verification.'); return; } @@ -1262,7 +1408,15 @@ } }; - animateTravel(token1, token2, pos1, pos2, msTravelTime, msg, finishTravelPhase); + animateTravel( + token1, + token2, + pos1, + pos2, + msTravelTime, + msg, + finishTravelPhase, + ); sustainTravelFx(pos1, pos2, travelFx, msTravelTime, finishTravelPhase); } @@ -1280,50 +1434,62 @@ msBeforeDestinationFx, } = context; const revealRenderBufferMs = 120; - const token1Id = token1.get("_id"); - const token2Id = token2.get("_id"); + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); - const layer1 = token1.get("layer"); - const layer2 = token2.get("layer"); + const layer1 = token1.get('layer'); + const layer2 = token2.get('layer'); const revealThenFx = () => { - withLiveTokens({ token1Id, token2Id, msg }, ({ token1: liveToken1, token2: liveToken2 }) => { - // 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); - }); + 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(); - } - }); + 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" }); - }) + !withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ layer: 'gmlayer' }); + liveToken2.set({ layer: 'gmlayer' }); + }, + ) ) { return; } @@ -1368,7 +1534,7 @@ const msTravelTime = travelTime * 1000; const msSwapDelay = swapDelay * 1000; const msBeforeDestinationFx = (destinationDelay + destinationTime) * 1000; - const useInvisibleTravel = travelMode === "invisible"; + const useInvisibleTravel = travelMode === 'invisible'; spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); spawnPointFx(pos2.left, pos2.top, originFx, pos2.page); @@ -1412,28 +1578,28 @@ * @returns {void} */ function installMacro(msgObj) { - const macroName = "SwapTokens"; - const existing = findObjs({ type: "macro", name: macroName }); + const macroName = 'SwapTokens'; + const existing = findObjs({ type: 'macro', name: macroName }); if (existing.length > 0) { whisperSenderError( msgObj, `A macro named '${macroName}' already exists.`, - "Macro Exists", + 'Macro Exists', ); return; } - createObj("macro", { + createObj('macro', { name: macroName, - action: "!swap-tokens", + action: '!swap-tokens', playerid: msgObj.playerid, - isvisibleto: "all", + isvisibleto: 'all', }); whisperGMSuccess( `Global macro '${macroName}' has been created and is visible to all players.`, - "Macro Installed", + 'Macro Installed', ); } @@ -1450,12 +1616,14 @@ return true; } - const hasManagementFlag = MANAGEMENT_FLAGS.some((flag) => flag.test(msg.content)); + 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", + 'You do not have permission to use script management flags.', + 'Access Denied', ); return true; } @@ -1497,22 +1665,28 @@ if (!isGM) { whisperSenderError( msg, - "You do not have permission to set game defaults.", - "Access Denied", + 'You do not have permission to set game defaults.', + 'Access Denied', ); return false; } if (tracker.valid > 0 && tracker.invalid === 0) { Object.assign(state.SwapTokenPositions, config); - whisperGMSuccess("New defaults saved to persistent state.", "Configuration"); + whisperGMSuccess( + 'New defaults saved to persistent state.', + 'Configuration', + ); showSettings(); } else if (tracker.invalid > 0) { - whisperGMError("Settings not saved due to invalid parameters.", "Save Failed"); + whisperGMError( + 'Settings not saved due to invalid parameters.', + 'Save Failed', + ); } else { whisperGMError( - "No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.", - "Nothing to Save", + 'No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.', + 'Nothing to Save', ); } return true; @@ -1525,7 +1699,7 @@ * @returns {void} */ function handleSwapTokens(msg) { - if (msg.type !== "api" || !/^!swap-tokens\b/i.test(msg.content)) { + if (msg.type !== 'api' || !/^!swap-tokens\b/i.test(msg.content)) { return; } @@ -1542,14 +1716,14 @@ const [token1, token2] = tokens; const pos1 = { - left: token1.get("left"), - top: token1.get("top"), - page: token1.get("pageid"), + left: token1.get('left'), + top: token1.get('top'), + page: token1.get('pageid'), }; const pos2 = { - left: token2.get("left"), - top: token2.get("top"), - page: token2.get("pageid"), + left: token2.get('left'), + top: token2.get('top'), + page: token2.get('pageid'), }; if (FLAG_INSTANT.test(msg.content)) { @@ -1572,14 +1746,14 @@ `Travel Time: ${config.travelTime}s`, `Swap Delay: ${config.swapDelay}s`, `Destination Delay: ${config.destinationDelay}s`, - ].join("
"); - whisperSender(msg, overrideDetails, "Override Active", "left"); + ].join('
'); + whisperSender(msg, overrideDetails, 'Override Active', 'left'); } const hasNoFx = - config.originFx === "none" && - config.travelFx === "none" && - config.destinationFx === "none"; + config.originFx === 'none' && + config.travelFx === 'none' && + config.destinationFx === 'none'; const hasNoTiming = config.originTime === 0 && config.travelTime === 0 && @@ -1600,7 +1774,7 @@ * * @returns {void} */ - on("ready", () => { + on('ready', () => { initializeState(); validateSettings(true); log( @@ -1608,9 +1782,8 @@ ); whisperGM( `MOD READY (v${SWAP_TOKEN_POSITIONS_VERSION})`, - "Script Ready", + 'Script Ready', ); - on("chat:message", handleSwapTokens); + on('chat:message', handleSwapTokens); }); - })(); diff --git a/SwapTokenPositions/SwapTokenPositions.js b/SwapTokenPositions/SwapTokenPositions.js index 6f6d9476f..8835bd317 100644 --- a/SwapTokenPositions/SwapTokenPositions.js +++ b/SwapTokenPositions/SwapTokenPositions.js @@ -4,28 +4,31 @@ * ------------------------------------------------ * Name: SwapTokenPositions * Script: SwapTokenPositions.js - * Built: 2026-04-24T13:11:59.434Z + * Built: 2026-04-25T01:23:35.563Z */ -(function () { +const SwapTokenPositionsMod = (() => { 'use strict'; - const SCRIPT_NAME = "SwapTokenPositions"; - const SWAP_TOKEN_POSITIONS_VERSION = "2.0.0"; - const SWAP_TOKEN_POSITIONS_LAST_UPDATED = "2026-04-24T13:11:59.434Z"; - - const COLOR_GLOW_PURPLE = "#B388FF"; - 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"; - - 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"; + 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; @@ -33,161 +36,161 @@ 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", + '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_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", + '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", + originFx: 'nova-magic', + travelFx: 'beam-magic', + destinationFx: 'burst-holy', originTime: 1, travelTime: 1, destinationTime: 0.5, swapDelay: 0.5, destinationDelay: 1, - travelMode: "normal", + travelMode: 'normal', }, lightning: { - originFx: "none", - travelFx: "beam-holy", - destinationFx: "burst-holy", + originFx: 'none', + travelFx: 'beam-holy', + destinationFx: 'burst-holy', originTime: 0, travelTime: 0.3, destinationTime: 0, swapDelay: 0, destinationDelay: 0.3, - travelMode: "normal", + travelMode: 'normal', }, shadow: { - originFx: "burst-smoke", - travelFx: "none", - destinationFx: "burst-smoke", + originFx: 'burst-smoke', + travelFx: 'none', + destinationFx: 'burst-smoke', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, fire: { - originFx: "explode-fire", - travelFx: "none", - destinationFx: "explode-fire", + originFx: 'explode-fire', + travelFx: 'none', + destinationFx: 'explode-fire', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, magic: { - originFx: "nova-magic", - travelFx: "none", - destinationFx: "burst-magic", + originFx: 'nova-magic', + travelFx: 'none', + destinationFx: 'burst-magic', originTime: 0.5, travelTime: 0, destinationTime: 0, swapDelay: 0.5, destinationDelay: 0.5, - travelMode: "normal", + travelMode: 'normal', }, transport: { - originFx: "glow-magic", - travelFx: "none", - destinationFx: "glow-magic", + originFx: 'glow-magic', + travelFx: 'none', + destinationFx: 'glow-magic', originTime: 0.55, travelTime: 0, destinationTime: 0, swapDelay: 0.15, destinationDelay: 0.05, - travelMode: "invisible", + travelMode: 'invisible', }, none: { - originFx: "none", - travelFx: "none", - destinationFx: "none", + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', originTime: 0, travelTime: 0, destinationTime: 0, swapDelay: 0, destinationDelay: 0, - travelMode: "normal", + travelMode: 'normal', }, }; const ALLOWED_PRESETS = Object.keys(FX_PRESETS); const FACTORY_DEFAULTS = { - originFx: "none", - travelFx: "none", - destinationFx: "none", + originFx: 'none', + travelFx: 'none', + destinationFx: 'none', originTime: 0, travelTime: 0, destinationTime: 0, swapDelay: 0, destinationDelay: 0, - travelMode: "normal", + travelMode: 'normal', }; const FLAG_HELP = /--help\b/i; @@ -212,6 +215,7 @@ 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, @@ -236,11 +240,11 @@ */ function escapeHtml(value) { return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } /** @@ -251,7 +255,7 @@ * @returns {string} Escaped token display name. */ function getSafeTokenName(token, fallback) { - const name = token.get("name"); + const name = token.get('name'); return escapeHtml(name?.trim() ? name : fallback); } @@ -263,24 +267,34 @@ * @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}
`; @@ -294,22 +308,22 @@ * @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}
`; @@ -322,21 +336,21 @@ * @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}
`; @@ -350,7 +364,7 @@ * @param {"left"|"center"|"right"} [align="center"] Content alignment. * @returns {void} */ - function whisperGM(msg, header = "", align = "center") { + function whisperGM(msg, header = '', align = 'center') { sendChat(SCRIPT_NAME, `/w GM ${generateStyledMessage(msg, align, header)}`); } @@ -363,9 +377,9 @@ * @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; + 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)}`, @@ -381,9 +395,9 @@ * @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; + 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)}`, @@ -397,8 +411,11 @@ * @param {string} [header="Success"] Optional header label. * @returns {void} */ - function whisperGMSuccess(text, header = "Success") { - sendChat(SCRIPT_NAME, `/w GM ${generateStyledSuccessMessage(text, header)}`); + function whisperGMSuccess(text, header = 'Success') { + sendChat( + SCRIPT_NAME, + `/w GM ${generateStyledSuccessMessage(text, header)}`, + ); } /** @@ -409,7 +426,7 @@ * @param {"left"|"center"|"right"} [align="left"] Content alignment. * @returns {void} */ - function whisperGMError(text, header = "Error", align = "left") { + function whisperGMError(text, header = 'Error', align = 'left') { sendChat( SCRIPT_NAME, `/w GM ${generateStyledErrorMessage(text, header, align)}`, @@ -425,14 +442,16 @@ * @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); + const match = new RegExp(String.raw`${flagRegex.source}\s+(\S+)`, 'i').exec( + content, + ); if (!match) { return { found: false, valid: false, value: null }; } const normalized = match[1] .trim() - .replaceAll(/(^['"]|['"]$)/g, "") - .replaceAll(/[.,;]+$/g, "") + .replaceAll(/(^['"]|['"]$)/g, '') + .replaceAll(/[.,;]+$/g, '') .toLowerCase(); if (allowedValues.includes(normalized)) { return { found: true, valid: true, value: normalized }; @@ -450,7 +469,10 @@ * @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); + const match = new RegExp( + String.raw`${flagRegex.source}\s+([\d.]+)`, + 'i', + ).exec(content); if (!match) { return { found: false, valid: false, value: null }; } @@ -472,13 +494,20 @@ * @param {string} errorMsg Error message shown when invalid. * @returns {void} */ - function applyStringFlagResult(result, key, config, updateTracker, msg, errorMsg) { + 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"); + whisperSenderError(msg, errorMsg, 'Invalid Input'); } } @@ -494,7 +523,15 @@ * @param {{min:number,max:number}} range Allowed numeric range. * @returns {void} */ - function applyNumericFlagResult(result, key, config, updateTracker, msg, label, range) { + function applyNumericFlagResult( + result, + key, + config, + updateTracker, + msg, + label, + range, + ) { if (result.valid) { config[key] = result.value; updateTracker.valid++; @@ -503,7 +540,7 @@ whisperSenderError( msg, `Invalid ${label}: must be between ${range.min} and ${range.max} seconds.`, - "Invalid Input", + 'Invalid Input', ); } } @@ -518,13 +555,19 @@ * @param {object} msg Roll20 chat message object. * @returns {void} */ - function processStringFlags(content, flagConfigs, config, updateTracker, msg) { + 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(", ")}`; + const errorMsg = `Invalid ${label}: '${result.value}'.

Valid: ${allowed.join(', ')}`; applyStringFlagResult(result, key, config, updateTracker, msg, errorMsg); } } @@ -540,13 +583,23 @@ * @param {object} msg Roll20 chat message object. * @returns {void} */ - function processNumericFlags(content, flagConfigs, parseFunc, config, updateTracker, msg) { + 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 }); + applyNumericFlagResult(result, key, config, updateTracker, msg, label, { + min, + max, + }); } } @@ -592,8 +645,8 @@ `Destination Time: ${settings.destinationTime}s
`, `Swap Delay: ${settings.swapDelay}s
`, `Destination Delay: ${settings.destinationDelay}s
`, - ].join(""); - whisperGM(settingsMsg, "Persistent Settings", "left"); + ].join(''); + whisperGM(settingsMsg, 'Persistent Settings', 'left'); } /** @@ -604,8 +657,8 @@ function resetSettings() { state.SwapTokenPositions = { ...FACTORY_DEFAULTS }; whisperGM( - "Settings reset to factory defaults.", - "Settings Reset", + 'Settings reset to factory defaults.', + 'Settings Reset', ); showSettings(); } @@ -630,22 +683,24 @@ 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.`); + 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: '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", + key: 'destinationTime', + label: 'Destination Time', min: TIME_MIN, max: TIME_MAX, }, - { key: "swapDelay", label: "Swap Delay", min: DELAY_MIN, max: DELAY_MAX }, + { key: 'swapDelay', label: 'Swap Delay', min: DELAY_MIN, max: DELAY_MAX }, { - key: "destinationDelay", - label: "Destination Delay", + key: 'destinationDelay', + label: 'Destination Delay', min: DELAY_MIN, max: DELAY_MAX, }, @@ -653,23 +708,26 @@ for (const { key, label, min, max } of timingFields) { const value = settings[key]; - if (typeof value !== "number" || value < min || value > max) { + 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"); + '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"); + whisperGMSuccess( + 'All persistent settings are valid.', + 'Settings Validation', + ); } return true; } @@ -684,20 +742,52 @@ */ 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", + key: 'travelFx', allowed: ALLOWED_TRAVEL_FX, - oldName: "--beam-fx", - newName: "--travel-fx", + oldName: '--beam-fx', + newName: '--travel-fx', }, { flag: FLAG_LEGACY_BURST_FX, - key: "destinationFx", + key: 'destinationFx', allowed: ALLOWED_POINT_FX, - oldName: "--burst-fx", - newName: "--destination-fx", + oldName: '--burst-fx', + newName: '--destination-fx', }, ]; @@ -709,8 +799,8 @@ whisperSender( msg, `${oldName} is deprecated. Use ${newName} instead.`, - "Deprecated Flag", - "left", + 'Deprecated Flag', + 'left', ); if (result.valid) { config[key] = result.value; @@ -719,19 +809,24 @@ updateTracker.invalid++; whisperSenderError( msg, - `Invalid value for deprecated ${oldName}: '${result.value}'.

Valid: ${allowed.join(", ")}`, - "Invalid Input", + `Invalid value for deprecated ${oldName}: '${result.value}'.

Valid: ${allowed.join(', ')}`, + 'Invalid Input', ); } } - const durationResult = parseFloatFlag(content, FLAG_LEGACY_DURATION, DELAY_MIN, DELAY_MAX); + 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", + '--duration is deprecated. Use --swap-delay instead.', + 'Deprecated Flag', + 'left', ); if (durationResult.valid) { config.swapDelay = durationResult.value; @@ -741,7 +836,7 @@ whisperSenderError( msg, `Invalid value for deprecated --duration: must be between ${DELAY_MIN} and ${DELAY_MAX} seconds.`, - "Invalid Input", + 'Invalid Input', ); } } @@ -768,8 +863,8 @@ updateTracker.invalid++; whisperSenderError( msg, - `Invalid preset: '${presetResult.value}'.

Valid presets: ${ALLOWED_PRESETS.join(", ")}`, - "Invalid Input", + `Invalid preset: '${presetResult.value}'.

Valid presets: ${ALLOWED_PRESETS.join(', ')}`, + 'Invalid Input', ); } } @@ -791,55 +886,87 @@ const fxFlags = [ { flag: FLAG_ORIGIN_FX, - key: "originFx", + key: 'originFx', allowed: ALLOWED_POINT_FX, - label: "Origin FX", + label: 'Origin FX', }, { flag: FLAG_TRAVEL_FX, - key: "travelFx", + key: 'travelFx', allowed: ALLOWED_TRAVEL_FX, - label: "Travel FX", + label: 'Travel FX', }, { flag: FLAG_TRAVEL_MODE, - key: "travelMode", + key: 'travelMode', allowed: ALLOWED_TRAVEL_MODES, - label: "Travel Mode", + label: 'Travel Mode', }, { flag: FLAG_DESTINATION_FX, - key: "destinationFx", + key: 'destinationFx', allowed: ALLOWED_POINT_FX, - label: "Destination 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_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", + key: 'destinationTime', + label: 'Destination Time', min: TIME_MIN, max: TIME_MAX, }, ]; - processNumericFlags(content, timeFlags, parseFloatFlag, config, updateTracker, msg); + 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_SWAP_DELAY, + key: 'swapDelay', + label: 'Swap Delay', + min: DELAY_MIN, + max: DELAY_MAX, + }, { flag: FLAG_DESTINATION_DELAY, - key: "destinationDelay", - label: "Destination Delay", + key: 'destinationDelay', + label: 'Destination Delay', min: DELAY_MIN, max: DELAY_MAX, }, ]; - processNumericFlags(content, delayFlags, parseFloatFlag, config, updateTracker, msg); + processNumericFlags( + content, + delayFlags, + parseFloatFlag, + config, + updateTracker, + msg, + ); return config; } @@ -854,48 +981,48 @@ const helpMsg = [ `SwapTokenPositions v${SWAP_TOKEN_POSITIONS_VERSION}
`, `Last Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}
`, - "
Basic Usage:
", - "!swap-tokens — Instant swap of 2 selected tokens.
", - "!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
", - "!swap-tokens --help — Show this help message (available to all players).
", - "
FX Stages:
", - "Pipeline order: Origin FX → Travel FX → Swap → Destination FX.
", - "--origin-fx <type> — FX at both original positions before movement.
", - "--travel-fx <type> — FX between tokens during transition.
", - "--travel-mode <normal|invisible> — Keep tokens visible during travel or hide them until reveal.
", - "--destination-fx <type> — FX at both new positions after swap.
", - "
Stage Timing:
", + '
Basic Usage:
', + '!swap-tokens — Instant swap of 2 selected tokens.
', + '!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
', + '!swap-tokens --help — Show this help message (available to all players).
', + '
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}> — Wait (s) after Destination FX (stored, no pipeline effect).
`, - "
Delays:
", + `--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 between Travel stage and swap.
`, - "
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.
", + `--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"); + '
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'); } /** @@ -908,13 +1035,15 @@ * @returns {void} */ function spawnPointFx(x, y, fxType, pageId) { - if (fxType === "none") { + if (fxType === 'none') { return; } try { spawnFx(x, y, fxType, pageId); } catch (error) { - log(`SwapTokenPositions: Point FX failed, but swap will continue: ${error.message}`); + log( + `SwapTokenPositions: Point FX failed, but swap will continue: ${error.message}`, + ); } } @@ -927,7 +1056,7 @@ * @returns {void} */ function spawnTravelFx(pos1, pos2, fxType) { - if (fxType === "none") { + if (fxType === 'none') { return; } try { @@ -937,7 +1066,9 @@ fxType, ); } catch (error) { - log(`SwapTokenPositions: Travel FX failed, but swap will continue: ${error.message}`); + log( + `SwapTokenPositions: Travel FX failed, but swap will continue: ${error.message}`, + ); } } @@ -951,30 +1082,35 @@ const selectedCount = (msg.selected || []).length; if (selectedCount !== 2) { - const isSilent = SILENT_MANAGEMENT_FLAGS.some((flag) => flag.test(msg.content)); + 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", + 'Selection Error', ); } return null; } - const token1 = getObj("graphic", msg.selected[0]._id); - const token2 = getObj("graphic", msg.selected[1]._id); + const token1 = getObj('graphic', msg.selected[0]._id); + const token2 = getObj('graphic', msg.selected[1]._id); if (!token1 || !token2) { - whisperSenderError(msg, "One or both selected tokens could not be found."); + whisperSenderError( + msg, + 'One or both selected tokens could not be found.', + ); return null; } - if (token1.get("pageid") !== token2.get("pageid")) { + if (token1.get('pageid') !== token2.get('pageid')) { whisperSenderError( msg, - "Please select two tokens on the same page to perform a swap.", - "Selection Error", + 'Please select two tokens on the same page to perform a swap.', + 'Selection Error', ); return null; } @@ -993,10 +1129,10 @@ */ function hasVerifiedSwapPosition(token1, token2, pos1, pos2) { return ( - token1.get("left") === pos2.left && - token1.get("top") === pos2.top && - token2.get("left") === pos1.left && - token2.get("top") === pos1.top + token1.get('left') === pos2.left && + token1.get('top') === pos2.top && + token2.get('left') === pos1.left && + token2.get('top') === pos1.top ); } @@ -1008,8 +1144,8 @@ * @returns {{token1:object, token2:object}|null} Live tokens or null when missing. */ function getLiveTokenPair(token1Id, token2Id) { - const token1 = getObj("graphic", token1Id); - const token2 = getObj("graphic", token2Id); + const token1 = getObj('graphic', token1Id); + const token2 = getObj('graphic', token2Id); if (!token1 || !token2) { return null; } @@ -1028,8 +1164,8 @@ if (!livePair) { whisperSenderError( context.msg, - "Swap cancelled because one or both tokens are no longer available.", - "Swap Cancelled", + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled', ); return false; } @@ -1075,7 +1211,7 @@ * @returns {void} */ function sustainTravelFx(pos1, pos2, travelFx, durationMs, onComplete) { - if (travelFx === "none") { + if (travelFx === 'none') { onComplete(); return; } @@ -1113,14 +1249,22 @@ * @param {Function} onComplete Callback after animation reaches the destination. * @returns {void} */ - function animateTravel(token1, token2, pos1, pos2, durationMs, msg, onComplete) { + function animateTravel( + token1, + token2, + pos1, + pos2, + durationMs, + msg, + onComplete, + ) { if (durationMs <= 0) { onComplete(); return; } - const token1Id = token1.get("_id"); - const token2Id = token2.get("_id"); + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); // Roll20 can coalesce very frequent token updates. Use paced, fixed steps so // travel visibly spans the configured duration. const maxTickMs = 120; @@ -1138,10 +1282,13 @@ 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 }); - }) + !withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ left: nextToken1Left, top: nextToken1Top }); + liveToken2.set({ left: nextToken2Left, top: nextToken2Top }); + }, + ) ) { return; } @@ -1169,22 +1316,19 @@ * @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"); + 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 }); - })) { + 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; } @@ -1197,21 +1341,23 @@ if (!livePair) { whisperSenderError( msg, - "Swap cancelled because one or both tokens are no longer available.", - "Swap Cancelled", + 'Swap cancelled because one or both tokens are no longer available.', + 'Swap Cancelled', ); return; } - if (hasVerifiedSwapPosition(livePair.token1, livePair.token2, pos1, pos2)) { - const token1Name = getSafeTokenName(livePair.token1, "Token 1"); - const token2Name = getSafeTokenName(livePair.token2, "Token 2"); + 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", + 'Success', ); - if (typeof onVerified === "function") { + if (typeof onVerified === 'function') { onVerified(); } return; @@ -1219,7 +1365,7 @@ attempt += 1; if (attempt >= maxVerificationAttempts) { - whisperSenderError(msg, "Token swap failed verification."); + whisperSenderError(msg, 'Token swap failed verification.'); return; } @@ -1262,7 +1408,15 @@ } }; - animateTravel(token1, token2, pos1, pos2, msTravelTime, msg, finishTravelPhase); + animateTravel( + token1, + token2, + pos1, + pos2, + msTravelTime, + msg, + finishTravelPhase, + ); sustainTravelFx(pos1, pos2, travelFx, msTravelTime, finishTravelPhase); } @@ -1280,50 +1434,62 @@ msBeforeDestinationFx, } = context; const revealRenderBufferMs = 120; - const token1Id = token1.get("_id"); - const token2Id = token2.get("_id"); + const token1Id = token1.get('_id'); + const token2Id = token2.get('_id'); - const layer1 = token1.get("layer"); - const layer2 = token2.get("layer"); + const layer1 = token1.get('layer'); + const layer2 = token2.get('layer'); const revealThenFx = () => { - withLiveTokens({ token1Id, token2Id, msg }, ({ token1: liveToken1, token2: liveToken2 }) => { - // 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); - }); + 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(); - } - }); + 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" }); - }) + !withLiveTokens( + { token1Id, token2Id, msg }, + ({ token1: liveToken1, token2: liveToken2 }) => { + liveToken1.set({ layer: 'gmlayer' }); + liveToken2.set({ layer: 'gmlayer' }); + }, + ) ) { return; } @@ -1368,7 +1534,7 @@ const msTravelTime = travelTime * 1000; const msSwapDelay = swapDelay * 1000; const msBeforeDestinationFx = (destinationDelay + destinationTime) * 1000; - const useInvisibleTravel = travelMode === "invisible"; + const useInvisibleTravel = travelMode === 'invisible'; spawnPointFx(pos1.left, pos1.top, originFx, pos1.page); spawnPointFx(pos2.left, pos2.top, originFx, pos2.page); @@ -1412,28 +1578,28 @@ * @returns {void} */ function installMacro(msgObj) { - const macroName = "SwapTokens"; - const existing = findObjs({ type: "macro", name: macroName }); + const macroName = 'SwapTokens'; + const existing = findObjs({ type: 'macro', name: macroName }); if (existing.length > 0) { whisperSenderError( msgObj, `A macro named '${macroName}' already exists.`, - "Macro Exists", + 'Macro Exists', ); return; } - createObj("macro", { + createObj('macro', { name: macroName, - action: "!swap-tokens", + action: '!swap-tokens', playerid: msgObj.playerid, - isvisibleto: "all", + isvisibleto: 'all', }); whisperGMSuccess( `Global macro '${macroName}' has been created and is visible to all players.`, - "Macro Installed", + 'Macro Installed', ); } @@ -1450,12 +1616,14 @@ return true; } - const hasManagementFlag = MANAGEMENT_FLAGS.some((flag) => flag.test(msg.content)); + 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", + 'You do not have permission to use script management flags.', + 'Access Denied', ); return true; } @@ -1497,22 +1665,28 @@ if (!isGM) { whisperSenderError( msg, - "You do not have permission to set game defaults.", - "Access Denied", + 'You do not have permission to set game defaults.', + 'Access Denied', ); return false; } if (tracker.valid > 0 && tracker.invalid === 0) { Object.assign(state.SwapTokenPositions, config); - whisperGMSuccess("New defaults saved to persistent state.", "Configuration"); + whisperGMSuccess( + 'New defaults saved to persistent state.', + 'Configuration', + ); showSettings(); } else if (tracker.invalid > 0) { - whisperGMError("Settings not saved due to invalid parameters.", "Save Failed"); + whisperGMError( + 'Settings not saved due to invalid parameters.', + 'Save Failed', + ); } else { whisperGMError( - "No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.", - "Nothing to Save", + 'No settings were provided to save. Please include flags like --origin-fx or --preset along with --save.', + 'Nothing to Save', ); } return true; @@ -1525,7 +1699,7 @@ * @returns {void} */ function handleSwapTokens(msg) { - if (msg.type !== "api" || !/^!swap-tokens\b/i.test(msg.content)) { + if (msg.type !== 'api' || !/^!swap-tokens\b/i.test(msg.content)) { return; } @@ -1542,14 +1716,14 @@ const [token1, token2] = tokens; const pos1 = { - left: token1.get("left"), - top: token1.get("top"), - page: token1.get("pageid"), + left: token1.get('left'), + top: token1.get('top'), + page: token1.get('pageid'), }; const pos2 = { - left: token2.get("left"), - top: token2.get("top"), - page: token2.get("pageid"), + left: token2.get('left'), + top: token2.get('top'), + page: token2.get('pageid'), }; if (FLAG_INSTANT.test(msg.content)) { @@ -1572,14 +1746,14 @@ `Travel Time: ${config.travelTime}s`, `Swap Delay: ${config.swapDelay}s`, `Destination Delay: ${config.destinationDelay}s`, - ].join("
"); - whisperSender(msg, overrideDetails, "Override Active", "left"); + ].join('
'); + whisperSender(msg, overrideDetails, 'Override Active', 'left'); } const hasNoFx = - config.originFx === "none" && - config.travelFx === "none" && - config.destinationFx === "none"; + config.originFx === 'none' && + config.travelFx === 'none' && + config.destinationFx === 'none'; const hasNoTiming = config.originTime === 0 && config.travelTime === 0 && @@ -1600,7 +1774,7 @@ * * @returns {void} */ - on("ready", () => { + on('ready', () => { initializeState(); validateSettings(true); log( @@ -1608,9 +1782,8 @@ ); whisperGM( `MOD READY (v${SWAP_TOKEN_POSITIONS_VERSION})`, - "Script Ready", + 'Script Ready', ); - on("chat:message", handleSwapTokens); + on('chat:message', handleSwapTokens); }); - })();