diff --git a/SwapTokenPositions/2.0.0/SwapTokenPositions.js b/SwapTokenPositions/2.0.0/SwapTokenPositions.js
new file mode 100644
index 000000000..8835bd317
--- /dev/null
+++ b/SwapTokenPositions/2.0.0/SwapTokenPositions.js
@@ -0,0 +1,1789 @@
+/**
+ * NOTE: GENERATED FILE - DO NOT EDIT DIRECTLY.
+ * NOTE: Source files live under src/ and are bundled with `npm run build`.
+ * ------------------------------------------------
+ * Name: SwapTokenPositions
+ * Script: SwapTokenPositions.js
+ * Built: 2026-04-25T01:23:35.563Z
+ */
+const SwapTokenPositionsMod = (() => {
+ 'use strict';
+
+ const SCRIPT_NAME = 'SwapTokenPositions';
+ const SWAP_TOKEN_POSITIONS_VERSION = '2.0.0';
+ const SWAP_TOKEN_POSITIONS_LAST_UPDATED = '2026-04-25T01:23:35.563Z';
+ const COLOR_BG_SOFT_BLACK = '#0A0A12';
+ const COLOR_TEXT_ARCANE_SILVER = '#E6DFFF';
+ const COLOR_TEXT_DIM_SILVER = '#B8AFCF';
+ const COLOR_ACCENT_PURPLE_LIGHT = '#FF4D6D';
+ const COLOR_ACCENT_PURPLE_DARK = '#5B21B6';
+ const COLOR_HEADER_PURPLE_LIGHT = '#E9D5FF';
+
+ const COLOR_INFO_LIGHT = '#DBEAFE';
+ const COLOR_INFO_DARK = '#1E40AF';
+ const COLOR_ERROR_RED = '#D32F2F';
+ const COLOR_ERROR_DARK = '#B71C1C';
+ const COLOR_ERROR_LIGHT = '#FFCDD2';
+ const COLOR_ERROR_BG_LIGHT = '#FFEBEE';
+ const COLOR_SUCCESS_GREEN = '#2E7D32';
+ const COLOR_SUCCESS_DARK = '#1B5E20';
+ const COLOR_SUCCESS_LIGHT = '#E8F5E9';
+ const COLOR_SUCCESS_BG_LIGHT = '#F1F5FE';
+
+ const TIME_MIN = 0;
+ const TIME_MAX = 10;
+ const DELAY_MIN = 0;
+ const DELAY_MAX = 10;
+
+ const ALLOWED_TRAVEL_FX = [
+ 'none',
+ 'beam-magic',
+ 'beam-acid',
+ 'beam-charm',
+ 'beam-fire',
+ 'beam-frost',
+ 'beam-holy',
+ 'beam-death',
+ 'beam-energy',
+ 'beam-lightning',
+ ];
+
+ const ALLOWED_TRAVEL_MODES = ['normal', 'invisible'];
+
+ const ALLOWED_POINT_FX = [
+ 'none',
+ 'nova-magic',
+ 'nova-acid',
+ 'nova-charm',
+ 'nova-fire',
+ 'nova-frost',
+ 'nova-holy',
+ 'nova-death',
+ 'burst-magic',
+ 'burst-acid',
+ 'burst-charm',
+ 'burst-fire',
+ 'burst-frost',
+ 'burst-holy',
+ 'burst-death',
+ 'burst-energy',
+ 'burst-smoke',
+ 'explode-magic',
+ 'explode-acid',
+ 'explode-charm',
+ 'explode-fire',
+ 'explode-frost',
+ 'explode-holy',
+ 'explode-death',
+ 'burn-magic',
+ 'burn-acid',
+ 'burn-charm',
+ 'burn-fire',
+ 'burn-frost',
+ 'burn-holy',
+ 'burn-death',
+ 'splatter-magic',
+ 'splatter-acid',
+ 'splatter-charm',
+ 'splatter-fire',
+ 'splatter-frost',
+ 'splatter-holy',
+ 'splatter-death',
+ 'splatter-dark',
+ 'glow-magic',
+ 'glow-acid',
+ 'glow-charm',
+ 'glow-fire',
+ 'glow-frost',
+ 'glow-holy',
+ 'glow-death',
+ ];
+
+ const FX_PRESETS = {
+ portal: {
+ originFx: 'nova-magic',
+ travelFx: 'beam-magic',
+ destinationFx: 'burst-holy',
+ originTime: 1,
+ travelTime: 1,
+ destinationTime: 0.5,
+ swapDelay: 0.5,
+ destinationDelay: 1,
+ travelMode: 'normal',
+ },
+ lightning: {
+ originFx: 'none',
+ travelFx: 'beam-holy',
+ destinationFx: 'burst-holy',
+ originTime: 0,
+ travelTime: 0.3,
+ destinationTime: 0,
+ swapDelay: 0,
+ destinationDelay: 0.3,
+ travelMode: 'normal',
+ },
+ shadow: {
+ originFx: 'burst-smoke',
+ travelFx: 'none',
+ destinationFx: 'burst-smoke',
+ originTime: 0.5,
+ travelTime: 0,
+ destinationTime: 0,
+ swapDelay: 0.5,
+ destinationDelay: 0.5,
+ travelMode: 'normal',
+ },
+ fire: {
+ originFx: 'explode-fire',
+ travelFx: 'none',
+ destinationFx: 'explode-fire',
+ originTime: 0.5,
+ travelTime: 0,
+ destinationTime: 0,
+ swapDelay: 0.5,
+ destinationDelay: 0.5,
+ travelMode: 'normal',
+ },
+ magic: {
+ originFx: 'nova-magic',
+ travelFx: 'none',
+ destinationFx: 'burst-magic',
+ originTime: 0.5,
+ travelTime: 0,
+ destinationTime: 0,
+ swapDelay: 0.5,
+ destinationDelay: 0.5,
+ travelMode: 'normal',
+ },
+ transport: {
+ originFx: 'glow-magic',
+ travelFx: 'none',
+ destinationFx: 'glow-magic',
+ originTime: 0.55,
+ travelTime: 0,
+ destinationTime: 0,
+ swapDelay: 0.15,
+ destinationDelay: 0.05,
+ travelMode: 'invisible',
+ },
+ none: {
+ originFx: 'none',
+ travelFx: 'none',
+ destinationFx: 'none',
+ originTime: 0,
+ travelTime: 0,
+ destinationTime: 0,
+ swapDelay: 0,
+ destinationDelay: 0,
+ travelMode: 'normal',
+ },
+ };
+
+ const ALLOWED_PRESETS = Object.keys(FX_PRESETS);
+
+ const FACTORY_DEFAULTS = {
+ originFx: 'none',
+ travelFx: 'none',
+ destinationFx: 'none',
+ originTime: 0,
+ travelTime: 0,
+ destinationTime: 0,
+ swapDelay: 0,
+ destinationDelay: 0,
+ travelMode: 'normal',
+ };
+
+ const FLAG_HELP = /--help\b/i;
+ const FLAG_SHOW_SETTINGS = /--show-settings\b/i;
+ const FLAG_CHECK_SETTINGS = /--check-settings\b/i;
+ const FLAG_RESET_SETTINGS = /--reset-settings\b/i;
+ const FLAG_SAVE = /--save\b/i;
+ const FLAG_INSTALL_MACRO = /--install-macro\b/i;
+
+ const FLAG_INSTANT = /--instant\b/i;
+ const FLAG_PRESET = /--preset\b/i;
+ const FLAG_ORIGIN_FX = /--origin-fx\b/i;
+ const FLAG_TRAVEL_FX = /--travel-fx\b/i;
+ const FLAG_DESTINATION_FX = /--destination-fx\b/i;
+ const FLAG_ORIGIN_TIME = /--origin-time\b/i;
+ const FLAG_TRAVEL_TIME = /--travel-time\b/i;
+ const FLAG_TRAVEL_MODE = /--travel-mode\b/i;
+ const FLAG_DESTINATION_TIME = /--destination-time\b/i;
+ const FLAG_SWAP_DELAY = /--swap-delay\b/i;
+ const FLAG_DESTINATION_DELAY = /--destination-delay\b/i;
+
+ const FLAG_LEGACY_BEAM_FX = /--beam-fx\b/i;
+ const FLAG_LEGACY_BURST_FX = /--burst-fx\b/i;
+ const FLAG_LEGACY_DURATION = /--duration\b/i;
+ const FLAG_LEGACY_MODE = /--mode\b/i;
+
+ const MANAGEMENT_FLAGS = [
+ FLAG_SHOW_SETTINGS,
+ FLAG_CHECK_SETTINGS,
+ FLAG_RESET_SETTINGS,
+ FLAG_INSTALL_MACRO,
+ ];
+
+ const SILENT_MANAGEMENT_FLAGS = [
+ FLAG_HELP,
+ FLAG_SHOW_SETTINGS,
+ FLAG_CHECK_SETTINGS,
+ FLAG_RESET_SETTINGS,
+ FLAG_INSTALL_MACRO,
+ ];
+
+ /**
+ * Escapes HTML-sensitive characters for safe chat rendering.
+ *
+ * @param {string} value Text to escape.
+ * @returns {string} Escaped text.
+ */
+ function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+ }
+
+ /**
+ * Builds a safe display name for a token in chat output.
+ *
+ * @param {object} token Roll20 graphic token object.
+ * @param {string} fallback Fallback label when token has no name.
+ * @returns {string} Escaped token display name.
+ */
+ function getSafeTokenName(token, fallback) {
+ const name = token.get('name');
+ return escapeHtml(name?.trim() ? name : fallback);
+ }
+
+ /**
+ * Builds the standard styled chat message container.
+ *
+ * @param {string} msg Message body as HTML.
+ * @param {"left"|"center"|"right"} [align="center"] Content alignment.
+ * @param {string} [header=""] Optional header label.
+ * @returns {string} HTML for a styled chat card.
+ */
+ function generateStyledMessage(msg, align = 'center', header = '') {
+ const padding = align === 'center' ? '3px 0px' : '3px 8px';
+ const isScriptReadyHeader = header === 'Script Ready';
+ const headerBackground = isScriptReadyHeader
+ ? COLOR_HEADER_PURPLE_LIGHT
+ : COLOR_INFO_LIGHT;
+ const headerTextColor = isScriptReadyHeader
+ ? COLOR_BG_SOFT_BLACK
+ : COLOR_INFO_DARK;
+ const headerLabel = isScriptReadyHeader
+ ? `đ ${header} đ`
+ : `âšī¸ ${header}`;
+ const mainStyle = [
+ 'width:100%',
+ 'border-radius:4px',
+ `box-shadow:1px 1px 1px ${COLOR_TEXT_DIM_SILVER}`,
+ `text-align:${align}`,
+ 'vertical-align:middle',
+ 'margin:0px auto',
+ `border:1px solid ${COLOR_BG_SOFT_BLACK}`,
+ `color:${COLOR_TEXT_ARCANE_SILVER}`,
+ `background-image:-webkit-linear-gradient(-45deg,${COLOR_ACCENT_PURPLE_DARK} 0%,${COLOR_ACCENT_PURPLE_LIGHT} 100%)`,
+ 'overflow:hidden',
+ ].join(';');
+
+ const headerHtml = header
+ ? `
${headerLabel}
`
+ : '';
+ const contentHtml = `${msg}
`;
+
+ return `${headerHtml}${contentHtml}
`;
+ }
+
+ /**
+ * Builds a red error variant of the styled chat container.
+ *
+ * @param {string} msg Error body as HTML.
+ * @param {string} [header="Error"] Optional header label.
+ * @param {"left"|"center"|"right"} [align="left"] Content alignment.
+ * @returns {string} HTML for an error-styled chat card.
+ */
+ function generateStyledErrorMessage(msg, header = 'Error', align = 'left') {
+ const mainStyle = [
+ 'width:100%',
+ 'border-radius:4px',
+ `box-shadow:1px 1px 1px ${COLOR_ERROR_RED}`,
+ `text-align:${align}`,
+ 'vertical-align:middle',
+ 'margin:0px auto',
+ `border:1px solid ${COLOR_ERROR_DARK}`,
+ `color:${COLOR_ERROR_LIGHT}`,
+ `background-color:${COLOR_ERROR_DARK}`,
+ `background-image:-webkit-linear-gradient(-45deg,${COLOR_ERROR_DARK} 0%,${COLOR_ERROR_RED} 100%)`,
+ 'overflow:hidden',
+ ].join(';');
+
+ const headerHtml = `â ī¸ ${header}
`;
+ const contentHtml = `${msg}
`;
+
+ return `${headerHtml}${contentHtml}
`;
+ }
+
+ /**
+ * Builds a green success variant of the styled chat container.
+ *
+ * @param {string} msg Success body as HTML.
+ * @param {string} [header="Success"] Optional header label.
+ * @returns {string} HTML for a success-styled chat card.
+ */
+ function generateStyledSuccessMessage(msg, header = 'Success') {
+ const mainStyle = [
+ 'width:100%',
+ 'border-radius:4px',
+ `box-shadow:1px 1px 1px ${COLOR_SUCCESS_GREEN}`,
+ 'text-align:center',
+ 'vertical-align:middle',
+ 'margin:0px auto',
+ `border:1px solid ${COLOR_SUCCESS_DARK}`,
+ `color:${COLOR_SUCCESS_LIGHT}`,
+ `background-image:-webkit-linear-gradient(-45deg,${COLOR_SUCCESS_DARK} 0%,${COLOR_SUCCESS_GREEN} 100%)`,
+ 'overflow:hidden',
+ ].join(';');
+
+ const headerHtml = `â
${header}
`;
+ const contentHtml = `${msg}
`;
+
+ return `${headerHtml}${contentHtml}
`;
+ }
+
+ /**
+ * Whispers a styled message card to the GM.
+ *
+ * @param {string} msg Message body as HTML.
+ * @param {string} [header=""] Optional header label.
+ * @param {"left"|"center"|"right"} [align="center"] Content alignment.
+ * @returns {void}
+ */
+ function whisperGM(msg, header = '', align = 'center') {
+ sendChat(SCRIPT_NAME, `/w GM ${generateStyledMessage(msg, align, header)}`);
+ }
+
+ /**
+ * Whispers a styled message card to the user that sent the command.
+ *
+ * @param {object} msgObj Roll20 chat message object.
+ * @param {string} text Message body as HTML.
+ * @param {string} [header=""] Optional header label.
+ * @param {"left"|"center"|"right"} [align="center"] Content alignment.
+ * @returns {void}
+ */
+ function whisperSender(msgObj, text, header = '', align = 'center') {
+ const player = getObj('player', msgObj.playerid);
+ const name = player ? player.get('_displayname') : msgObj.who;
+ sendChat(
+ SCRIPT_NAME,
+ `/w "${name}" ${generateStyledMessage(text, align, header)}`,
+ );
+ }
+
+ /**
+ * Whispers an error-styled message card to the user that sent the command.
+ *
+ * @param {object} msgObj Roll20 chat message object.
+ * @param {string} text Error body as HTML.
+ * @param {string} [header="Error"] Optional header label.
+ * @param {"left"|"center"|"right"} [align="left"] Content alignment.
+ * @returns {void}
+ */
+ function whisperSenderError(msgObj, text, header = 'Error', align = 'left') {
+ const player = getObj('player', msgObj.playerid);
+ const name = player ? player.get('_displayname') : msgObj.who;
+ sendChat(
+ SCRIPT_NAME,
+ `/w "${name}" ${generateStyledErrorMessage(text, header, align)}`,
+ );
+ }
+
+ /**
+ * Whispers a success-styled message card to the GM.
+ *
+ * @param {string} text Success body as HTML.
+ * @param {string} [header="Success"] Optional header label.
+ * @returns {void}
+ */
+ function whisperGMSuccess(text, header = 'Success') {
+ sendChat(
+ SCRIPT_NAME,
+ `/w GM ${generateStyledSuccessMessage(text, header)}`,
+ );
+ }
+
+ /**
+ * Whispers an error-styled message card to the GM.
+ *
+ * @param {string} text Error body as HTML.
+ * @param {string} [header="Error"] Optional header label.
+ * @param {"left"|"center"|"right"} [align="left"] Content alignment.
+ * @returns {void}
+ */
+ function whisperGMError(text, header = 'Error', align = 'left') {
+ sendChat(
+ SCRIPT_NAME,
+ `/w GM ${generateStyledErrorMessage(text, header, align)}`,
+ );
+ }
+
+ /**
+ * Parses a string flag and validates it against an allowed set.
+ *
+ * @param {string} content Full command content.
+ * @param {RegExp} flagRegex Regex for the flag name.
+ * @param {string[]} allowedValues Allowed lower-case values.
+ * @returns {{found:boolean, valid:boolean, value:(string|null)}} Parse result.
+ */
+ function parseStringFlag(content, flagRegex, allowedValues) {
+ const match = new RegExp(String.raw`${flagRegex.source}\s+(\S+)`, 'i').exec(
+ content,
+ );
+ if (!match) {
+ return { found: false, valid: false, value: null };
+ }
+ const normalized = match[1]
+ .trim()
+ .replaceAll(/(^['"]|['"]$)/g, '')
+ .replaceAll(/[.,;]+$/g, '')
+ .toLowerCase();
+ if (allowedValues.includes(normalized)) {
+ return { found: true, valid: true, value: normalized };
+ }
+ return { found: true, valid: false, value: match[1] };
+ }
+
+ /**
+ * Parses a numeric flag and validates it against an inclusive range.
+ *
+ * @param {string} content Full command content.
+ * @param {RegExp} flagRegex Regex for the flag name.
+ * @param {number} min Minimum allowed value.
+ * @param {number} max Maximum allowed value.
+ * @returns {{found:boolean, valid:boolean, value:(number|null)}} Parse result.
+ */
+ function parseFloatFlag(content, flagRegex, min, max) {
+ const match = new RegExp(
+ String.raw`${flagRegex.source}\s+([\d.]+)`,
+ 'i',
+ ).exec(content);
+ if (!match) {
+ return { found: false, valid: false, value: null };
+ }
+ const value = Number.parseFloat(match[1]);
+ if (!Number.isNaN(value) && value >= min && value <= max) {
+ return { found: true, valid: true, value };
+ }
+ return { found: true, valid: false, value: null };
+ }
+
+ /**
+ * Applies a parsed string flag result to config and update tracking.
+ *
+ * @param {{found:boolean, valid:boolean, value:(string|null)}} result Parse result.
+ * @param {string} key Config key to set.
+ * @param {object} config Mutable config object.
+ * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters.
+ * @param {object} msg Roll20 chat message object.
+ * @param {string} errorMsg Error message shown when invalid.
+ * @returns {void}
+ */
+ function applyStringFlagResult(
+ result,
+ key,
+ config,
+ updateTracker,
+ msg,
+ errorMsg,
+ ) {
+ if (result.valid) {
+ config[key] = result.value;
+ updateTracker.valid++;
+ } else {
+ updateTracker.invalid++;
+ whisperSenderError(msg, errorMsg, 'Invalid Input');
+ }
+ }
+
+ /**
+ * Applies a parsed numeric flag result to config and update tracking.
+ *
+ * @param {{found:boolean, valid:boolean, value:(number|null)}} result Parse result.
+ * @param {string} key Config key to set.
+ * @param {object} config Mutable config object.
+ * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters.
+ * @param {object} msg Roll20 chat message object.
+ * @param {string} label Human-readable field label.
+ * @param {{min:number,max:number}} range Allowed numeric range.
+ * @returns {void}
+ */
+ function applyNumericFlagResult(
+ result,
+ key,
+ config,
+ updateTracker,
+ msg,
+ label,
+ range,
+ ) {
+ if (result.valid) {
+ config[key] = result.value;
+ updateTracker.valid++;
+ } else {
+ updateTracker.invalid++;
+ whisperSenderError(
+ msg,
+ `Invalid ${label}: must be between ${range.min} and ${range.max} seconds.`,
+ 'Invalid Input',
+ );
+ }
+ }
+
+ /**
+ * Parses and applies a collection of string flags.
+ *
+ * @param {string} content Full command content.
+ * @param {Array<{flag:RegExp,key:string,allowed:string[],label:string}>} flagConfigs Flag specs.
+ * @param {object} config Mutable config object.
+ * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters.
+ * @param {object} msg Roll20 chat message object.
+ * @returns {void}
+ */
+ function processStringFlags(
+ content,
+ flagConfigs,
+ config,
+ updateTracker,
+ msg,
+ ) {
+ for (const { flag, key, allowed, label } of flagConfigs) {
+ const result = parseStringFlag(content, flag, allowed);
+ if (!result.found) {
+ continue;
+ }
+ const errorMsg = `Invalid ${label}: '${result.value}'.
Valid: ${allowed.join(', ')}`;
+ applyStringFlagResult(result, key, config, updateTracker, msg, errorMsg);
+ }
+ }
+
+ /**
+ * Parses and applies a collection of numeric flags.
+ *
+ * @param {string} content Full command content.
+ * @param {Array<{flag:RegExp,key:string,label:string,min:number,max:number}>} flagConfigs Flag specs.
+ * @param {(content:string, flagRegex:RegExp, min:number, max:number)=>{found:boolean, valid:boolean, value:(number|null)}} parseFunc Numeric parser.
+ * @param {object} config Mutable config object.
+ * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters.
+ * @param {object} msg Roll20 chat message object.
+ * @returns {void}
+ */
+ function processNumericFlags(
+ content,
+ flagConfigs,
+ parseFunc,
+ config,
+ updateTracker,
+ msg,
+ ) {
+ for (const { flag, key, label, min, max } of flagConfigs) {
+ const result = parseFunc(content, flag, min, max);
+ if (!result.found) {
+ continue;
+ }
+ applyNumericFlagResult(result, key, config, updateTracker, msg, label, {
+ min,
+ max,
+ });
+ }
+ }
+
+ /**
+ * Ensures persisted script settings exist and backfills missing keys with defaults.
+ *
+ * @returns {void}
+ */
+ function initializeState() {
+ if (!state.SwapTokenPositions) {
+ state.SwapTokenPositions = {};
+ }
+ for (const [key, value] of Object.entries(FACTORY_DEFAULTS)) {
+ if (state.SwapTokenPositions[key] === undefined) {
+ state.SwapTokenPositions[key] = value;
+ }
+ }
+ }
+
+ /**
+ * Retrieves persisted script settings from Roll20 state.
+ *
+ * @returns {object} Effective script settings object.
+ */
+ function getSettings() {
+ return state.SwapTokenPositions;
+ }
+
+ /**
+ * Renders the current persisted settings to GM chat.
+ *
+ * @returns {void}
+ */
+ function showSettings() {
+ const settings = getSettings();
+ const settingsMsg = [
+ `Origin FX: ${settings.originFx}
`,
+ `Travel FX: ${settings.travelFx}
`,
+ `Travel Mode: ${settings.travelMode}
`,
+ `Destination FX: ${settings.destinationFx}
`,
+ `Origin Time: ${settings.originTime}s
`,
+ `Travel Time: ${settings.travelTime}s
`,
+ `Destination Time: ${settings.destinationTime}s
`,
+ `Swap Delay: ${settings.swapDelay}s
`,
+ `Destination Delay: ${settings.destinationDelay}s
`,
+ ].join('');
+ whisperGM(settingsMsg, 'Persistent Settings', 'left');
+ }
+
+ /**
+ * Resets persisted script settings to factory defaults.
+ *
+ * @returns {void}
+ */
+ function resetSettings() {
+ state.SwapTokenPositions = { ...FACTORY_DEFAULTS };
+ whisperGM(
+ 'Settings reset to factory defaults.',
+ 'Settings Reset',
+ );
+ showSettings();
+ }
+
+ /**
+ * Validates persisted settings for supported FX values and timing ranges.
+ *
+ * @param {boolean} [silentOnSuccess=false] When true, success output is suppressed.
+ * @returns {boolean} True when settings are valid; otherwise false.
+ */
+ function validateSettings(silentOnSuccess = false) {
+ const settings = getSettings();
+ const errors = [];
+
+ if (!ALLOWED_POINT_FX.includes(settings.originFx)) {
+ errors.push(`Origin FX '${settings.originFx}' is no longer valid.`);
+ }
+ if (!ALLOWED_TRAVEL_FX.includes(settings.travelFx)) {
+ errors.push(`Travel FX '${settings.travelFx}' is no longer valid.`);
+ }
+ if (!ALLOWED_TRAVEL_MODES.includes(settings.travelMode)) {
+ errors.push(`Travel Mode '${settings.travelMode}' is no longer valid.`);
+ }
+ if (!ALLOWED_POINT_FX.includes(settings.destinationFx)) {
+ errors.push(
+ `Destination FX '${settings.destinationFx}' is no longer valid.`,
+ );
+ }
+
+ const timingFields = [
+ { key: 'originTime', label: 'Origin Time', min: TIME_MIN, max: TIME_MAX },
+ { key: 'travelTime', label: 'Travel Time', min: TIME_MIN, max: TIME_MAX },
+ {
+ key: 'destinationTime',
+ label: 'Destination Time',
+ min: TIME_MIN,
+ max: TIME_MAX,
+ },
+ { key: 'swapDelay', label: 'Swap Delay', min: DELAY_MIN, max: DELAY_MAX },
+ {
+ key: 'destinationDelay',
+ label: 'Destination Delay',
+ min: DELAY_MIN,
+ max: DELAY_MAX,
+ },
+ ];
+
+ for (const { key, label, min, max } of timingFields) {
+ const value = settings[key];
+ if (typeof value !== 'number' || value < min || value > max) {
+ errors.push(`${label} (${value}) is out of range (${min}-${max}).`);
+ }
+ }
+
+ if (errors.length > 0) {
+ const errorMsg = [
+ 'Validation Issues Found:
',
+ errors.map((error) => `• ${error}`).join('
'),
+ '
Try running !swap-tokens --reset-settings to fix these issues.',
+ ].join('');
+ whisperGMError(errorMsg, 'Settings Validation');
+ return false;
+ }
+
+ if (!silentOnSuccess) {
+ whisperGMSuccess(
+ 'All persistent settings are valid.',
+ 'Settings Validation',
+ );
+ }
+ return true;
+ }
+
+ /**
+ * Applies deprecated flags to the active config while emitting compatibility warnings.
+ *
+ * @param {object} msg Roll20 chat message object.
+ * @param {object} config Mutable config object.
+ * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters.
+ * @returns {void}
+ */
+ function applyLegacyFlags(msg, config, updateTracker) {
+ const content = msg.content;
+ const legacyModeToPreset = {
+ beams: 'lightning',
+ transport: 'transport',
+ };
+
+ const modeResult = parseStringFlag(
+ content,
+ FLAG_LEGACY_MODE,
+ Object.keys(legacyModeToPreset),
+ );
+
+ if (modeResult.found) {
+ if (modeResult.valid) {
+ const mappedPreset = legacyModeToPreset[modeResult.value];
+ whisperSender(
+ msg,
+ `--mode is deprecated. Use --preset ${mappedPreset} instead.`,
+ 'Deprecated Flag',
+ 'left',
+ );
+ Object.assign(config, FX_PRESETS[mappedPreset]);
+ updateTracker.valid++;
+ } else {
+ updateTracker.invalid++;
+ whisperSenderError(
+ msg,
+ `Invalid value for deprecated --mode: '${modeResult.value}'.
Valid: ${Object.keys(legacyModeToPreset).join(', ')}`,
+ 'Invalid Input',
+ );
+ }
+ }
+
+ const fxMappings = [
+ {
+ flag: FLAG_LEGACY_BEAM_FX,
+ key: 'travelFx',
+ allowed: ALLOWED_TRAVEL_FX,
+ oldName: '--beam-fx',
+ newName: '--travel-fx',
+ },
+ {
+ flag: FLAG_LEGACY_BURST_FX,
+ key: 'destinationFx',
+ allowed: ALLOWED_POINT_FX,
+ oldName: '--burst-fx',
+ newName: '--destination-fx',
+ },
+ ];
+
+ for (const { flag, key, allowed, oldName, newName } of fxMappings) {
+ const result = parseStringFlag(content, flag, allowed);
+ if (!result.found) {
+ continue;
+ }
+ whisperSender(
+ msg,
+ `${oldName} is deprecated. Use ${newName} instead.`,
+ 'Deprecated Flag',
+ 'left',
+ );
+ if (result.valid) {
+ config[key] = result.value;
+ updateTracker.valid++;
+ } else {
+ updateTracker.invalid++;
+ whisperSenderError(
+ msg,
+ `Invalid value for deprecated ${oldName}: '${result.value}'.
Valid: ${allowed.join(', ')}`,
+ 'Invalid Input',
+ );
+ }
+ }
+
+ const durationResult = parseFloatFlag(
+ content,
+ FLAG_LEGACY_DURATION,
+ DELAY_MIN,
+ DELAY_MAX,
+ );
+ if (durationResult.found) {
+ whisperSender(
+ msg,
+ '--duration is deprecated. Use --swap-delay instead.',
+ 'Deprecated Flag',
+ 'left',
+ );
+ if (durationResult.valid) {
+ config.swapDelay = durationResult.value;
+ updateTracker.valid++;
+ } else {
+ updateTracker.invalid++;
+ whisperSenderError(
+ msg,
+ `Invalid value for deprecated --duration: must be between ${DELAY_MIN} and ${DELAY_MAX} seconds.`,
+ 'Invalid Input',
+ );
+ }
+ }
+ }
+
+ /**
+ * Applies a preset configuration layer when the preset flag is present.
+ *
+ * @param {object} msg Roll20 chat message object.
+ * @param {string} content Full command content.
+ * @param {object} config Mutable config object.
+ * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters.
+ * @returns {void}
+ */
+ function applyPresetLayer(msg, content, config, updateTracker) {
+ const presetResult = parseStringFlag(content, FLAG_PRESET, ALLOWED_PRESETS);
+ if (!presetResult.found) {
+ return;
+ }
+ if (presetResult.valid) {
+ Object.assign(config, FX_PRESETS[presetResult.value]);
+ updateTracker.valid++;
+ } else {
+ updateTracker.invalid++;
+ whisperSenderError(
+ msg,
+ `Invalid preset: '${presetResult.value}'.
Valid presets: ${ALLOWED_PRESETS.join(', ')}`,
+ 'Invalid Input',
+ );
+ }
+ }
+
+ /**
+ * Builds the final swap configuration by layering settings, preset, and explicit flags.
+ *
+ * @param {object} msg Roll20 chat message object.
+ * @param {{valid:number, invalid:number}} updateTracker Valid/invalid counters.
+ * @returns {object} Effective swap configuration.
+ */
+ function buildSwapConfig(msg, updateTracker) {
+ const content = msg.content;
+ const config = { ...getSettings() };
+
+ applyPresetLayer(msg, content, config, updateTracker);
+ applyLegacyFlags(msg, config, updateTracker);
+
+ const fxFlags = [
+ {
+ flag: FLAG_ORIGIN_FX,
+ key: 'originFx',
+ allowed: ALLOWED_POINT_FX,
+ label: 'Origin FX',
+ },
+ {
+ flag: FLAG_TRAVEL_FX,
+ key: 'travelFx',
+ allowed: ALLOWED_TRAVEL_FX,
+ label: 'Travel FX',
+ },
+ {
+ flag: FLAG_TRAVEL_MODE,
+ key: 'travelMode',
+ allowed: ALLOWED_TRAVEL_MODES,
+ label: 'Travel Mode',
+ },
+ {
+ flag: FLAG_DESTINATION_FX,
+ key: 'destinationFx',
+ allowed: ALLOWED_POINT_FX,
+ label: 'Destination FX',
+ },
+ ];
+ processStringFlags(content, fxFlags, config, updateTracker, msg);
+
+ const timeFlags = [
+ {
+ flag: FLAG_ORIGIN_TIME,
+ key: 'originTime',
+ label: 'Origin Time',
+ min: TIME_MIN,
+ max: TIME_MAX,
+ },
+ {
+ flag: FLAG_TRAVEL_TIME,
+ key: 'travelTime',
+ label: 'Travel Time',
+ min: TIME_MIN,
+ max: TIME_MAX,
+ },
+ {
+ flag: FLAG_DESTINATION_TIME,
+ key: 'destinationTime',
+ label: 'Destination Time',
+ min: TIME_MIN,
+ max: TIME_MAX,
+ },
+ ];
+ processNumericFlags(
+ content,
+ timeFlags,
+ parseFloatFlag,
+ config,
+ updateTracker,
+ msg,
+ );
+
+ const delayFlags = [
+ {
+ flag: FLAG_SWAP_DELAY,
+ key: 'swapDelay',
+ label: 'Swap Delay',
+ min: DELAY_MIN,
+ max: DELAY_MAX,
+ },
+ {
+ flag: FLAG_DESTINATION_DELAY,
+ key: 'destinationDelay',
+ label: 'Destination Delay',
+ min: DELAY_MIN,
+ max: DELAY_MAX,
+ },
+ ];
+ processNumericFlags(
+ content,
+ delayFlags,
+ parseFloatFlag,
+ config,
+ updateTracker,
+ msg,
+ );
+
+ return config;
+ }
+
+ /**
+ * Sends full command and option help text to the invoking player.
+ *
+ * @param {object} msgObj Roll20 chat message object.
+ * @returns {void}
+ */
+ function showHelp(msgObj) {
+ const helpMsg = [
+ `SwapTokenPositions v${SWAP_TOKEN_POSITIONS_VERSION}
`,
+ `Last Updated: ${SWAP_TOKEN_POSITIONS_LAST_UPDATED}
`,
+ '
Basic Usage:
',
+ '!swap-tokens — Instant swap of 2 selected tokens.
',
+ '!swap-tokens --instant — Force instant swap, ignoring all FX and timing.
',
+ '!swap-tokens --help — Show this help message (available to all players).
',
+ '
FX Stages:
',
+ 'Pipeline order: Origin FX → Travel FX → Swap → Destination FX.
',
+ '--origin-fx <type> — FX at both original positions before movement.
',
+ '--travel-fx <type> — FX between tokens during transition.
',
+ '--travel-mode <normal|invisible> — Keep tokens visible during travel or hide them until reveal.
',
+ '--destination-fx <type> — FX at both new positions after swap.
',
+ '
Stage Timing:
',
+ `--origin-time <${TIME_MIN}-${TIME_MAX}> — Wait (s) after Origin FX before continuing.
`,
+ `--travel-time <${TIME_MIN}-${TIME_MAX}> — Duration (s) of the travel animation stage.
`,
+ `--destination-time <${TIME_MIN}-${TIME_MAX}> — Additional wait (s) before Destination FX is shown.
`,
+ '
Delays:
',
+ `--swap-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause between Origin and Travel stages.
`,
+ `--destination-delay <${DELAY_MIN}-${DELAY_MAX}> — Additional pause before Destination FX is shown.
`,
+ '
Presets:
',
+ `--preset <name> — Apply a preset. Valid: ${ALLOWED_PRESETS.join(', ')}
`,
+ '• portal — Magical portal teleport (nova, beam, burst).
',
+ '• lightning — Fast lightning strike (beam, burst).
',
+ '• shadow — Dark shadow blink (splatter, no travel FX).
',
+ '• fire — Fiery explosion swap (explode, no travel FX).
',
+ '• magic — Arcane sparkle swap (nova, burst).
',
+ '• transport — Starship transport shimmer (invisible travel reveal).
',
+ '• none — No FX, equivalent to instant mode.
',
+ 'Explicit flags override preset values. Example: --preset portal --travel-time 3
',
+ '
Global Configuration (GM Only):
',
+ '--save — Commit provided flags as the new global defaults.
',
+ '--show-settings — View current persistent defaults.
',
+ '--reset-settings — Restore all factory defaults.
',
+ "--install-macro — Create a global 'SwapTokens' macro.
",
+ '
Examples:
',
+ '!swap-tokens
',
+ '!swap-tokens --preset portal
',
+ '!swap-tokens --preset transport
',
+ '!swap-tokens --preset portal --travel-time 3
',
+ '!swap-tokens --origin-fx nova-magic --swap-delay 1 --destination-fx burst-holy
',
+ '!swap-tokens --preset lightning --save
',
+ ].join('');
+
+ whisperSender(msgObj, helpMsg, 'SwapTokenPositions Help', 'left');
+ }
+
+ /**
+ * Spawns a point FX on a page when enabled.
+ *
+ * @param {number} x X coordinate.
+ * @param {number} y Y coordinate.
+ * @param {string} fxType Roll20 FX type.
+ * @param {string} pageId Roll20 page id.
+ * @returns {void}
+ */
+ function spawnPointFx(x, y, fxType, pageId) {
+ if (fxType === 'none') {
+ return;
+ }
+ try {
+ spawnFx(x, y, fxType, pageId);
+ } catch (error) {
+ log(
+ `SwapTokenPositions: Point FX failed, but swap will continue: ${error.message}`,
+ );
+ }
+ }
+
+ /**
+ * Spawns travel FX between two positions when enabled.
+ *
+ * @param {{left:number, top:number, page:string}} pos1 Source position.
+ * @param {{left:number, top:number, page:string}} pos2 Destination position.
+ * @param {string} fxType Roll20 FX type.
+ * @returns {void}
+ */
+ function spawnTravelFx(pos1, pos2, fxType) {
+ if (fxType === 'none') {
+ return;
+ }
+ try {
+ spawnFxBetweenPoints(
+ { x: pos1.left, y: pos1.top, pageid: pos1.page },
+ { x: pos2.left, y: pos2.top, pageid: pos2.page },
+ fxType,
+ );
+ } catch (error) {
+ log(
+ `SwapTokenPositions: Travel FX failed, but swap will continue: ${error.message}`,
+ );
+ }
+ }
+
+ /**
+ * Validates selection and resolves the two tokens targeted for swapping.
+ *
+ * @param {object} msg Roll20 chat message object.
+ * @returns {Array