From d83671508b96731328ce4dd945286749c75932c4 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Thu, 29 Oct 2026 06:24:21 +0000 Subject: [PATCH 01/14] fix(TokenController): Restore the 4.0.0 and 4.0.1 version folders script.json listed 4.0.1 as the current version, but no 4.0.1 folder was ever added, so One-Click installs of it failed. The 4.0.0 folder had also been overwritten with later 4.x code. Restore 4.0.0/ to the code released as 4.0.0 (e53c26bf) and add 4.0.1/ with the code released as 4.0.1 (72dcd80b), so every listed version installs what it says. Signed-off-by: Steve Roberts --- TokenController/4.0.0/TokenController.js | 90 +- TokenController/4.0.1/TokenController.js | 2264 ++++++++++++++++++++++ 2 files changed, 2307 insertions(+), 47 deletions(-) create mode 100644 TokenController/4.0.1/TokenController.js diff --git a/TokenController/4.0.0/TokenController.js b/TokenController/4.0.0/TokenController.js index d8c2130df8..4c1f72218f 100644 --- a/TokenController/4.0.0/TokenController.js +++ b/TokenController/4.0.0/TokenController.js @@ -69,8 +69,8 @@ API_Meta.TokenController = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; const TokenController = (() => { const NAME = 'TokenController'; - const VERSION = '4.0.1'; - const AUTHOR = 'Developesque'; + const VERSION = '4.2.0'; + const AUTHOR = 'Scott E. Schwarz'; let errorMessage = ""; let listingVars = false; let combatStarted = false; @@ -229,14 +229,10 @@ const TokenController = (() => { log(`${NAME} ${VERSION} by ${AUTHOR} Ready Meta Offset : ${API_Meta.TokenController.offset}`); createMenu(); - - on('chat:message', chatHandler); - on('change:graphic', graphicHandler); - on('change:campaign:turnorder', turnorderHandler); }); // Token Destruction - const graphicHandler = function (obj, prev) { + on("change:graphic", function (obj, prev) { if (obj.get("_subtype") !== "token") { return; } @@ -269,47 +265,10 @@ const TokenController = (() => { rotation: obj.get('rotation') }); }); - }; - - // When Roll20 combat starts - const turnorderHandler = function (campaign, prev) { - const rawCurrent = campaign.get('turnorder'); - const rawPrev = (prev && typeof prev.get === 'function') ? prev.get('turnorder') : prev; - let current = []; - let prevOrder = []; - try { - current = typeof rawCurrent === 'string' ? (JSON.parse(rawCurrent || '[]') || []) : (Array.isArray(rawCurrent) ? rawCurrent : []); - } catch (e) { current = []; } - try { - prevOrder = typeof rawPrev === 'string' ? (JSON.parse(rawPrev || '[]') || []) : (Array.isArray(rawPrev) ? rawPrev : []); - } catch (e) { prevOrder = []; } - const wasEmpty = !prevOrder || prevOrder.length === 0; - const isNowActive = current && current.length > 0; - - if (isNowActive && current[0] && typeof current[0] === 'object' && current[0].id) { - currentCombatant = current[0].id; - } - - if (combatStarted && !isNowActive) { - combatStarted = false; - //sendChat('System', '๐Ÿ† Combat has ended!'); - return; - } - - if (combatStarted && state[NAME].storedVariables.combatPatrol) { - pathTokens(true); - } + }); - // When Roll20 combat starts - if (!combatStarted && !wasEmpty && isNowActive) { - combatStarted = true; - //sendChat('System', 'โš”๏ธ Combat has started!'); - sendChat(`${NAME}`, `/w GM โš”๏ธ Patrols have been stopped! Combat Patrolling is ${state[NAME].storedVariables.combatPatrol ? "enabled" : "disabled"}`); - } - }; - // Commands - const chatHandler = function (msg) { + on("chat:message", function (msg) { try { if (msg.type === "api" && (msg.content.toLowerCase().startsWith("!token-control") || msg.content.toLowerCase().startsWith("!tc"))) { @@ -538,7 +497,44 @@ const TokenController = (() => { log(` ${NAME} Error: ${err}
${errorMessage}`); errorMessage = ""; } - }; + }); + + // When Roll20 combat starts + on('change:campaign:turnorder', function (campaign, prev) { + const rawCurrent = campaign.get('turnorder'); + const rawPrev = (prev && typeof prev.get === 'function') ? prev.get('turnorder') : prev; + let current = []; + let prevOrder = []; + try { + current = typeof rawCurrent === 'string' ? (JSON.parse(rawCurrent || '[]') || []) : (Array.isArray(rawCurrent) ? rawCurrent : []); + } catch (e) { current = []; } + try { + prevOrder = typeof rawPrev === 'string' ? (JSON.parse(rawPrev || '[]') || []) : (Array.isArray(rawPrev) ? rawPrev : []); + } catch (e) { prevOrder = []; } + const wasEmpty = !prevOrder || prevOrder.length === 0; + const isNowActive = current && current.length > 0; + + if (isNowActive && current[0] && typeof current[0] === 'object' && current[0].id) { + currentCombatant = current[0].id; + } + + if (combatStarted && !isNowActive) { + combatStarted = false; + //sendChat('System', '๐Ÿ† Combat has ended!'); + return; + } + + if (combatStarted && state[NAME].storedVariables.combatPatrol) { + pathTokens(true); + } + + // When Roll20 combat starts + if (!combatStarted && !wasEmpty && isNowActive) { + combatStarted = true; + //sendChat('System', 'โš”๏ธ Combat has started!'); + sendChat(`${NAME}`, `/w GM โš”๏ธ Patrols have been stopped! Combat Patrolling is ${state[NAME].storedVariables.combatPatrol ? "enabled" : "disabled"}`); + } + }); function cleanupStaleTokens() { const beforePaths = state[NAME].storedVariables.activeTokenPaths.length; diff --git a/TokenController/4.0.1/TokenController.js b/TokenController/4.0.1/TokenController.js new file mode 100644 index 0000000000..d8c2130df8 --- /dev/null +++ b/TokenController/4.0.1/TokenController.js @@ -0,0 +1,2264 @@ +/* +Token Control - A Roll20 Script to move tokens along scripted paths at a variable interval +* Commands: Square Brackets are Optional, Angle Brackets are Required, Vertical Bars mean OR + +# General Commands +* !token-control + - Displays the help text + - Honestly, just use the menu, it got crazy + +# Versioning +* 2.2.0 - Tokens now automatically reverse paths when reaching the end (thought that already existed) +* 2.2.1 - Fixed path reversal bug +* 2.2.2 - Fixed Draft Path Token Movement +* 3.0.0 - Fixed Pixel Unit Size +* - Added Rotate Tokens Option + - Menu Enhancements + - Added Combat Detection + - Added Combat Patrol Pausing + - Added Combat Patrol Toggle + - Fixed Path and Follow Stopping Logic + - Rebuilt Follow System + - Added Stealth Toggle + - Added Path Layer Swapping as Stealth + - Fixed Token Reset +* 4.0.0 - Bug fixes and safety improvements +* - setPath no longer corrupts path (was storing object instead of string) +* - resetTokens fixed (path.forEach and clear logic) +* - turnorder: parse JSON and guard before accessing current[0] +* - moveToken: fixed pathCode undefined, pageMeta from page object when missing from cache +* - Path regex supports multi-digit distances ([0-9]+) +* - Path step logic uses pathArray.length instead of entry.length +* - draftPath: removed duplicate step increment +* - listDrafts: fixed wrong variable in message +* - processFollowMovement: cleanup by tokenId not list index +* - Chat handler requires msg.type === "api" +* - Null checks for tokens in pathTokens, lockTokens, listTokens, getTokensOnPath +* - validatePathString typo: "w/ GM" -> "/w GM" +* - stopPaths: createMenu() when stopping by path name +* 4.1.0 - Fixed start location per path +* - Paths can have a "fixed start": tokens move there when the path is started (e.g. vehicles, chef in kitchen) +* - Commands: !tc setstart (one token selected), !tc clearstart +* - Menu: "Set start" / "Clear start" per path; list path shows fixed start status +* 4.2.0 - Feature pack: live tick, cleanup, sub-steps, path options, export/import, bounds, handout refresh +* - Live tick: changing interval takes effect immediately (!tc tick) +* - Stale token cleanup each tick (removes deleted tokens from state) +* - Smooth movement: !tc substeps toggles sub-step movement (long segments move 1 unit per tick) +* - Path interval: !tc pathinterval for per-path speed +* - Run once: !tc runonce โ€” path runs once then token stops +* - Duplicate: !tc duplicate +* - Export/import: !tc exportpaths, !tc importpaths +* - Max tokens: !tc maxtokens +* - Bounds check when starting path (warns if path would leave map) +* - Cycle toggle: !tc cycle (cycle vs one-way) +* - Random start step: !tc randomstart +* - Usage Guide handout content refreshed on API load when it exists + +# Upcomging Features: +* Cleaner Pathing -- Version(N.M.+) + - Break long segments into smaller steps +* Area Patrol -- Version (+.0.0) + +# Known Defects: +* +*/ + +var API_Meta = API_Meta || {}; +API_Meta.TokenController = { offset: Number.MAX_SAFE_INTEGER, lineCount: -1 }; +{ try { throw new Error(''); } catch (e) { API_Meta.TokenController.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - 6); } } + +const TokenController = (() => { + const NAME = 'TokenController'; + const VERSION = '4.0.1'; + const AUTHOR = 'Developesque'; + let errorMessage = ""; + let listingVars = false; + let combatStarted = false; + let currentCombatant = ""; + let menuShowCount = 0; + let pathTokensIntervalId = null; + + // Ready & Initialize Vars + on('ready', function () { + if (!state[NAME]) { + state[NAME] = { + module: NAME, + schemaVersion: VERSION, + resets: 0, + }; + } + + if (state[NAME].resets == undefined) { + state[NAME].resets = 0; + } + + if (state[NAME].schemaVersion != VERSION) { + state[NAME].schemaVersion = VERSION; + + if (state[NAME].storedVariables != undefined) { + if (state[NAME].storedVariables.paths == undefined) { + state[NAME].storedVariables.paths = []; + } + if (state[NAME].storedVariables.activeTokenPaths == undefined) { + state[NAME].storedVariables.activeTokenPaths = []; + } + if (state[NAME].storedVariables.tokenMemory == undefined) { + state[NAME].storedVariables.tokenMemory = []; + } + if (state[NAME].storedVariables.pathDrafts == undefined) { + state[NAME].storedVariables.pathDrafts = []; + } + if (state[NAME].storedVariables.patrolArea == undefined) { + state[NAME].storedVariables.patrolArea = []; + } + if (state[NAME].storedVariables.interval == undefined) { + state[NAME].storedVariables.interval = 5000; + } + if (state[NAME].storedVariables.unitPerClick == undefined) { + state[NAME].storedVariables.unitPerClick = 2; + } + if (state[NAME].storedVariables.pageMetas == undefined) { + state[NAME].storedVariables.pageMetas = []; + } + if (state[NAME].storedVariables.unitPx == undefined) { + state[NAME].storedVariables.unitPx = 70; + } + if (state[NAME].storedVariables.rotateTokens == undefined) { + state[NAME].storedVariables.rotateTokens = false; + } + if (state[NAME].storedVariables.combatPatrol == undefined) { + state[NAME].storedVariables.combatPatrol = false; + } + if (state[NAME].storedVariables.subStepMovement == undefined) { + state[NAME].storedVariables.subStepMovement = false; + } + } + } + + if (state[NAME].storedVariables == undefined) { + state[NAME].storedVariables = { + paths: [ + { + name: "Square", + path: "U2R2D2L2", + isCycle: true, + }, { + name: "Rectangle", + path: "U1R2D1L2", + isCycle: true, + }, { + name: "T", + path: "U3U0WWR2R0WWL4WWR2D3WW", + isCycle: true, + }, + { + name: "Blinker", + path: "S", + isCycle: true, + } + ], + activeTokenPaths: [ + /*{ + tokenId: "Test", + pathName: "Square", + step: 0, + isReversing: false, + + initialLeft: 0, + initialTop: 0, + initialRotation: 0, + },*/ + ], + tokenMemory: [ + /*{ + tokenId: "Test", + pageId: "Test", + rotation: 0, + left: 0, + top: 0, + followingTokenId: "", + isFollowing: false, + isLocked: false, + },*/ + ], + pathDrafts: [ + /*{ + name: "Test", + path: "U2R2D2L2", + tokenId: "", + step: 0, + currentStep: { // Since the last vector is L, if U,D,R is pressed next, progress steps and concat this to setPath, else distance++ + direction: "", + distance: 0, + }, + previousStep: { + direction: "", + distance: 0, + }, + },*/ + ], + patrolArea: [ + /*{ + name: "Test", + pageId: "", + radius: 0, // Grid Units + },*/ + ], + pageMetas: [ + /*{ + pageId: "", + pageName: "", + pageWidth: 0, // Grid Units * 70 + pageHeight: 0, // Grid Units * 70 + pageScaleNumber: 0 // in {scale_units} + }*/ + ], + interval: 5000, + unitPerClick: 1, + unitPx: 70, + rotateTokens: false, + combatPatrol: false, + subStepMovement: false, + }; + } + + setupMacros(); + + if (pathTokensIntervalId) clearInterval(pathTokensIntervalId); + pathTokensIntervalId = setInterval(pathTokens, state[NAME].storedVariables.interval); + + log(`${NAME} ${VERSION} by ${AUTHOR} Ready Meta Offset : ${API_Meta.TokenController.offset}`); + createMenu(); + + on('chat:message', chatHandler); + on('change:graphic', graphicHandler); + on('change:campaign:turnorder', turnorderHandler); + }); + + // Token Destruction + const graphicHandler = function (obj, prev) { + if (obj.get("_subtype") !== "token") { + return; + } + + let tokenId = obj.get("_id"); + let tokenPath = state[NAME].storedVariables.activeTokenPaths.find(p => p.tokenId == tokenId); + if (tokenPath) { + state[NAME].storedVariables.activeTokenPaths.splice(state[NAME].storedVariables.activeTokenPaths.indexOf(tokenPath), 1); + } + + const moved = obj.get('left') !== prev.left || obj.get('top') !== prev.top; + if (!moved) { + return; + } + + const followers = state[NAME].storedVariables.tokenMemory.filter(t => + t.isFollowing && t.followingTokenId === tokenId + ); + + followers.forEach(follower => { + const followerToken = getObj('graphic', follower.tokenId); + if (!followerToken) return; + + const offsetX = follower.offsetX || 0; + const offsetY = follower.offsetY || 0; + + followerToken.set({ + left: prev.left + offsetX, + top: prev.top + offsetY, + rotation: obj.get('rotation') + }); + }); + }; + + // When Roll20 combat starts + const turnorderHandler = function (campaign, prev) { + const rawCurrent = campaign.get('turnorder'); + const rawPrev = (prev && typeof prev.get === 'function') ? prev.get('turnorder') : prev; + let current = []; + let prevOrder = []; + try { + current = typeof rawCurrent === 'string' ? (JSON.parse(rawCurrent || '[]') || []) : (Array.isArray(rawCurrent) ? rawCurrent : []); + } catch (e) { current = []; } + try { + prevOrder = typeof rawPrev === 'string' ? (JSON.parse(rawPrev || '[]') || []) : (Array.isArray(rawPrev) ? rawPrev : []); + } catch (e) { prevOrder = []; } + const wasEmpty = !prevOrder || prevOrder.length === 0; + const isNowActive = current && current.length > 0; + + if (isNowActive && current[0] && typeof current[0] === 'object' && current[0].id) { + currentCombatant = current[0].id; + } + + if (combatStarted && !isNowActive) { + combatStarted = false; + //sendChat('System', '๐Ÿ† Combat has ended!'); + return; + } + + if (combatStarted && state[NAME].storedVariables.combatPatrol) { + pathTokens(true); + } + + // When Roll20 combat starts + if (!combatStarted && !wasEmpty && isNowActive) { + combatStarted = true; + //sendChat('System', 'โš”๏ธ Combat has started!'); + sendChat(`${NAME}`, `/w GM โš”๏ธ Patrols have been stopped! Combat Patrolling is ${state[NAME].storedVariables.combatPatrol ? "enabled" : "disabled"}`); + } + }; + + // Commands + const chatHandler = function (msg) { + try { + if (msg.type === "api" && (msg.content.toLowerCase().startsWith("!token-control") || msg.content.toLowerCase().startsWith("!tc"))) { + + if (!playerIsGM(msg.playerid)) { + sendChat(`${NAME}`, "/w " + msg.who + " You do not have permission to use this command."); + return; + } + + const args = msg.content.split(/\s+/); + + if (args.length == 1) { + createMenu(); + return; + } + + const command = args[1]; + + switch (command.toLowerCase()) { + // General Commands + case "setup": + setupMacros(); + break; + case "list": + if (args.length < 3) { + listPaths(); + } + switch (args[2].toLowerCase()) { + case "paths": + listPaths(args.length > 3 ? args.slice(3).join(' ').trim() : undefined); + break; + case "tokens": + listTokens(args.length > 3 ? args.slice(3).join(' ').trim() : undefined); + break; + case "tick": + listTick(); + break; + case "vars": + listVars(); + break; + case "draft": + listDrafts(args.length > 3 ? args.slice(3).join(' ').trim() : undefined); + break; + } + break; + + // Path Commands + case "add": + if (args.length < 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc add "); + return; + } + addPath(args.slice(2, -1).join(' ').trim(), args[args.length - 1]); + break; + case "set": + if (args.length < 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc set "); + return; + } + setPath(args.slice(2, -1).join(' ').trim(), args[args.length - 1]); + break; + case "remove": + removePath(args.slice(2).join(' ').trim()); + break; + case "setstart": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc setstart (with one token selected)"); + return; + } + if (!msg.selected || msg.selected.length !== 1) { + sendChat(`${NAME}`, "/w " + msg.who + " Select exactly one token to use as the fixed start location."); + return; + } + setPathStart(args.slice(2).join(' ').trim(), msg.selected[0]._id); + break; + case "clearstart": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc clearstart "); + return; + } + clearPathStart(args.slice(2).join(' ').trim()); + break; + case "build": + if (args.length !== 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Invalid number of arguments. Usage: !tc build "); + return; + } + buildPath(args[2], args[3]); + break; + case "draft": + if (args.length < 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Invalid number of arguments. Usage: !tc draft "); + return; + } + draftPath(args[2], args[3], args.length > 4 ? args[4] : undefined); + break; + case "unit": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Invalid number of arguments. Usage: !tc unit "); + return; + } + state[NAME].storedVariables.unitPerClick = parseInt(args[2]); + createMenu(); + break; + case "unitpx": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Invalid number of arguments. Usage: !tc unitpx "); + return; + } + const unitPx = parseFloat(args[2]); + if (isNaN(unitPx) || unitPx < 1) { + sendChat(`${NAME}`, "/w " + msg.who + " Invalid unitPx. Must be a positive number."); + return; + } + state[NAME].storedVariables.unitPx = unitPx; + createMenu(); + break; + + // Token Commands + case "start": + if (!msg.selected || msg.selected.length == 0) { + sendChat(`${NAME}`, "/w GM No tokens selected."); + return; + } + startPath(msg.selected, args.slice(2).join(' ').trim()); + break; + case "stop": + stopPaths(msg.selected, args.length >= 3 ? args.slice(2).join(' ').trim() : undefined); + break; + case "lock": + lockTokens(msg.selected); + break; + case "unlock": + unlockTokens(msg.selected); + break; + case "follow": + if (args.length < 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Invalid number of arguments. Usage: !tc follow "); + return; + } + followTokens(args[2], args[3]); + break; + + // Config Commands + case "tick": + updateTick(args.length >= 3 ? args[2] : undefined); + break; + case "hide": + hideCommands(); + break; + case "reset": + resetTokens(); + break; + case "usage": + showUsage(); + break; + case "examples": + addExamplePaths(); + break; + case "rotate": + state[NAME].storedVariables.rotateTokens = !state[NAME].storedVariables.rotateTokens; + createMenu(); + break; + case "combat": + state[NAME].storedVariables.combatPatrol = !state[NAME].storedVariables.combatPatrol; + createMenu(); + break; + case "substeps": + state[NAME].storedVariables.subStepMovement = !state[NAME].storedVariables.subStepMovement; + createMenu(); + break; + case "pathinterval": + if (args.length < 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc pathinterval (0 = use global)"); + return; + } + setPathInterval(args.slice(2, -1).join(' ').trim(), args[args.length - 1]); + break; + case "runonce": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc runonce "); + return; + } + togglePathRunOnce(args.slice(2).join(' ').trim()); + break; + case "duplicate": + if (args.length < 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc duplicate "); + return; + } + duplicatePath(args.slice(2, -1).join(' ').trim(), args[args.length - 1]); + break; + case "exportpaths": + exportPaths(); + break; + case "importpaths": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc importpaths (path definitions array)"); + return; + } + importPaths(args.slice(2).join(' ')); + break; + case "maxtokens": + if (args.length < 4) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc maxtokens (0 = no limit)"); + return; + } + setPathMaxTokens(args.slice(2, -1).join(' ').trim(), args[args.length - 1]); + break; + case "cycle": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc cycle (toggles cycle / one-way)"); + return; + } + togglePathCycle(args.slice(2).join(' ').trim()); + break; + case "randomstart": + if (args.length < 3) { + sendChat(`${NAME}`, "/w " + msg.who + " Usage: !tc randomstart (toggles random start step)"); + return; + } + togglePathRandomStart(args.slice(2).join(' ').trim()); + break; + } + } + } catch (err) { + log(` ${NAME} Error: ${err}
${errorMessage}`); + errorMessage = ""; + } + }; + + function cleanupStaleTokens() { + const beforePaths = state[NAME].storedVariables.activeTokenPaths.length; + const beforeMemory = state[NAME].storedVariables.tokenMemory.length; + state[NAME].storedVariables.activeTokenPaths = state[NAME].storedVariables.activeTokenPaths.filter( + tp => getObj('graphic', tp.tokenId) + ); + state[NAME].storedVariables.tokenMemory = state[NAME].storedVariables.tokenMemory.filter( + tm => getObj('graphic', tm.tokenId) + ); + const removedPaths = beforePaths - state[NAME].storedVariables.activeTokenPaths.length; + const removedMemory = beforeMemory - state[NAME].storedVariables.tokenMemory.length; + if (removedPaths > 0 || removedMemory > 0) { + log(`${NAME}: Cleaned ${removedPaths} stale path(s) and ${removedMemory} stale memory entry(ies).`); + } + } + + function pathTokens(overrideCombat = false) { + if (combatStarted && !overrideCombat) { + return; + } + + cleanupStaleTokens(); + + let blockIds = []; + + if (state[NAME].storedVariables.tokenMemory.length > 0) { + state[NAME].storedVariables.tokenMemory.forEach(token => { + if (token.isLocked) { + blockIds.push(token.tokenId); + let obj = getObj("graphic", token.tokenId); + if (obj) { + obj.set({ + "left": token.left, + "top": token.top, + "rotation": token.rotation + }); + } + } + }); + } + + // Move Tokens + for (let i = 0; i < state[NAME].storedVariables.activeTokenPaths.length; i++) { + if (blockIds.includes(state[NAME].storedVariables.activeTokenPaths[i].tokenId)) { + continue; + } + + const tokenPath = state[NAME].storedVariables.activeTokenPaths[i]; + if (!tokenPath) { + log(`${NAME}: Error: Token path not found.`); + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + continue; + } + + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name == tokenPath.pathName); + if (pathIndex == -1) { + log(`${NAME}: Error: Path ${tokenPath.pathName} not found.`); + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + continue; + } + + const pathDef = state[NAME].storedVariables.paths[pathIndex]; + if (pathDef.intervalOverride != null && pathDef.intervalOverride > 0) { + tokenPath.tickCounter = (tokenPath.tickCounter || 0) + 1; + const globalInterval = state[NAME].storedVariables.interval; + if (tokenPath.tickCounter * globalInterval < pathDef.intervalOverride) { + continue; + } + tokenPath.tickCounter = 0; + } + + let pathCode = pathDef.path; + if (typeof pathCode !== 'string') { + pathCode = (pathCode && pathCode.path) ? pathCode.path : ''; + } + + const pathArray = pathCode.match(/([UDLR])([0-9]+)|W|S/g); + if (!pathArray || pathArray.length < 1) { + log(`${NAME}: Error: Path code ${pathCode} is invalid - No Vector Matches.`); + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + continue; + } + + const pathVector = pathArray[tokenPath.step]; + if (!pathVector) { + log(`${NAME}: Error: Path code ${pathCode} is invalid - No Vectors present at step ${tokenPath.step}.`); + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + continue; + } + + let direction = pathVector.substring(0, 1); + if (tokenPath.isReversing) { + switch (direction) { + case "U": + direction = "D"; + break; + case "D": + direction = "U"; + break; + case "L": + direction = "R"; + break; + case "R": + direction = "L"; + break; + } + } + + const distance = (direction === 'W' || direction === 'S') ? 0 : parseInt(pathVector.substring(1), 10); + if (isNaN(distance) && direction !== "W" && direction !== "S") { + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + sendChat("GM", `Error: Path code ${pathCode} is invalid - Distance is not a number.`); + continue; + } + + const token = getObj("graphic", tokenPath.tokenId); + if (!token) { + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + continue; + } + const left = token.get("left"); + const top = token.get("top"); + const rotation = token.get("rotation"); + + const useSubSteps = state[NAME].storedVariables.subStepMovement && (direction === 'U' || direction === 'D' || direction === 'L' || direction === 'R') && distance > 1; + const entry = state[NAME].storedVariables.activeTokenPaths[i]; + if (useSubSteps) { + if (entry.subStepTotal == null || entry.subStepTotal === 0) { + entry.subStepTotal = distance; + entry.subStep = 0; + } + const moveDist = 1; + if (!moveToken(tokenPath.tokenId, direction, moveDist, tokenPath.pageId, pathCode)) { + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + sendChat("GM", `Error: Failed to move token ${tokenPath.tokenId}.`); + continue; + } + processFollowMovement(tokenPath.tokenId, left, top, rotation); + entry.subStep = (entry.subStep || 0) + 1; + if (entry.subStep < entry.subStepTotal) { + continue; + } + entry.subStepTotal = 0; + entry.subStep = 0; + } else { + if (!moveToken(tokenPath.tokenId, direction, distance, tokenPath.pageId, pathCode)) { + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + sendChat("GM", `Error: Failed to move token ${tokenPath.tokenId}.`); + continue; + } + processFollowMovement(tokenPath.tokenId, left, top, rotation); + } + + if (pathDef.runOnce && tokenPath.step === pathArray.length - 1) { + state[NAME].storedVariables.activeTokenPaths.splice(i, 1); + i--; + continue; + } + + if (tokenPath.isReversing) { + if (tokenPath.step === 0) { + state[NAME].storedVariables.activeTokenPaths[i].isReversing = false; + + if (pathArray.length > 1) { + state[NAME].storedVariables.activeTokenPaths[i].step++; + } + } else { + state[NAME].storedVariables.activeTokenPaths[i].step--; + } + } else { + if (tokenPath.step === pathArray.length - 1) { + if (pathDef.isCycle) { + state[NAME].storedVariables.activeTokenPaths[i].step = 0; + } else { + state[NAME].storedVariables.activeTokenPaths[i].isReversing = true; + if (pathArray.length > 1) { + state[NAME].storedVariables.activeTokenPaths[i].step--; + } + } + } else { + state[NAME].storedVariables.activeTokenPaths[i].step++; + } + } + } + } + + function moveToken(tokenId, direction, distance, pageId, pathCodeForError) { + let angle = 0; + switch (direction.toUpperCase()) { + case "U": + angle = 180; + break; + case "D": + angle = 0; + break; + case "L": + angle = 90; + break; + case "R": + angle = 270; + break; + case "W": + return true; + case "S": + toggleStealth(tokenId); + return true; + default: + sendChat(`${NAME}`, `/w GM Error: Invalid direction "${direction}"${pathCodeForError ? ` in path "${pathCodeForError}"` : ''}.`); + return false; + } + + const token = getObj("graphic", tokenId); + if (!token) { + sendChat(`${NAME}`, `/w GM Error: Token ${tokenId} not found.`); + return false; + } + + if (distance == 0 && state[NAME].storedVariables.rotateTokens) { + token.set("rotation", angle); + return true; + } + + let pageMeta = state[NAME].storedVariables.pageMetas.find(p => p.pageId === pageId); + if (!pageMeta) { + const pageIdFromToken = token.get('_pageid'); + const page = getObj('page', pageIdFromToken); + if (!page) { + sendChat(`${NAME}`, `/w GM Error: Page not found for token. Please restart the path.`); + return false; + } + pageMeta = { + pageWidth: page.get('width') * state[NAME].storedVariables.unitPx, + pageHeight: page.get('height') * state[NAME].storedVariables.unitPx + }; + } + + if (angle === 0 || angle === 180) { + const top = token.get("top") + (distance * state[NAME].storedVariables.unitPx * (angle === 0 ? 1 : -1)); + if ((top + state[NAME].storedVariables.unitPx) > pageMeta.pageHeight || top < 0) { + sendChat(`${NAME}`, `/w GM Error: Token ${tokenId} is out of bounds. Top: ${top + state[NAME].storedVariables.unitPx} (+${state[NAME].storedVariables.unitPx}) > ${pageMeta.pageHeight}`); + return true; + } + token.set({ "top": top }); + + } else { + const left = token.get("left") + (distance * state[NAME].storedVariables.unitPx * (angle === 90 ? -1 : 1)); + if ((left + state[NAME].storedVariables.unitPx) > pageMeta.pageWidth || left < 0) { + sendChat(`${NAME}`, `/w GM Error: Token ${tokenId} is out of bounds. Left: ${left + state[NAME].storedVariables.unitPx} (+${state[NAME].storedVariables.unitPx}) > ${pageMeta.pageWidth}`); + return true; + } + token.set({ "left": left }); + } + + if (state[NAME].storedVariables.rotateTokens) { + token.set({ "rotation": angle }); + } + + return true; + } + + function setupMacros() { + let oldMenuMacro = findObjs({ type: 'macro', name: 'TokenController_Menu' }); + if (oldMenuMacro.length > 0) { + oldMenuMacro[0].remove(); + } + + let menuMacro = findObjs({ type: 'macro', name: 'T-Cntrl_Menu' }); + + const gmPlayers = findObjs({ _type: 'player' }).filter(player => playerIsGM(player.get("_id"))); + + if (!menuMacro || menuMacro.length < 1) { + _.each(gmPlayers, function (player) { + createObj('macro', { + name: 'T-Cntrl_Menu', + action: '!token-control', + playerid: player.get("_id"), + }); + }); + } else if (menuMacro.length > 1) { + for (let i = 1; i < menuMacro.length; i++) { + menuMacro[i].remove(); + } + } + + let buildPathMacro = findObjs({ type: 'macro', name: 'T-Cntrl_Builder' }); + if (!buildPathMacro || buildPathMacro.length < 1) { + _.each(gmPlayers, function (player) { + createObj('macro', { + name: 'T-Cntrl_Builder', + action: '!tc Build @{selected|token_id} ?{Name of Path?}', + playerid: player.get("_id"), + visibleto: player.get("_id"), + istokenaction: true + }); + }); + } else if (buildPathMacro.length > 1) { + for (let i = 1; i < buildPathMacro.length; i++) { + buildPathMacro[i].remove(); + } + } + + let followMacro = findObjs({ type: 'macro', name: 'T-Cntrl_Follow' }); + if (!followMacro || followMacro.length < 1) { + _.each(gmPlayers, function (player) { + createObj('macro', { + name: 'T-Cntrl_Follow', + action: '!tc Follow @{selected|token_id} @{target|token_id}', + playerid: player.get("_id"), + visibleto: player.get("_id"), + istokenaction: true + }); + }); + } else if (followMacro.length > 1) { + for (let i = 1; i < followMacro.length; i++) { + followMacro[i].remove(); + } + } + + let usageHandout = findObjs({ type: 'handout', name: 'TokenController Usage Guide' }); + if (!usageHandout || usageHandout.length < 1) { + if (gmPlayers.length > 0) setupHandouts(gmPlayers[0]); + } else { + if (usageHandout.length > 1) { + for (let i = 1; i < usageHandout.length; i++) { + usageHandout[i].remove(); + } + } + usageHandout = findObjs({ type: 'handout', name: 'TokenController Usage Guide' }); + if (usageHandout.length === 1) { + usageHandout[0].set({ notes: getUsageGuideContent() }); + } + } + } + + function getUsageGuideContent() { + let content = new HtmlBuilder('div'); + content.append('.heading', 'Overview'); + content.append('.info', [ + 'TokenController moves tokens along scripted paths (U/D/L/R directions plus W=wait, S=stealth toggle).', + 'Open the menu with !tc or !token-control. Most actions are done from the menu or the T-Cntrl_ macros.', + ].join('
')); + + content.append('.heading', 'Macros'); + content.append('.info', [ + 'T-Cntrl_Menu: Opens the TokenController menu (!tc).', + 'T-Cntrl_Builder: With a token selected, starts a path draft (prompts for path name).', + 'T-Cntrl_Follow: With a token selected and a target, makes the selected token follow the target.', + 'Macros are recreated on API restart if missing. Delete any "T-Cntrl_" macro to force rebuild.', + ].join('
')); + + content.append('.heading', 'Path Building'); + content.append('.info', [ + 'Select a token and click T-Cntrl_Builder (or use the menu), then enter a path name to create a Draft.', + 'In the menu under Draft Paths: use U, D, L, R to add movement (with units per click); W = wait, S = stealth toggle. Set saves the draft as a path; Rmv removes the draft.', + 'Keep the draft token on the map until you click Set. Paths are grid-based and work on any map; page size is stored so tokens stay in bounds.', + ].join('
')); + + content.append('.heading', 'Fixed Start Location'); + content.append('.info', [ + 'Some paths can always start from the same spot (e.g. a chef in a kitchen, vehicles in a bay).', + 'Set start: Select one token at the desired start position, then use that path\'s "Set start" button (or !tc setstart <pathName>). From then on, any token started on that path is moved there first, then runs the path.', + 'Clear start: Use "Clear start" for that path (or !tc clearstart <pathName>) to go back to "start from wherever the token is."', + 'List Paths for a path shows whether it has a fixed start and the coordinates.', + ].join('
')); + + content.append('.heading', 'Token Controls'); + content.append('.info', [ + 'Start: Select token(s), then click Start next to a path name. Tokens begin patrolling that path.', + 'Stop: Path Stop (with or without selection) stops that path for selected tokens or all on that path. Token Stop stops all paths for selected tokens or every token.', + 'Reset: Puts all patrolling tokens back to their starting positions and clears path/follow state.', + 'Lock / Unlock: Locked tokens stay in place (e.g. to pause one patrol). Control order: Locked > Follow > Path.', + 'Follow: Use T-Cntrl_Follow (select follower, target leader) or !tc follow <followerId> <leaderId>. Unlocking stops following.', + ].join('
')); + + content.append('.heading', 'Settings (Menu)'); + content.append('.info', [ + 'Interval (Tick): Time in ms between each path step. Default 5000 (5 s). Change takes effect immediately.', + 'Pixels Per Unit: Grid movement scale (default 70). Match your map\'s grid pixel size so one path unit = one grid cell.', + 'Rotate Tokens: When on, tokens turn to face their movement direction.', + 'Combat Patrol: When on, patrols still advance once per turn order change during combat; when off, patrols pause when combat starts.', + ].join('
')); + + content.append('.heading', 'Commands (GM only)'); + content.append('.info', [ + '!tc or !token-control โ€” menu. !tc list paths [name], !tc list tokens, !tc list draft [name], !tc list tick, !tc list vars.', + '!tc add <name> <pathCode> โ€” add path (e.g. U2R2D2L2). !tc set <name> <pathCode> โ€” update path. !tc remove <name>.', + '!tc setstart <pathName> โ€” set fixed start from selected token. !tc clearstart <pathName> โ€” clear fixed start.', + '!tc start <pathName>, !tc stop [pathName], !tc reset. !tc lock, !tc unlock. !tc follow <followerId> <leaderId>.', + '!tc tick <ms>, !tc unit <n>, !tc unitpx <n>, !tc rotate, !tc combat, !tc substeps, !tc usage.', + '!tc pathinterval <pathName> <ms|0>, !tc runonce <pathName>, !tc duplicate <src> <newName>, !tc exportpaths, !tc importpaths <json>.', + '!tc maxtokens <pathName> <n|0>, !tc cycle <pathName>, !tc randomstart <pathName>, !tc examples.', + ].join('
')); + + return content.toString({ + "heading": { + "font-size": "1.5em", + "color": "#00f", + "text-align": "center", + }, + "info": { + "font-size": "1em", + "color": "#000", + "margin-bottom": "10px", + } + }); + } + + function setupHandouts() { + const handout = createObj('handout', { + name: 'TokenController Usage Guide', + inplayerjournals: 'all', + archived: false + }); + handout.set({ notes: getUsageGuideContent() }); + } + + function showUsage() { + createMenu(); + sendChat('TokenController', '/w gm ' + '
' + "See the Handout: TokenController Usage Guide for more information.
https://youtu.be/MlNW_kN7sp8?si=DTWQ_Z90KKSVdfGF" + '
'); + } + + function addPath(name, pathString) { + if (!name) { + sendChat(`${NAME}`, "/w GM Please specify a name for the path."); + return; + } + + // If name is already in path list, tell GM and return + if (state[NAME].storedVariables.paths.find(p => p.name == name)) { + sendChat(`${NAME}`, "/w GM Path name already exists."); + return; + } + + if (!validatePathString(pathString)) { + return; + } + + state[NAME].storedVariables.paths.push({ + name: name, + path: pathString, + isCycle: testCycle(pathString) + }); + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${name}" added.`); + } + + function setPath(name, pathString) { + if (!validatePathString(pathString)) { + return; + } + + const index = getPathIndexByName(name); + if (index == -1) { + sendChat(`${NAME}`, `/w GM Path ${name} not found.`); + return; + } + + state[NAME].storedVariables.paths[index].path = pathString; + state[NAME].storedVariables.paths[index].isCycle = testCycle(pathString); + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${name}" updated to ${pathString}.`); + } + + function testCycle(pathString, silent) { + const workingString = (typeof pathString === 'string' ? pathString : '').replace(/W/g, ""); + const pathArray = workingString.match(/([UDLR])(\d+)/g); + if (!pathArray || pathArray.length === 0) return false; + + let vert = 0; + let horz = 0; + + for (let i = 0; i < pathArray.length; i++) { + const direction = pathArray[i][0]; + const distance = parseInt(pathArray[i].substr(1), 10); + + if (direction === "U") vert += distance; + else if (direction === "D") vert -= distance; + else if (direction === "L") horz -= distance; + else if (direction === "R") horz += distance; + } + + const isCycle = vert === 0 && horz === 0; + if (isCycle && !silent) { + sendChat('GM', `Path is a cycle: ${pathString}`); + } + return isCycle; + } + + function removePath(name) { + let index = getPathIndexByName(name); + if (index == -1) { + // Try to find and remove from pathDrafts + index = state[NAME].storedVariables.pathDrafts.findIndex(p => p.name == name); + if (index > -1) { + state[NAME].storedVariables.pathDrafts.splice(index, 1); + sendChat(`${NAME}`, `/w GM Path "${name}" removed from Drafts.`); + } else { + sendChat(`${NAME}`, `/w GM Path "${name}" not found in Drafts.`); + } + + return; + } + + state[NAME].storedVariables.paths.splice(index, 1); + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${name}" removed.`); + } + + function setPathStart(pathName, tokenId) { + if (!pathName) { + sendChat(`${NAME}`, "/w GM Please specify a path name."); + return; + } + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === pathName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" not found.`); + return; + } + const token = getObj('graphic', tokenId); + if (!token) { + sendChat(`${NAME}`, "/w GM Token not found."); + return; + } + const path = state[NAME].storedVariables.paths[pathIndex]; + path.fixedStart = true; + path.startLeft = token.get('left'); + path.startTop = token.get('top'); + path.startPageId = token.get('_pageid'); + path.startRotation = token.get('rotation'); + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" now has a fixed start at this token's position. Tokens will move here when the path is started.`); + } + + function clearPathStart(pathName) { + if (!pathName) { + sendChat(`${NAME}`, "/w GM Please specify a path name."); + return; + } + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === pathName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" not found.`); + return; + } + const path = state[NAME].storedVariables.paths[pathIndex]; + path.fixedStart = false; + delete path.startLeft; + delete path.startTop; + delete path.startPageId; + delete path.startRotation; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" now starts from wherever the token is (no fixed start).`); + } + + function setPathInterval(pathName, msStr) { + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === pathName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" not found.`); + return; + } + const path = state[NAME].storedVariables.paths[pathIndex]; + const ms = parseInt(msStr, 10); + if (msStr === '0' || msStr.toLowerCase() === 'off') { + delete path.intervalOverride; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" now uses the global tick interval.`); + return; + } + if (isNaN(ms) || ms < 1) { + sendChat(`${NAME}`, "/w GM Please specify a positive interval in ms, or 0 to use global."); + return; + } + path.intervalOverride = ms; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" interval set to ${ms} ms.`); + } + + function togglePathRunOnce(pathName) { + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === pathName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" not found.`); + return; + } + const path = state[NAME].storedVariables.paths[pathIndex]; + path.runOnce = !path.runOnce; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" run-once is now ${path.runOnce ? 'on' : 'off'}.`); + } + + function togglePathCycle(pathName) { + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === pathName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" not found.`); + return; + } + const path = state[NAME].storedVariables.paths[pathIndex]; + path.isCycle = !path.isCycle; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" is now ${path.isCycle ? 'cycling' : 'one-way (reverse at end)'}.`); + } + + function togglePathRandomStart(pathName) { + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === pathName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" not found.`); + return; + } + const path = state[NAME].storedVariables.paths[pathIndex]; + path.randomStart = !path.randomStart; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" random start step is now ${path.randomStart ? 'on' : 'off'}.`); + } + + function setPathMaxTokens(pathName, nStr) { + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === pathName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" not found.`); + return; + } + const path = state[NAME].storedVariables.paths[pathIndex]; + const n = parseInt(nStr, 10); + if (nStr === '0' || nStr.toLowerCase() === 'off' || isNaN(n) || n < 0) { + delete path.maxTokens; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" has no token limit.`); + return; + } + path.maxTokens = n; + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${pathName}" max tokens set to ${n}.`); + } + + function pathWouldLeaveBounds(pathStr, startLeft, startTop, unitPx, pageWidth, pageHeight) { + const pathArray = (typeof pathStr === 'string' ? pathStr : '').match(/([UDLR])([0-9]+)|W|S/g); + if (!pathArray || pathArray.length === 0) return false; + let left = startLeft; + let top = startTop; + const tokenSize = unitPx; + for (let i = 0; i < pathArray.length; i++) { + const dir = pathArray[i].charAt(0); + const dist = (dir === 'W' || dir === 'S') ? 0 : parseInt(pathArray[i].substring(1), 10) || 0; + if (dir === 'U') top -= dist * unitPx; + else if (dir === 'D') top += dist * unitPx; + else if (dir === 'L') left -= dist * unitPx; + else if (dir === 'R') left += dist * unitPx; + if (left < 0 || top < 0 || (left + tokenSize) > pageWidth || (top + tokenSize) > pageHeight) return true; + } + return false; + } + + function exportPaths() { + const out = state[NAME].storedVariables.paths.map(p => { + const o = { name: p.name, path: typeof p.path === 'string' ? p.path : (p.path && p.path.path) || '', isCycle: !!p.isCycle }; + if (p.fixedStart && p.startLeft != null) { + o.fixedStart = true; + o.startLeft = p.startLeft; + o.startTop = p.startTop; + o.startPageId = p.startPageId; + o.startRotation = p.startRotation; + } + if (p.intervalOverride != null) o.intervalOverride = p.intervalOverride; + if (p.runOnce != null) o.runOnce = p.runOnce; + if (p.maxTokens != null) o.maxTokens = p.maxTokens; + if (p.randomStart != null) o.randomStart = p.randomStart; + return o; + }); + const json = JSON.stringify(out); + sendChat(`${NAME}`, '/w GM Paths export (copy for !tc importpaths):'); + if (json.length > 1800) { + const chunkSize = 1800; + for (let i = 0; i < json.length; i += chunkSize) { + sendChat(`${NAME}`, '/w GM ' + json.substring(i, i + chunkSize)); + } + } else { + sendChat(`${NAME}`, '/w GM ' + json); + } + } + + function importPaths(jsonStr) { + let data; + try { + data = JSON.parse(jsonStr); + } catch (e) { + sendChat(`${NAME}`, '/w GM Invalid JSON for import.'); + return; + } + if (!Array.isArray(data)) { + sendChat(`${NAME}`, '/w GM Import data must be an array of path objects.'); + return; + } + let added = 0; + let skipped = 0; + data.forEach(p => { + if (!p.name || !p.path) return; + if (state[NAME].storedVariables.paths.find(ex => ex.name === p.name)) { + skipped++; + return; + } + const pathStr = typeof p.path === 'string' ? p.path : (p.path && p.path.path) || ''; + const entry = { name: p.name, path: pathStr, isCycle: !!p.isCycle }; + if (p.fixedStart && p.startLeft != null) { + entry.fixedStart = true; + entry.startLeft = p.startLeft; + entry.startTop = p.startTop; + entry.startPageId = p.startPageId; + entry.startRotation = p.startRotation; + } + if (p.intervalOverride != null) entry.intervalOverride = p.intervalOverride; + if (p.runOnce != null) entry.runOnce = p.runOnce; + if (p.maxTokens != null) entry.maxTokens = p.maxTokens; + if (p.randomStart != null) entry.randomStart = p.randomStart; + state[NAME].storedVariables.paths.push(entry); + added++; + }); + createMenu(); + sendChat(`${NAME}`, `/w GM Imported ${added} path(s), skipped ${skipped} (name already exists).`); + } + + function duplicatePath(sourceName, newName) { + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name === sourceName); + if (pathIndex === -1) { + sendChat(`${NAME}`, `/w GM Path "${sourceName}" not found.`); + return; + } + if (state[NAME].storedVariables.paths.find(p => p.name === newName)) { + sendChat(`${NAME}`, `/w GM A path named "${newName}" already exists.`); + return; + } + const src = state[NAME].storedVariables.paths[pathIndex]; + const pathStr = typeof src.path === 'string' ? src.path : (src.path && src.path.path) || ''; + const copy = { + name: newName, + path: pathStr, + isCycle: !!src.isCycle, + }; + if (src.fixedStart && src.startLeft != null) { + copy.fixedStart = true; + copy.startLeft = src.startLeft; + copy.startTop = src.startTop; + copy.startPageId = src.startPageId; + copy.startRotation = src.startRotation; + } + if (src.intervalOverride != null) copy.intervalOverride = src.intervalOverride; + if (src.runOnce != null) copy.runOnce = src.runOnce; + if (src.maxTokens != null) copy.maxTokens = src.maxTokens; + if (src.randomStart != null) copy.randomStart = src.randomStart; + state[NAME].storedVariables.paths.push(copy); + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${sourceName}" duplicated as "${newName}".`); + } + + function addExamplePaths() { + const examples = [ + { name: 'Square', path: 'U2R2D2L2' }, + { name: 'Big Square', path: 'U3R3D3L3' }, + { name: 'Horizontal', path: 'R4L4' }, + { name: 'Vertical', path: 'U4D4' }, + { name: 'Rectangle', path: 'U1R2D1L2' }, + { name: 'Pause Square', path: 'U2WR2WD2WL2W' }, + { name: 'Zigzag', path: 'U1R1U1R1D1L1D1L1' }, + { name: 'Blinker', path: 'S' }, + { name: 'T-Patrol', path: 'U3U0WWR2R0WWL4WWR2D3WW' }, + { name: 'Figure-8', path: 'R2U2L2D2L2U2R2D2' }, + ]; + const existing = state[NAME].storedVariables.paths; + let added = 0; + examples.forEach(ex => { + if (!existing.find(p => p.name === ex.name)) { + existing.push({ + name: ex.name, + path: ex.path, + isCycle: testCycle(ex.path, true) + }); + added++; + } + }); + createMenu(); + sendChat(`${NAME}`, `/w GM Added ${added} example path(s). ${added === 0 ? 'All example paths already exist.' : 'Paths already present were skipped.'}`); + } + + function buildPath(tokenId, name) { + if (!name) { + sendChat(`${NAME}`, "/w GM Please specify a name for the path."); + return; + } + + if (!tokenId) { + sendChat(`${NAME}`, "/w GM Please specify a tokenId for the path."); + return; + } + + // Check for name in pathDraft list and path list + if (state[NAME].storedVariables.pathDrafts.find(p => p.name == name) || state[NAME].storedVariables.paths.find(p => p.name == name)) { + sendChat(`${NAME}`, "/w GM Path name already exists."); + return; + } + + // Create pathDraft + const pathDraft = { + name: name, + path: "", + tokenId: tokenId, + step: 0, + currentStep: { + direction: "", + distance: undefined, + }, + previousStep: { + direction: "", + distance: undefined, + }, + }; + + state[NAME].storedVariables.pathDrafts.push(pathDraft); + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${name}" added to Drafts.`); + } + + function draftPath(name, direction, distance) { + if (!name) { + sendChat(`${NAME}`, "/w GM Please specify a name for the path."); + return; + } + + if (!direction) { + sendChat(`${NAME}`, "/w GM Please specify a direction for the path."); + return; + } + + // Short Circuits for Set and Remove + if (direction == "Set") { + setDraftToPath(name); + createMenu(); + return; + } else if (direction == "Rmv") { + state[NAME].storedVariables.pathDrafts.splice(state[NAME].storedVariables.pathDrafts.findIndex(p => p.name == name), 1); + createMenu(); + return; + } + + // Check for valid direction + if (!/[UDLRWS]/.test(direction)) { + sendChat(`${NAME}`, `/w GM Invalid direction {${direction}}.`); + return; + } + + // Validate distance + if (!!distance) { + if (direction == "W" || direction == "S") { + sendChat(`${NAME}`, `/w GM "W" means "Wait", "S" means "Stealth Toggle". These should not have distance input.`); + + } else if (!/\d+/.test(distance)) { + sendChat(`${NAME}`, "/w GM Please specify a valid distance."); + return; + } + } + + // Get draft by name in pathDrafts + const draftIndex = state[NAME].storedVariables.pathDrafts.findIndex(p => p.name == name); + if (draftIndex < 0) { + sendChat(`${NAME}`, "/w GM Path draft not found."); + return; + } + + // Validate Draft + if (state[NAME].storedVariables.pathDrafts[draftIndex].path == undefined || state[NAME].storedVariables.pathDrafts[draftIndex].tokenId == undefined) { + sendChat(`${NAME}`, "/w GM Path draft improperly started, removing draft. Please try again."); + state[NAME].storedVariables.pathDrafts.splice(draftIndex, 1); + return; + } + + state[NAME].storedVariables.pathDrafts[draftIndex].path += + (direction === "W") ? "W" + : (direction === "S") ? "S" + : `${direction}${distance}`; + + state[NAME].storedVariables.pathDrafts[draftIndex].step++; + + // Move Token for Visual Verification + if (!moveToken(state[NAME].storedVariables.pathDrafts[draftIndex].tokenId, direction, distance || 0)) { + sendChat(`${NAME}`, "/w GM Failed to move token."); + } + + createMenu(); + } + + function setDraftToPath(name) { + if (!name) { + sendChat(`${NAME}`, "/w GM Please specify a name for the path."); + return; + } + + // Get draft by name in pathDrafts + const draftIndex = state[NAME].storedVariables.pathDrafts.findIndex(p => p.name == name); + if (draftIndex < 0) { + sendChat(`${NAME}`, "/w GM Path draft not found."); + return; + } + + // Validate Draft + // TODO: Round up all of these into one function + if (state[NAME].storedVariables.pathDrafts[draftIndex].path == undefined || state[NAME].storedVariables.pathDrafts[draftIndex].tokenId == undefined) { + sendChat(`${NAME}`, "/w GM Path draft improperly started, removing draft. Please try again."); + state[NAME].storedVariables.pathDrafts.splice(draftIndex, 1); + return; + } + + // Add path to path list + state[NAME].storedVariables.paths.push({ + name: name, + path: state[NAME].storedVariables.pathDrafts[draftIndex].path, + isCycle: testCycle(state[NAME].storedVariables.pathDrafts[draftIndex].path), + }); + + // Remove draft + state[NAME].storedVariables.pathDrafts.splice(draftIndex, 1); + + createMenu(); + sendChat(`${NAME}`, `/w GM Path "${name}" added to Paths and removed from Drafts.`); + } + + function getPathIndexByName(name) { + if (!name) { + sendChat(`${NAME}`, "/w GM Please specify a name for the path."); + return -1; + } + + const index = state[NAME].storedVariables.paths.findIndex(path => path.name == name); + if (index == -1) { + sendChat(`${NAME}`, `/w GM Path "${name}" not found in Paths.`); + return -1; + } + + return index; + } + + function validatePathString(pathString) { + if (!pathString) { + sendChat(`${NAME}`, "/w GM Please specify a path code for the path."); + return false; + } + + if (pathString.length < 4) { + sendChat(`${NAME}`, "/w GM Path must be at least 2 Vectors (ie. U1D1) long."); + return false; + } + + let pathArray = []; + try { + pathArray = pathString.match(/([UDLR])(\d+)/g); + } catch (err) { + sendChat(`${NAME}`, "/w GM Path must be a valid path code (ie. U1R1L1D1)."); + return false; + } + + if (!pathArray || pathArray.length == 0) { + sendChat(`${NAME}`, "/w GM Path must be a valid path code (ie. U1R1L1D1)."); + return false; + } + + for (let i = 0; i < pathArray.length; i++) { + const vector = pathArray[i]; + const direction = vector.charAt(0); + const distance = parseInt(vector.substring(1), 10); + + if (distance < 0 && direction != "W") { + sendChat(`${NAME}`, "/w GM Path must have at least 1 unit of distance."); + return false; + } + + if (direction !== "U" && direction !== "D" && direction !== "L" && direction !== "R") { + sendChat(`${NAME}`, "/w GM Path must use U, D, L, or R for directions."); + return false; + } + } + + return true; + } + + function listPaths(name) { + const paths = state[NAME].storedVariables.paths; + if (name) { + const index = getPathIndexByName(name); + if (index == -1) { + return; + } + + const p = paths[index]; + const pathStr = typeof p.path === 'string' ? p.path : (p.path && p.path.path) || ''; + const fixedStartInfo = p.fixedStart ? ` Fixed start: yes (${p.startLeft}, ${p.startTop})` : ' Fixed start: no (start from anywhere)'; + const intervalInfo = p.intervalOverride ? ` Interval: ${p.intervalOverride}ms` : ''; + const runOnceInfo = p.runOnce ? ' Run once: yes' : ''; + const maxInfo = p.maxTokens != null ? ` Max tokens: ${p.maxTokens}` : ''; + const randomStartInfo = p.randomStart ? ' Random start: yes' : ''; + sendChat(`${NAME}`, `/w GM Path "${name}" is defined as "${pathStr}" (Cycle: ${p.isCycle}).${fixedStartInfo}${intervalInfo}${runOnceInfo}${maxInfo}${randomStartInfo} Current Tokens on Path:
${getTokensOnPath(name)}`); + } else { + sendChat(`${NAME}`, `/w GM Paths:
${paths.map(path => ``).join("")}
${path.name}${path.path}${path.isCycle}
`); + } + } + + function listTokens(name) { + const tokens = state[NAME].storedVariables.activeTokenPaths; + if (tokens.length === 0) { + sendChat(`${NAME}`, "/w GM No Tokens are currently on a Path."); + return; + } + + const tokenRow = (token) => { + const obj = getObj('graphic', token.tokenId); + return `-- ${obj ? obj.get('name') : token.tokenId}${token.pathName}`; + }; + if (name) { + sendChat(`${NAME}`, `/w GM Tokens on Path "${name}":
${tokens.filter(t => t.pathName === name).map(tokenRow).join("")}`); + } else { + sendChat(`${NAME}`, `/w GM Tokens:
${tokens.map(tokenRow).join("")}
`); + } + } + + function listTick() { + sendChat(`${NAME}`, `/w GM
Tick: ${state[NAME].storedVariables.interval} ms`); + } + + function listVars() { + listingVars = true; + createMenu(); + } + + function listDrafts(name) { + if (name != undefined && name != "") { + const draftIndex = state[NAME].storedVariables.pathDrafts.findIndex(draft => draft.name == name); + + if (draftIndex > -1) { + const draft = state[NAME].storedVariables.pathDrafts[draftIndex]; + if (draft.tokenId === undefined || draft.tokenId === "") { + sendChat(`${NAME}`, `/w GM Draft "${draft.name}" does not have a tokenId assigned and will now be deleted.`); + state[NAME].storedVariables.pathDrafts.splice(draftIndex, 1); + return; + } + + if (state[NAME].storedVariables.pathDrafts[draftIndex].path == undefined || state[NAME].storedVariables.pathDrafts[draftIndex].path == "") { + sendChat(`${NAME}`, `/w GM Draft "${state[NAME].storedVariables.pathDrafts[draftIndex].name}" with Token "${getObj('graphic', state[NAME].storedVariables.pathDrafts[draftIndex].tokenId).get('name')}" has yet to be started.`); + return; + } else { + sendChat(`${NAME}`, `/w GM Draft "${state[NAME].storedVariables.pathDrafts[draftIndex].name}" with Token "${getObj('graphic', state[NAME].storedVariables.pathDrafts[draftIndex].tokenId).get('name')}" is on Path "${state[NAME].storedVariables.pathDrafts[draftIndex].path}".`); + } + } else { + sendChat(`${NAME}`, `/w GM Draft "${name}" not found.`); + return; + } + } + + sendChat(`${NAME}`, `/w GM Path Drafts:
${state[NAME].storedVariables.pathDrafts.map(draft => `
${draft.name}
${draft.path}
${draft.step}
${draft.tokenId}`).join("
")}`); + } + + function getTokensOnPath(name) { + if (!name) { + sendChat(`${NAME}`, "/w GM Please specify a name for the path."); + return; + } + + if (!!state[NAME].storedVariables.activeTokenPaths && (state[NAME].storedVariables.activeTokenPaths.length === 0 || state[NAME].storedVariables.activeTokenPaths.filter(token => token.pathName === name).length === 0)) { + return "None"; + } + + return state[NAME].storedVariables.activeTokenPaths.filter(token => token.pathName === name).map(token => { + const obj = getObj('graphic', token.tokenId); + return `-- ${obj ? obj.get('name') : token.tokenId}`; + }).join("
"); + } + + function startPath(selected, pathName) { + if (!selected) { + sendChat(`${NAME}`, "/w GM Please select a token to start a path."); + return; + } + + if (!pathName) { + sendChat(`${NAME}`, "/w GM Please specify a path name."); + return; + } + + const pathIndex = state[NAME].storedVariables.paths.findIndex(p => p.name == pathName); + if (pathIndex == -1) { + sendChat(`${NAME}`, "/w GM Path not found."); + return; + } + + const pathDef = state[NAME].storedVariables.paths[pathIndex]; + if (pathDef.isCycle == undefined) { + pathDef.isCycle = testCycle(pathDef.path, true); + } + + const useFixedStart = pathDef.fixedStart === true && pathDef.startLeft != null && pathDef.startTop != null && pathDef.startPageId; + const pathStrForStart = typeof pathDef.path === 'string' ? pathDef.path : (pathDef.path && pathDef.path.path) || ''; + const pathArrayForStart = pathStrForStart.match(/([UDLR])([0-9]+)|W|S/g); + const randomStep = (pathDef.randomStart && pathArrayForStart && pathArrayForStart.length > 1) + ? Math.floor(Math.random() * pathArrayForStart.length) : 0; + + selected.forEach(function (sel) { + const countOnPath = state[NAME].storedVariables.activeTokenPaths.filter(t => t.pathName === pathName).length; + if (pathDef.maxTokens != null && pathDef.maxTokens > 0 && countOnPath >= pathDef.maxTokens) { + sendChat(`${NAME}`, `/w GM Path "${pathName}" is at max tokens (${pathDef.maxTokens}). Skipping.`); + return; + } + + const token = getObj('graphic', sel._id); + if (!token) { + sendChat(`${NAME}`, "/w GM Please select a token to start a path."); + return; + } + + let tokenLeft, tokenTop, pageId, tokenRotation; + + if (useFixedStart) { + // Move token to the path's fixed start position (and page if needed) + if (token.get('_pageid') !== pathDef.startPageId) { + token.set('_pageid', pathDef.startPageId); + } + token.set({ + left: pathDef.startLeft, + top: pathDef.startTop, + rotation: pathDef.startRotation != null ? pathDef.startRotation : token.get('rotation') + }); + tokenLeft = pathDef.startLeft; + tokenTop = pathDef.startTop; + pageId = pathDef.startPageId; + tokenRotation = pathDef.startRotation != null ? pathDef.startRotation : token.get('rotation'); + } else { + tokenLeft = token.get('left'); + tokenTop = token.get('top'); + pageId = token.get('_pageid'); + tokenRotation = token.get('rotation'); + } + + if (tokenLeft == undefined) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Token Left."); + return; + } + if (tokenTop == undefined) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Token Top."); + return; + } + if (pageId == undefined) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Page ID from Token."); + return; + } + + const page = getObj('page', pageId); + if (!page) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Page."); + return; + } + + const pageName = page.get('name'); + if (pageName == undefined) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Page Name."); + return; + } + + const pageWidth = page.get('width'); + if (pageWidth == undefined) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Page Width."); + return; + } + + const pageHeight = page.get('height'); + if (pageHeight == undefined) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Page Height."); + return; + } + + const pageScale = page.get('scale_number'); + if (pageScale == undefined) { + sendChat(`${NAME}`, "/w GM Unable to retrieve Page Scale."); + return; + } + + if (state[NAME].storedVariables.pageMetas === undefined) { + state[NAME].storedVariables.pageMetas = []; + } + + if (state[NAME].storedVariables.activeTokenPaths === undefined) { + state[NAME].storedVariables.activeTokenPaths = []; + } + + const pageMetaIndex = state[NAME].storedVariables.pageMetas.findIndex(pageMeta => pageMeta.pageId === pageId); + if (pageMetaIndex === -1) { + state[NAME].storedVariables.pageMetas.push({ + pageId: pageId, + pageName: pageName, + pageWidth: pageWidth * state[NAME].storedVariables.unitPx, + pageHeight: pageHeight * state[NAME].storedVariables.unitPx, + pageScaleNumber: pageScale + }); + } else if (state[NAME].storedVariables.pageMetas[pageMetaIndex].pageScaleNumber === undefined) { + state[NAME].storedVariables.pageMetas[pageMetaIndex].pageWidth = pageWidth * state[NAME].storedVariables.unitPx; + state[NAME].storedVariables.pageMetas[pageMetaIndex].pageHeight = pageHeight * state[NAME].storedVariables.unitPx; + state[NAME].storedVariables.pageMetas[pageMetaIndex].pageScaleNumber = pageScale; + } + + const pathStr = typeof pathDef.path === 'string' ? pathDef.path : (pathDef.path && pathDef.path.path) || ''; + const pageWpx = pageWidth * state[NAME].storedVariables.unitPx; + const pageHpx = pageHeight * state[NAME].storedVariables.unitPx; + if (pathStr && pathWouldLeaveBounds(pathStr, tokenLeft, tokenTop, state[NAME].storedVariables.unitPx, pageWpx, pageHpx)) { + sendChat(`${NAME}`, `/w GM Warning: Path "${pathName}" may leave the map from this start position. Token still added.`); + } + + state[NAME].storedVariables.activeTokenPaths.push({ + tokenId: sel._id, + pathName: pathName, + step: randomStep, + subStep: 0, + subStepTotal: 0, + tickCounter: 0, + initialLeft: tokenLeft, + initialTop: tokenTop, + initialRotation: tokenRotation, + isReversing: false, + pageId: pageId, + }); + }); + + createMenu(); + sendChat(`${NAME}`, `/w GM Tokens on Path "${pathName}" have started moving.`); + } + + function stopPaths(selected, name) { + if (!!selected && selected.length > 0) { + let stoppedReportText = "Stopped: { Token Name | Path Name }
"; + + if (!!selected && selected.length > 0) { + for (let i = 0; i < selected.length; i++) { + + // Stop Token's Path + const tokenId = selected[i]['_id']; + const index = state[NAME].storedVariables.activeTokenPaths.findIndex(token => token.tokenId == tokenId); + if (index > -1) { + stoppedReportText += `-- ${getObj('graphic', tokenId).get('name')} | ${state[NAME].storedVariables.activeTokenPaths[index].pathName}
`; + state[NAME].storedVariables.activeTokenPaths.splice(index, 1); + } + + // Stop Token's Follow + const memoryIndex = state[NAME].storedVariables.tokenMemory.findIndex(memory => memory.tokenId == tokenId); + if (memoryIndex > -1) { + stoppedReportText += `-- ${getObj('graphic', tokenId).get('name')} | Following
`; + state[NAME].storedVariables.tokenMemory.splice(memoryIndex, 1); + } + } + } else { + state[NAME].storedVariables.activeTokenPaths = []; + stoppedReportText = "All Tokens have stopped moving."; + } + + createMenu(); + sendChat(`${NAME}`, `/w GM ${stoppedReportText}`); + + return; + } + + if (name !== undefined && name !== "") { + state[NAME].storedVariables.activeTokenPaths = state[NAME].storedVariables.activeTokenPaths.filter(token => token.pathName !== name); + createMenu(); + sendChat(`${NAME}`, `/w GM Tokens on Path "${name}" have stopped moving.`); + } + } + + function lockTokens(selected) { + if (!selected) { + sendChat(`${NAME}`, "/w GM Please select a token to lock."); + return; + } + + let lockReadout = "Locked the following tokens:
"; + selected.forEach(function (sel) { + const token = getObj('graphic', sel._id); + if (!token) return; + + token.set('status_red', true); + + const index = state[NAME].storedVariables.tokenMemory.findIndex(t => t.tokenId === sel._id); + if (index === -1) { + state[NAME].storedVariables.tokenMemory.push({ + tokenId: sel._id, + pageId: token.get('_pageid'), + rotation: token.get('rotation'), + left: token.get('left'), + top: token.get('top'), + followingTokenId: "", + isFollowing: false, + isLocked: true + }); + } else { + state[NAME].storedVariables.tokenMemory[index].isLocked = true; + state[NAME].storedVariables.tokenMemory[index].isFollowing = false; + state[NAME].storedVariables.tokenMemory[index].followingTokenId = ""; + state[NAME].storedVariables.tokenMemory[index].left = token.get('left'); + state[NAME].storedVariables.tokenMemory[index].top = token.get('top'); + state[NAME].storedVariables.tokenMemory[index].rotation = token.get('rotation'); + } + + lockReadout += `-- ${token.get('name')}
`; + }); + + createMenu(); + sendChat(`${NAME}`, `/w GM ${lockReadout}`); + } + + function unlockTokens(selected) { + if (!selected) { + sendChat(`${NAME}`, "/w GM Please select a token to unlock."); + return; + } + + selected.forEach(function (selected) { + const token = getObj('graphic', selected._id); + token.set('status_red', false); + + const index = state[NAME].storedVariables.tokenMemory.findIndex(token => token.tokenId == selected._id); + if (index == -1) { + sendChat(`${NAME}`, `/w GM Token "${selected._id}" is not currently locked.`); + return; + } + + state[NAME].storedVariables.tokenMemory.splice(index, 1); + }); + + createMenu(); + sendChat(`${NAME}`, `/w GM Tokens have been unlocked.`); + } + + function followTokens(followerId, leaderId) { + const followerToken = getObj('graphic', followerId); + const leaderToken = getObj('graphic', leaderId); + if (!followerToken || !leaderToken) { + sendChat(`${NAME}`, `/w GM Invalid token(s) selected.`); + return; + } + + let memory = state[NAME].storedVariables.tokenMemory; + if (memory.find(t => t.tokenId === followerId && t.isFollowing && t.followingTokenId === leaderId)) { + sendChat(`${NAME}`, `/w GM ${followerToken.get('name')} is already following ${leaderToken.get('name')}.`); + return; + } + + const followerIndex = state[NAME].storedVariables.tokenMemory.findIndex(token => token.tokenId == followerId); + + if (followerIndex == -1) { + state[NAME].storedVariables.tokenMemory.push({ + tokenId: followerId, + pageId: followerToken.get('_pageid'), + followingTokenId: leaderId, + rotation: followerToken.get('rotation'), + left: followerToken.get('left'), + top: followerToken.get('top'), + isFollowing: true, + isLocked: false + }); + } else { + state[NAME].storedVariables.tokenMemory[followerIndex].followingTokenId = leaderId; + state[NAME].storedVariables.tokenMemory[followerIndex].isFollowing = true; + state[NAME].storedVariables.tokenMemory[followerIndex].rotation = followerToken.get('rotation'); + state[NAME].storedVariables.tokenMemory[followerIndex].left = followerToken.get('left'); + state[NAME].storedVariables.tokenMemory[followerIndex].top = followerToken.get('top'); + + if (state[NAME].storedVariables.tokenMemory[followerIndex].isLocked) { + followerToken.set('status_red', false); + state[NAME].storedVariables.tokenMemory[followerIndex].isLocked = false; + } + } + + createMenu(); + sendChat(`${NAME}`, `/w GM Token "${followerId}" is now following Token "${leaderId}".`); + } + + function processFollowMovement(leaderId, left, top, rotation) { + const listOfFollowers = state[NAME].storedVariables.tokenMemory.filter(token => token.followingTokenId === leaderId); + const tokenIdsToRemove = []; + + for (let i = 0; i < listOfFollowers.length; i++) { + if (listOfFollowers[i].isLocked) { + continue; + } + + const follower = getObj('graphic', listOfFollowers[i].tokenId); + if (!follower) { + tokenIdsToRemove.push(listOfFollowers[i].tokenId); + continue; + } + + follower.set({ + 'left': left, + 'top': top, + 'rotation': rotation + }); + } + + if (tokenIdsToRemove.length > 0) { + state[NAME].storedVariables.tokenMemory = state[NAME].storedVariables.tokenMemory.filter(t => !tokenIdsToRemove.includes(t.tokenId)); + } + } + + function updateTick(interval) { + const intervalNumber = parseInt(interval, 10); + if (isNaN(intervalNumber)) { + sendChat(`${NAME}`, "/w GM Please specify a valid interval."); + return; + } + + if (intervalNumber < 1) { + sendChat(`${NAME}`, "/w GM Please specify an interval greater than 0."); + return; + } + + state[NAME].storedVariables.interval = intervalNumber < 10 ? intervalNumber * 1000 : intervalNumber; + if (pathTokensIntervalId) clearInterval(pathTokensIntervalId); + pathTokensIntervalId = setInterval(pathTokens, state[NAME].storedVariables.interval); + createMenu(); + if (intervalNumber < 2000 && intervalNumber > 10) { + let dangerBody = new HtmlBuilder('.danger'); + dangerBody.append('.dangerText', "It is recommended to use an interval greater than 2000ms (lag danger zone)."); + + + sendChat(`${NAME}`, '/w GM ' + dangerBody.toString({ + 'danger': { + // red background + 'background-color': '#ff0000', + // white text + 'color': '#ffffff' + } + })); + } + sendChat(`${NAME}`, `/w GM Tick interval set to ${state[NAME].storedVariables.interval} milliseconds.`); + } + + function hideCommands() { + state[NAME].storedVariables.hideCommands = !state[NAME].storedVariables.hideCommands; + createMenu(); + } + + function resetTokens() { + state[NAME].storedVariables.activeTokenPaths.forEach(function (pathEntry) { + const tokenObj = getObj('graphic', pathEntry.tokenId); + if (tokenObj) { + tokenObj.set('left', pathEntry.initialLeft); + tokenObj.set('top', pathEntry.initialTop); + tokenObj.set('rotation', pathEntry.initialRotation); + tokenObj.set('layer', 'objects'); + } + }); + state[NAME].storedVariables.activeTokenPaths = []; + + if (state[NAME].storedVariables.tokenMemory.length > 0) { + state[NAME].storedVariables.tokenMemory.filter(tm => tm.isFollowing).forEach(function (token) { + const tokenObj = getObj('graphic', token.tokenId); + if (tokenObj) { + tokenObj.set('left', token.left); + tokenObj.set('top', token.top); + tokenObj.set('rotation', token.rotation); + tokenObj.set('status_red', false); + tokenObj.set('layer', 'objects'); + } + }); + } + + createMenu(); + sendChat(`${NAME}`, "/w GM All Tokens have been reset."); + } + + function createMenu() { + menuShowCount++; + + let menu = new HtmlBuilder('.menu'); + menu.append('.menuHeader', 'Token Controls'); + + let content = menu.append('div'); + let topRow = content.append('table'); + let tr = topRow.append('tr'); + tr.append('td', '[Usage](!tc usage)'); + tr.append('td', `[\`\`Add examples\`\`](!tc examples)`); + content.append('.subLabel', '!tc may be used in place of !token-control'); + + content.append('.menuLabel', 'Paths'); + content.append('.subLabel', 'Select one or more tokens to start a path'); + let table = content.append('table'); + + for (let i = 0; i < state[NAME].storedVariables.paths.length; i++) { + const path = state[NAME].storedVariables.paths[i]; + + let row = table.append('tr', undefined, { title: path.name }); + + row.append('td', `[${path.name}](!tc List Paths ${path.name})`); + row.append('td', `[\`\`Start\`\`](!tc Start ${path.name})`); + row.append('td', `[\`\`Stop\`\`](!tc Stop ${path.name})`); + row.append('td', `[\`\`Reset\`\`](!tc Reset ${path.name})`); + row.append('td', path.fixedStart + ? `[\`\`Clear start\`\`](!tc clearstart ${path.name})` + : `[\`\`Set start\`\`](!tc setstart ${path.name})`); + row.append('td', `[\`\`Remove\`\`](!tc Remove ${path.name})`); + } + + if (!!state[NAME].storedVariables.pathDrafts && state[NAME].storedVariables.pathDrafts.length > 0) { + content.append('.menuLabel', 'Draft Paths'); + content.append('.subLabel', 'Select one or more tokens to start a draft path'); + table = content.append('table'); + + for (let i = 0; i < state[NAME].storedVariables.pathDrafts.length; i++) { + const path = state[NAME].storedVariables.pathDrafts[i]; + + let row = table.append('tr', undefined, { title: path.name }); + row.append('td', `[${path.name}](!tc List Draft ${path.name})`); + row.append('td', `[\`\`L\`\`](!tc draft ${path.name} L ${state[NAME].storedVariables.unitPerClick})`); + row.append('td', `[\`\`U\`\`](!tc draft ${path.name} U ${state[NAME].storedVariables.unitPerClick})`); + row.append('td', `[\`\`D\`\`](!tc draft ${path.name} D ${state[NAME].storedVariables.unitPerClick})`); + row.append('td', `[\`\`R\`\`](!tc draft ${path.name} R ${state[NAME].storedVariables.unitPerClick})`); + row.append('td', `[\`\`W\`\`](!tc draft ${path.name} W)`); + row.append('td', `[\`\`S\`\`](!tc draft ${path.name} S)`); + row.append('td', `[\`\`Set\`\`](!tc draft ${path.name} Set)`); + row.append('td', `[\`\`Rmv\`\`](!tc draft ${path.name} Rmv)`); + } + + content.append('.menuLabel', `[Units: ${state[NAME].storedVariables.unitPerClick}](!tc unit ?{Units per click |0|1|2|3|4|5|6|7|8|9|10})`); + } + + content.append('.menuLabel', 'Token'); + content.append('.subLabel', 'Select one or more tokens to stop a path'); + table = content.append('table'); + let row = table.append('tr'); + row.append('td', `[\`\`Stop\`\`](!tc Stop)`); + row.append('td', `[\`\`Reset\`\`](!tc Reset)`); + row.append('td', `[\`\`Lock\`\`](!tc Lock)`); + row.append('td', `[\`\`Unlock\`\`](!tc Unlock)`); + + content.append('.menuLabel', 'Interval'); + table = content.append('table'); + row = table.append('tr'); + row.append('td', `[Tick](!tc List Tick)`); + row.append('td', `[\`\`DOWN(s)\`\`](!tc Tick ${state[NAME].storedVariables.interval - 1000})`); + row.append('td', `[\`\`DOWN(ms)\`\`](!tc Tick ${state[NAME].storedVariables.interval - 100})`); + row.append('td', `[\`\`UP(ms)\`\`](!tc Tick ${state[NAME].storedVariables.interval + 100})`); + row.append('td', `[\`\`UP(s)\`\`](!tc Tick ${state[NAME].storedVariables.interval + 1000})`); + + content.append('.menuLabel', 'Pixels Per Unit'); + table = content.append('table'); + row = table.append('tr'); + row.append('td', `[\`\`${state[NAME].storedVariables.unitPx}\`\`](!tc unitpx ?{Pixels per Grid Unit?})`); + row.append('td', `[\`\`Default\`\`](!tc unitpx 70)`); + + content.append('.menuLabel', 'Rotate Tokens'); + table = content.append('table'); + row = table.append('tr'); + row.append('td', `[\`\`Toggle ${state[NAME].storedVariables.rotateTokens ? 'Off' : 'On'}\`\`](!tc rotate)`); + + // Combat Patroling + content.append('.menuLabel', 'Combat Patrol'); + content.append('.subLabel', 'Patrols pause during combat - this setting moves tokens on turn order change when enabled.'); + table = content.append('table'); + row = table.append('tr'); + row.append('td', `[\`\`Toggle ${state[NAME].storedVariables.combatPatrol ? 'Off' : 'On'}\`\`](!tc combat)`); + + content.append('.menuLabel', 'Smooth movement (sub-steps)'); + content.append('.subLabel', 'When on, long moves (e.g. U3) are done 1 unit per tick for smoother motion.'); + table = content.append('table'); + row = table.append('tr'); + row.append('td', `[\`\`Toggle ${state[NAME].storedVariables.subStepMovement ? 'Off' : 'On'}\`\`](!tc substeps)`); + + if (listingVars) { + content.append('.menuLabel', 'Variables'); + content.append('.subLabel', 'Current variables live in script state.'); + // Create a table for Token Memory Variables + table = content.append('table', 'Token Memory'); + row = table.append('tr'); // Headers + row.append('td', 'Token'); + row.append('td', 'ID'); + row.append('td', 'Page'); + row.append('td', 'Rotation'); + row.append('td', 'Left'); + row.append('td', 'Top'); + row.append('td', 'Following Token'); + row.append('td', 'Is Following'); + row.append('td', 'Is Locked'); + + for (let i = 0; i < state[NAME].storedVariables.tokenMemory.length; i++) { + const token = state[NAME].storedVariables.tokenMemory[i]; + const obj = getObj('graphic', token.tokenId); + if (!obj) continue; + + row = table.append('tr'); + row.append('td', `${obj.get('name')}`); + row.append('td', `${token.tokenId}`); + row.append('td', `${token.pageId}`); + row.append('td', `${token.rotation}`); + row.append('td', `${token.left}`); + row.append('td', `${token.top}`); + row.append('td', `${token.followingTokenId}`); + row.append('td', `${token.isFollowing}`); + row.append('td', `${token.isLocked}`); + } + + // Create a table for Active Token Paths + table = content.append('table', 'Active Token Paths'); + row = table.append('tr'); // Headers + row.append('td', 'Token'); + row.append('td', 'ID'); + row.append('td', 'Path'); + row.append('td', 'Step'); + + for (let i = 0; i < state[NAME].storedVariables.activeTokenPaths.length; i++) { + const tokenPath = state[NAME].storedVariables.activeTokenPaths[i]; + row = table.append('tr'); + row.append('td', `${getObj('graphic', tokenPath.tokenId).get('name')}`); + row.append('td', `${tokenPath.tokenId}`); + row.append('td', `${tokenPath.pathName}`); + row.append('td', `${tokenPath.step}`); + } + + // Create a table for Path Drafts + table = content.append('table', 'Path Drafts'); + row = table.append('tr'); // Headers + row.append('td', 'Name'); + row.append('td', 'TokenId'); + row.append('td', 'Path'); + row.append('td', 'Step'); + row.append('td', 'Prev'); + row.append('td', 'Curr'); + + for (let i = 0; i < state[NAME].storedVariables.pathDrafts.length; i++) { + const pathDraft = state[NAME].storedVariables.pathDrafts[i]; + row = table.append('tr'); + row.append('td', `${pathDraft.name}`); + row.append('td', `${pathDraft.tokenId}`); + row.append('td', `${pathDraft.path}`); + row.append('td', `${pathDraft.step}`); + if (pathDraft.prevStep) { + row.append('td', `${pathDraft.previousStep.direction + pathDraft.previousStep.distance}`); + } + if (pathDraft.currentStep) { + row.append('td', `${pathDraft.currentStep.direction + pathDraft.currentStep.distance}`); + } + } + + listingVars = false; + } + + menu.append('.patreon', '[``Become a Patron``](https://www.patreon.com/bePatron?u=23167000)'); + + // Version & Menu Show Count + menu.append('.subLabel', `V.${VERSION} - ${menuShowCount}`) + + sendChat(`${NAME}`, '/w GM ' + menu.toString({ + 'optionsTable': { + 'width': '100%' + }, + 'menu': { + 'background': '#33658A', + 'border': 'solid 1px #000', + 'border-radius': '5px', + 'font-weight': 'bold', + 'margin-bottom': '1em', + 'overflow': 'hidden', + 'color': '#fff', + 'justify-content': 'space-evenly', + }, + 'menuBody': { + 'padding': '5px', + 'text-align': 'center' + }, + 'menuLabel': { + 'color': '#F6AE2D', + 'margin-top': '5px', + }, + 'menuHeader': { + 'background': '#000', + 'color': '#fff', + 'text-align': 'center', + }, + 'usageHeader': { + 'background': '#000', + 'color': '#fff', + 'text-align': 'center', + }, + 'usage': { + 'background': '#000', + 'color': '#fff', + 'text-align': 'center', + }, + 'subLabel': { + 'color': '#F26419', + 'font-size': '0.8em', + }, + 'patreon': { + 'color': '#F6AE2D', + 'font-size': '1.1em', + 'text-align': 'center', + 'margin-top': '10px', + 'margin-bottom': '10px' + } + })); + } + + function toggleStealth(tokenId) { + try { + const token = getObj('graphic', tokenId); + const currentLayer = token.get('layer'); + const newLayer = currentLayer === 'objects' ? 'gmlayer' : 'objects'; + token.set('layer', newLayer); + } + catch (e) { + sendChat(`${NAME}`, `/w GM Toggle Stealth - Token not found.`); + } + } + + return {}; +})(); + +{ try { throw new Error(''); } catch (e) { API_Meta.TokenController.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.TokenController.offset); } } From c237dce4031a8efc091cda71c8f3f57a3fc51144 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Thu, 29 Oct 2026 06:24:22 +0000 Subject: [PATCH 02/14] chore(TokenController): Add the Rollup build tooling Version 5 is written as ES modules and bundled with Rollup, in line with Hexploration and the other reworked Mods. Add the build, version bump and locale sync scripts, Prettier settings, and the npm package that runs them. The released version folders and the generated script.json are excluded from formatting. Signed-off-by: Steve Roberts --- TokenController/.gitattributes | 9 + TokenController/.gitignore | 5 + TokenController/.prettierignore | 14 + TokenController/.prettierrc.json | 5 + TokenController/package-lock.json | 509 ++++++++++++++++ TokenController/package.json | 20 + TokenController/rollup.config.js | 110 ++++ TokenController/scripts/build.mjs | 82 +++ TokenController/scripts/bump-version.mjs | 83 +++ TokenController/scripts/sync-locales.mjs | 724 +++++++++++++++++++++++ 10 files changed, 1561 insertions(+) create mode 100644 TokenController/.gitattributes create mode 100644 TokenController/.gitignore create mode 100644 TokenController/.prettierignore create mode 100644 TokenController/.prettierrc.json create mode 100644 TokenController/package-lock.json create mode 100644 TokenController/package.json create mode 100644 TokenController/rollup.config.js create mode 100644 TokenController/scripts/build.mjs create mode 100644 TokenController/scripts/bump-version.mjs create mode 100644 TokenController/scripts/sync-locales.mjs diff --git a/TokenController/.gitattributes b/TokenController/.gitattributes new file mode 100644 index 0000000000..3f638fbc97 --- /dev/null +++ b/TokenController/.gitattributes @@ -0,0 +1,9 @@ +# Enforce LF line endings for all text files in this project +* text=auto eol=lf + +# Treat these as binary to avoid any line-ending normalisation +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary diff --git a/TokenController/.gitignore b/TokenController/.gitignore new file mode 100644 index 0000000000..a48cb1b60c --- /dev/null +++ b/TokenController/.gitignore @@ -0,0 +1,5 @@ +node_modules + +*.zip +*alpha* +*beta* diff --git a/TokenController/.prettierignore b/TokenController/.prettierignore new file mode 100644 index 0000000000..a39e576699 --- /dev/null +++ b/TokenController/.prettierignore @@ -0,0 +1,14 @@ +node_modules +package-lock.json + +# Written by scripts/bump-version.mjs. +script.json + +# Released versions by the original author; never edit them. +1.0.0/ +2.1.1/ +2.1.3/ +2.2.0/ +3.0.0/ +4.0.0/ +4.0.1/ diff --git a/TokenController/.prettierrc.json b/TokenController/.prettierrc.json new file mode 100644 index 0000000000..d440171380 --- /dev/null +++ b/TokenController/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/TokenController/package-lock.json b/TokenController/package-lock.json new file mode 100644 index 0000000000..74e622542e --- /dev/null +++ b/TokenController/package-lock.json @@ -0,0 +1,509 @@ +{ + "name": "tokencontroller-roll20", + "version": "5.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tokencontroller-roll20", + "version": "5.0.0", + "devDependencies": { + "prettier": "^3.5.3", + "rollup": "^4.34.8" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "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.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + } + } +} diff --git a/TokenController/package.json b/TokenController/package.json new file mode 100644 index 0000000000..ddf82064fa --- /dev/null +++ b/TokenController/package.json @@ -0,0 +1,20 @@ +{ + "name": "tokencontroller-roll20", + "version": "5.0.0", + "description": "Roll20 API Mod that moves tokens along patrol routes, makes tokens follow each other and locks tokens in place.", + "private": true, + "type": "module", + "scripts": { + "build": "node scripts/build.mjs", + "set-version": "node scripts/bump-version.mjs", + "watch": "node scripts/build.mjs --watch", + "test": "node --test \"tests/*.test.mjs\"", + "format": "prettier --write \"TokenController.js\" \"rollup.config.js\" \"src/**/*.js\" \"tests/**/*.mjs\" \"*.md\" \"scripts/**/*.mjs\"", + "sync-locales": "node scripts/sync-locales.mjs", + "regenerate-locales": "node scripts/sync-locales.mjs --regenerate" + }, + "devDependencies": { + "prettier": "^3.5.3", + "rollup": "^4.34.8" + } +} diff --git a/TokenController/rollup.config.js b/TokenController/rollup.config.js new file mode 100644 index 0000000000..cbb7fce5cf --- /dev/null +++ b/TokenController/rollup.config.js @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { format as prettierFormat, resolveConfig } from 'prettier'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const scriptJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'script.json'), 'utf8')); +const buildTimestamp = new Date().toISOString(); +const scriptName = scriptJson.name; +const scriptFile = scriptJson.script; +const buildVersion = scriptJson.version; + +const banner = [ + '/**', + ' * NOTE: GENERATED FILE - DO NOT EDIT DIRECTLY.', + ' * NOTE: Source files live under src/ and are bundled with `npm run build`.', + ' * ------------------------------------------------', + ` * Name: ${scriptName}`, + ` * Script: ${scriptFile}`, + ` * Version: ${buildVersion}`, + ` * Built: ${buildTimestamp}`, + ' */', +].join('\n'); + +/** + * Formats generated JavaScript chunks using the project's .prettierrc.json config. + * + * @returns {import("rollup").Plugin} Rollup output plugin. + */ +function formatOutputPlugin() { + return { + name: 'format-output', + /** + * Formats each emitted chunk with Prettier, resolving options from + * .prettierrc.json so the config file is the single source of truth. + * + * @param {import("rollup").NormalizedOutputOptions} options Finalized output options. + * @param {import("rollup").OutputBundle} bundle Emitted output bundle. + * @returns {Promise} + */ + async generateBundle(options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') { + continue; + } + + const fileConfig = (await resolveConfig(options.file)) ?? {}; + output.code = await prettierFormat(output.code, { + ...fileConfig, + parser: 'babel', + }); + } + }, + }; +} + +// The IIFE binding is named TokenControllerMod so the public API can be published +// as globalThis.TokenController without a lexical name clash. +const iifeName = scriptName.replace(/[^a-zA-Z0-9]/g, '') + 'Mod'; + +/** @type {import("rollup").RollupOptions} */ +export default { + input: path.join(__dirname, 'src', 'index.js'), + plugins: [ + { + name: 'inject-build-metadata', + /** + * Replaces metadata placeholders in constants.js with build-time values. + * + * @param {string} code Module source code. + * @param {string} id Absolute module id. + * @returns {{code: string, map: null} | null} + */ + transform(code, id) { + if (!id.endsWith(path.join('src', 'constants.js'))) { + return null; + } + + return { + code: code + .replaceAll('__SCRIPT_NAME__', scriptName) + .replaceAll('__SCRIPT_FILE__', scriptFile) + .replaceAll('__BUILD_VERSION__', buildVersion) + .replaceAll('__BUILD_DATE__', buildTimestamp), + map: null, + }; + }, + }, + ], + output: [ + { + file: path.join(__dirname, scriptFile), + format: 'es', + banner, + intro: `const ${iifeName} = (() => {\n 'use strict';`, + outro: '})();', + plugins: [formatOutputPlugin()], + }, + { + file: path.join(__dirname, buildVersion, scriptFile), + format: 'es', + banner, + intro: `const ${iifeName} = (() => {\n 'use strict';`, + outro: '})();', + plugins: [formatOutputPlugin()], + }, + ], +}; diff --git a/TokenController/scripts/build.mjs b/TokenController/scripts/build.mjs new file mode 100644 index 0000000000..9cb73ab9de --- /dev/null +++ b/TokenController/scripts/build.mjs @@ -0,0 +1,82 @@ +/** + * Builds TokenController.js from src/ using Rollup. + * + * Version bumping is handled here (not via a package.json prebuild hook) so + * that an explicit version can be passed cleanly through npm run build. + * + * Usage: + * npm run build โ†’ auto-bump patch version, then build + * npm run build -- 1.1.0 โ†’ set version to 1.1.0, then build + * npm run build -- 1.1.0-alpha.1 โ†’ enter prerelease cycle, then build + * npm run build -- --watch โ†’ watch mode (no version bump) + * npm run build -- --no-bump โ†’ build without changing the version + * node scripts/build.mjs โ†’ same as npm run build (auto-bump) + * node scripts/build.mjs 1.1.0 โ†’ same as npm run build -- 1.1.0 + */ + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { rollup, watch as rollupWatch } from 'rollup'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.join(__dirname, '..'); + +const isWatch = process.argv.includes('--watch'); +const noBump = process.argv.includes('--no-bump'); +// Pick the first argument that doesn't start with '--' as an explicit version. +const versionArg = process.argv.slice(2).find((a) => !a.startsWith('--')); + +async function loadRollupConfig() { + // Load config lazily after any version bump and bypass ESM cache so + // rollup.config.js re-reads script.json with the latest version. + const configPath = path.join(rootDir, 'rollup.config.js'); + const configUrl = pathToFileURL(configPath); + configUrl.searchParams.set('t', Date.now().toString()); + const mod = await import(configUrl.href); + return mod.default; +} + +// Bump (or set) the version before every non-watch, non-no-bump build. +if (!isWatch && !noBump) { + const bumpArgs = versionArg ? [versionArg] : []; + const result = spawnSync(process.execPath, ['scripts/bump-version.mjs', ...bumpArgs], { + cwd: rootDir, + stdio: 'inherit', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +async function build() { + const config = await loadRollupConfig(); + const bundle = await rollup(config); + for (const output of config.output) { + await bundle.write(output); + console.log(`Built: ${path.relative(rootDir, output.file)}`); + } + await bundle.close(); +} + +if (isWatch) { + const config = await loadRollupConfig(); + const watcher = rollupWatch(config.output.map((output) => ({ ...config, output }))); + + watcher.on('event', (event) => { + if (event.code === 'BUNDLE_END') { + console.log(`Rebuilt at ${new Date().toLocaleTimeString()}`); + event.result?.close(); + } + if (event.code === 'ERROR') { + console.error('Build error:', event.error.message); + } + }); + + console.log('Watching for changes...'); +} else { + build().catch((err) => { + console.error('Build failed:', err.message); + process.exit(1); + }); +} diff --git a/TokenController/scripts/bump-version.mjs b/TokenController/scripts/bump-version.mjs new file mode 100644 index 0000000000..be579509f7 --- /dev/null +++ b/TokenController/scripts/bump-version.mjs @@ -0,0 +1,83 @@ +/** + * Increments the version in script.json and package.json. + * + * Auto-bump behaviour (no argument): + * - Stable release (e.g. 1.0.0) โ†’ increments patch: 1.0.1 + * - Prerelease with number โ†’ increments prerelease: 1.0.0-alpha.3 โ†’ 1.0.0-alpha.4 + * - Prerelease without number โ†’ adds .1: 1.0.0-alpha โ†’ 1.0.0-alpha.1 + * + * Explicit version (node scripts/bump-version.mjs ): + * - Sets exactly the given version string. + * - Use this to enter a prerelease cycle: node scripts/bump-version.mjs 1.1.0-alpha.1 + * - Use this to cut a release: node scripts/bump-version.mjs 1.1.0 + * + * When building prerelease versions (alpha/beta), always pass an explicit version + * rather than relying on auto-bump โ€” otherwise each `npm run build` will keep + * incrementing the prerelease counter instead of cutting the final release. + * To promote a prerelease to a stable release, set the version explicitly: + * npm run build -- 1.0.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const scriptJsonPath = path.join(__dirname, '..', 'script.json'); +const packageJsonPath = path.join(__dirname, '..', 'package.json'); + +const scriptJson = JSON.parse(fs.readFileSync(scriptJsonPath, 'utf8')); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + +const explicitVersion = process.argv[2]; +const currentVersion = scriptJson.version; +let nextVersion; + +if (explicitVersion) { + nextVersion = explicitVersion; +} else { + const current = currentVersion; + const dashIdx = current.indexOf('-'); + + if (dashIdx === -1) { + // Stable: 1.0.0 โ†’ 1.0.1 + const [major, minor, patch] = current.split('.').map(Number); + if (!Number.isFinite(patch)) { + console.error( + `ERROR: Cannot auto-bump malformed version "${current}". Use an explicit version.` + ); + process.exit(1); + } + nextVersion = `${major}.${minor}.${patch + 1}`; + } else { + // Prerelease: 1.0.0-alpha.3 โ†’ 1.0.0-alpha.4 / 1.0.0-alpha โ†’ 1.0.0-alpha.1 + const semver = current.slice(0, dashIdx); + const prerelease = current.slice(dashIdx + 1); + const trailingNumber = prerelease.match(/^(.*?)(\d+)$/); + if (trailingNumber) { + nextVersion = `${semver}-${trailingNumber[1]}${Number(trailingNumber[2]) + 1}`; + } else { + nextVersion = `${semver}-${prerelease}.1`; + } + } +} + +const isPrerelease = (version) => version.includes('-'); + +// Append current stable version to history only when the version actually +// changes (oldest-first, matching Roll20 corpus convention), deduplicate, then +// keep the last 5. Prerelease versions are intentionally skipped. +const historySeed = (scriptJson.previousversions || []).filter((v) => !isPrerelease(v)); +const shouldAppendCurrentStable = !isPrerelease(currentVersion) && currentVersion !== nextVersion; +const nextHistory = shouldAppendCurrentStable + ? [...new Set([...historySeed, currentVersion])].slice(-5) + : [...new Set(historySeed)].slice(-5); + +scriptJson.previousversions = nextHistory; +scriptJson.version = nextVersion; +packageJson.version = nextVersion; + +fs.writeFileSync(scriptJsonPath, JSON.stringify(scriptJson, null, 2) + '\n'); +fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); + +console.log(`Version set to ${nextVersion}`); diff --git a/TokenController/scripts/sync-locales.mjs b/TokenController/scripts/sync-locales.mjs new file mode 100644 index 0000000000..d9c514edfc --- /dev/null +++ b/TokenController/scripts/sync-locales.mjs @@ -0,0 +1,724 @@ +import console from 'node:console'; +import { readdirSync, statSync, writeFileSync } from 'node:fs'; +import https from 'node:https'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import prettier from 'prettier'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const LOCALE_DIR = resolve(__dirname, '../src/locales/locale'); +const SOURCE_LOCALE = 'en-US'; +const GOOGLE_TRANSLATE_HOST = 'translate.googleapis.com'; +const GOOGLE_TRANSLATE_PATH = '/translate_a/single?client=gtx'; + +/** + * @typedef {{ timeoutMs: number, maxRetries: number, retryDelayMs: number, interRequestDelayMs: number }} TranslateConfig + */ + +/** @type {TranslateConfig} */ +const SYNC_CONFIG = { + timeoutMs: 30000, + maxRetries: 2, + retryDelayMs: 8000, + interRequestDelayMs: 2000, +}; + +/** @type {TranslateConfig} */ +const REGENERATE_CONFIG = { + timeoutMs: 60000, + maxRetries: 4, + retryDelayMs: 10000, + interRequestDelayMs: 3000, +}; + +const PLACEHOLDER_PATTERN = /(\{[^{}]+\}|<[^&]+>|--[a-zA-Z0-9_-]+)/g; + +/** + * Loads a locale module from disk and returns its translation object. + * + * @param {string} localeCode Locale code to load from `src/locales/locale`. + * @returns {Promise>} Parsed translation object exported by the locale module. + */ +async function loadLocale(localeCode) { + const filePath = resolve(LOCALE_DIR, `${localeCode}.js`); + const moduleUrl = pathToFileURL(filePath).href; + const loaded = await import(moduleUrl); + return loaded.default; +} + +/** + * Replaces templated tokens with stable placeholders before translation. + * + * @param {string} text Source string that may include placeholders like `{name}`. + * @returns {{ masked: string, placeholders: string[] }} Masked string and placeholder list for later restoration. + */ +function maskPlaceholders(text) { + const placeholders = []; + const masked = text.replaceAll(PLACEHOLDER_PATTERN, (match) => { + const token = `__TC_PLACEHOLDER_${placeholders.length}__`; + placeholders.push(match); + return token; + }); + return { masked, placeholders }; +} + +/** + * Restores templated tokens after translation has completed. + * + * @param {string} text Translated string containing placeholder tokens. + * @param {string[]} placeholders Original placeholders captured before translation. + * @returns {string} Restored string with original placeholders reinserted. + */ +function unmaskPlaceholders(text, placeholders) { + return placeholders.reduce( + (restored, placeholder, index) => + restored + .replaceAll(`__TC_PLACEHOLDER_${index}__`, placeholder) + .replaceAll(`__TC_PLACEHOLDER_${index}__`, placeholder), + text + ); +} + +/** + * Fetches translated payload JSON from Google Translate. + * + * @param {string} pathnameWithQuery Request path and query string for the fixed translate host. + * @param {number} timeoutMs Request timeout in milliseconds. + * @returns {Promise} Parsed JSON payload from the translate endpoint. + */ +function fetchTranslatePayload(pathnameWithQuery, timeoutMs) { + return new Promise((resolvePayload, rejectPayload) => { + const request = https.request( + { + hostname: GOOGLE_TRANSLATE_HOST, + method: 'GET', + path: pathnameWithQuery, + }, + (response) => { + const statusCode = response.statusCode ?? 0; + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + if (statusCode < 200 || statusCode >= 300) { + rejectPayload(new Error(`HTTP ${statusCode}`)); + return; + } + try { + resolvePayload(JSON.parse(body)); + } catch (error) { + rejectPayload(error); + } + }); + } + ); + + request.setTimeout(timeoutMs, () => { + request.destroy(new Error('Request timed out')); + }); + request.on('error', (error) => rejectPayload(error)); + request.end(); + }); +} + +/** + * Translates a single English string into a target locale with retry logic. + * + * @param {string} text English source text to translate. + * @param {string} targetLocale Destination locale code. + * @param {Map} cache In-memory cache for locale/text translation results. + * @param {TranslateConfig} config Translation configuration. + * @param {number} [attempt=0] Current retry attempt number. + * @returns {Promise} Translated string (or original text if all retries fail). + */ +async function translateSingle(text, targetLocale, cache, config, attempt = 0) { + if (!text.trim()) { + return text; + } + + const cacheKey = `${targetLocale}::${text}`; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const { masked, placeholders } = maskPlaceholders(text); + const query = `&sl=en&tl=${encodeURIComponent(targetLocale)}&dt=t&q=${encodeURIComponent(masked)}`; + const requestPath = `${GOOGLE_TRANSLATE_PATH}${query}`; + + // Add inter-request delay to avoid rate limiting. + await new Promise((resolve) => setTimeout(resolve, config.interRequestDelayMs)); + + try { + const payload = await fetchTranslatePayload(requestPath, config.timeoutMs); + const translatedMasked = Array.isArray(payload?.[0]) + ? payload[0].map((part) => part?.[0] || '').join('') + : text; + + const translated = unmaskPlaceholders(translatedMasked, placeholders); + cache.set(cacheKey, translated); + return translated; + } catch (error) { + if (attempt < config.maxRetries && error.message.includes('timed out')) { + const delayMs = config.retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return translateSingle(text, targetLocale, cache, config, attempt + 1); + } + + if (attempt === config.maxRetries) { + console.error( + `[sync-locales] Translation failed for ${targetLocale} after ${config.maxRetries} retries: ${error.message}` + ); + } + cache.set(cacheKey, text); + return text; + } +} + +/** + * Creates a mutable translation placeholder that is finalized later. + * + * @param {string} sourceValue English source value that needs translation. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Task list used to backfill translated values. + * @returns {{ readonly value: string }} Deferred wrapper whose `value` is set after translation tasks complete. + */ +function createDeferredTranslation(sourceValue, translationTasks) { + let translatedValue = sourceValue; + translationTasks.push({ + text: sourceValue, + set: (value) => { + translatedValue = value; + }, + }); + return { + get value() { + return translatedValue; + }, + }; +} + +/** + * Counts the number of strings that need translation in a locale. + * + * @param {unknown} sourceValue Source locale node from `en-US`. + * @param {unknown} targetValue Target locale node at the same path. + * @param {boolean} [forceRetranslate=false] Whether to count already-translated strings. + * @returns {number} Count of strings needing translation. + */ +function countTranslationNeeds(sourceValue, targetValue, forceRetranslate = false) { + if (typeof sourceValue === 'string') { + return forceRetranslate || typeof targetValue !== 'string' || targetValue === sourceValue + ? 1 + : 0; + } + + if (Array.isArray(sourceValue)) { + const targetArray = Array.isArray(targetValue) ? targetValue : []; + return sourceValue.reduce( + (sum, item, index) => sum + countTranslationNeeds(item, targetArray[index], forceRetranslate), + 0 + ); + } + + if (sourceValue && typeof sourceValue === 'object') { + const targetObj = + targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue) + ? /** @type {Record} */ (targetValue) + : {}; + return Object.keys(/** @type {Record} */ (sourceValue)).reduce( + (sum, key) => + sum + + countTranslationNeeds( + /** @type {Record} */ (sourceValue)[key], + targetObj[key], + forceRetranslate + ), + 0 + ); + } + + return 0; +} + +/** + * Synchronizes array entries recursively against source locale structure. + * + * @param {unknown[]} sourceArray Source array from `en-US`. + * @param {unknown} targetValue Current locale value for the same key path. + * @param {{ missing: number, translated: number }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @param {(sourceValue: unknown, targetValue: unknown, stats: { missing: number, translated: number }, translationTasks: Array<{ set: (value: string) => void, text: string }>) => unknown} syncEntry Recursive sync function used for nested values. + * @returns {unknown[]} Synchronized array preserving source structure. + */ +function syncArray(sourceArray, targetValue, stats, translationTasks, syncEntry) { + const targetArray = Array.isArray(targetValue) ? targetValue : []; + const synced = []; + + for (let index = 0; index < sourceArray.length; index += 1) { + if (index >= targetArray.length) { + stats.missing += 1; + } + + synced.push(syncEntry(sourceArray[index], targetArray[index], stats, translationTasks)); + } + + return synced; +} + +/** + * Synchronizes object keys recursively against the source schema. + * Keys present in the target but absent from the source are pruned (counted in stats.pruned). + * + * @param {Record} sourceObject Source object from `en-US`. + * @param {unknown} targetValue Current locale value for the same object path. + * @param {{ missing: number, translated: number, pruned: number }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @param {(sourceValue: unknown, targetValue: unknown, stats: { missing: number, translated: number, pruned: number }, translationTasks: Array<{ set: (value: string) => void, text: string }>) => unknown} syncEntry Recursive sync function used for nested values. + * @returns {Record} Synchronized object with schema-only keys retained. + */ +function syncObject(sourceObject, targetValue, stats, translationTasks, syncEntry) { + const targetObject = + targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue) + ? /** @type {Record} */ (targetValue) + : {}; + const synced = {}; + + for (const key of Object.keys(sourceObject)) { + if (!(key in targetObject)) { + stats.missing += 1; + } + + synced[key] = syncEntry(sourceObject[key], targetObject[key], stats, translationTasks); + } + + for (const key of Object.keys(targetObject)) { + if (!(key in synced)) { + stats.pruned += 1; + } + } + + return synced; +} + +/** + * Synchronizes a single string value against the English source string. + * + * @param {unknown} sourceValue Source locale value expected to be a string. + * @param {unknown} targetValue Target locale value to compare. + * @param {{ missing: number, translated: number, forceRetranslate: boolean }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @returns {unknown} Existing localized string or deferred translated value wrapper. + */ +function syncStringValue(sourceValue, targetValue, stats, translationTasks) { + if (typeof targetValue !== 'string') { + stats.missing += 1; + stats.translated += 1; + return createDeferredTranslation(sourceValue, translationTasks); + } + + if (stats.forceRetranslate || targetValue === sourceValue) { + stats.translated += 1; + return createDeferredTranslation(sourceValue, translationTasks); + } + + return targetValue; +} + +/** + * Recursively synchronizes any translation node (string, array, object, or primitive). + * + * @param {unknown} sourceValue Source locale node from `en-US`. + * @param {unknown} targetValue Target locale node at the same path. + * @param {{ missing: number, translated: number }} stats Mutable counters for reporting sync actions. + * @param {Array<{ set: (value: string) => void, text: string }>} translationTasks Deferred translation tasks collected during traversal. + * @returns {unknown} Synchronized node value. + */ +function syncValue(sourceValue, targetValue, stats, translationTasks) { + if (typeof sourceValue === 'string') { + return syncStringValue(sourceValue, targetValue, stats, translationTasks); + } + + if (Array.isArray(sourceValue)) { + return syncArray(sourceValue, targetValue, stats, translationTasks, syncValue); + } + + if (sourceValue && typeof sourceValue === 'object') { + return syncObject( + /** @type {Record} */ (sourceValue), + targetValue, + stats, + translationTasks, + syncValue + ); + } + + return targetValue ?? sourceValue; +} + +/** + * Resolves deferred translation placeholders into plain serializable values. + * + * @param {unknown} value Node value that may contain deferred translation wrappers. + * @returns {unknown} Fully materialized value safe for JSON serialization. + */ +function finalizeValue(value) { + if (Array.isArray(value)) { + return value.map((entry) => finalizeValue(entry)); + } + + if (value && typeof value === 'object') { + if ('value' in value && Object.keys(value).length === 1) { + return value.value; + } + + const finalized = {}; + for (const [key, entryValue] of Object.entries(value)) { + finalized[key] = finalizeValue(entryValue); + } + return finalized; + } + + return value; +} + +/** + * Writes a locale translation object to its locale module file and formats it with Prettier. + * + * @param {string} localeCode Locale code for the output file name. + * @param {Record} localeData Synchronized translation data to persist. + * @returns {Promise} Resolves after the file has been written and formatted. + */ +async function writeLocale(localeCode, localeData) { + const filePath = resolve(LOCALE_DIR, `${localeCode}.js`); + const content = `const TRANSLATION = ${JSON.stringify(localeData, null, 2)};\n\nexport default TRANSLATION;\n`; + writeFileSync(filePath, content, 'utf8'); + const formatted = await prettier.format(content, { filepath: filePath }); + writeFileSync(filePath, formatted, 'utf8'); +} + +/** + * Processes translation tasks for a single locale and writes the result to disk. + * + * @param {string} localeCode Locale code to process. + * @param {Record} source Source locale data from `en-US`. + * @param {Record} localeData Current locale data. + * @param {Map} cache In-memory cache for translation results. + * @param {TranslateConfig} config Translation configuration. + * @param {boolean} forceRetranslate Whether to re-translate already-translated strings. + * @returns {Promise} + */ +async function processLocale(localeCode, source, localeData, cache, config, forceRetranslate) { + const stats = { missing: 0, translated: 0, pruned: 0, forceRetranslate }; + const translationTasks = []; + const syncedDraft = syncValue(source, localeData, stats, translationTasks); + + if (translationTasks.length === 0 && stats.pruned === 0) { + console.info(`[sync-locales] ${localeCode}: up to date`); + return; + } + + const uniqueTexts = [...new Set(translationTasks.map((task) => task.text))]; + const translatedByText = new Map(); + + for (const text of uniqueTexts) { + const translated = await translateSingle(text, localeCode, cache, config); + translatedByText.set(text, translated); + } + + for (const task of translationTasks) { + task.set(translatedByText.get(task.text) ?? task.text); + } + + const synced = finalizeValue(syncedDraft); + await writeLocale(localeCode, synced); + + const prunedNote = stats.pruned > 0 ? `, pruned ${stats.pruned}` : ''; + console.info( + `[sync-locales] ${localeCode}: missing ${stats.missing}, translated ${stats.translated}${prunedNote}` + ); +} + +/** + * Synchronizes all non-English locale files against `en-US`. + * Processes locales in order of most strings needing translation first, + * then oldest by last-modified date, so interrupted runs make the most progress. + * + * @param {string} [localeFilter] Optional locale code to limit sync to a single locale. + * @returns {Promise} Resolves after all targeted locale files have been synchronized and written. + */ +export async function syncLocales(localeFilter) { + const files = getLocaleCodes(); + + if (localeFilter === SOURCE_LOCALE) { + console.error( + `[sync-locales] ${SOURCE_LOCALE} is the source locale and cannot be synchronized.` + ); + process.exitCode = 1; + return; + } + + const source = await loadLocale(SOURCE_LOCALE); + let targetLocaleCodes = files.filter((code) => code !== SOURCE_LOCALE); + + if (localeFilter) { + targetLocaleCodes = targetLocaleCodes.filter((code) => code === localeFilter); + if (targetLocaleCodes.length === 0) { + console.error(`[sync-locales] No locale found matching: ${localeFilter}`); + process.exitCode = 1; + return; + } + } + + // Pre-scan all locales to determine priority ordering without making any HTTP requests. + const localeMetadata = await Promise.all( + targetLocaleCodes.map(async (localeCode) => { + const filePath = resolve(LOCALE_DIR, `${localeCode}.js`); + const [localeData, { mtimeMs }] = [await loadLocale(localeCode), statSync(filePath)]; + const needsTranslation = countTranslationNeeds(source, localeData); + return { localeCode, localeData, mtimeMs, needsTranslation }; + }) + ); + + // Sort: most strings needing translation first, then oldest file as tiebreaker. + localeMetadata.sort((a, b) => { + if (b.needsTranslation !== a.needsTranslation) { + return b.needsTranslation - a.needsTranslation; + } + return a.mtimeMs - b.mtimeMs; + }); + + const cache = new Map(); + + for (const { localeCode, localeData } of localeMetadata) { + await processLocale(localeCode, source, localeData, cache, SYNC_CONFIG, false); + } +} + +/** + * Regenerates translations for all (or a specific) locale, re-translating every string + * regardless of whether it already has a translation. Uses longer timeouts. + * + * @param {string} [localeFilter] Optional locale code to limit regeneration to a single locale. + * @returns {Promise} Resolves after all targeted locale files have been regenerated. + */ +export async function regenerateLocales(localeFilter) { + const files = readdirSync(LOCALE_DIR) + .filter((file) => file.endsWith('.js')) + .map((file) => file.replace(/\.js$/u, '')); + + if (localeFilter === SOURCE_LOCALE) { + console.error( + `[sync-locales] ${SOURCE_LOCALE} is the source locale and cannot be regenerated.` + ); + process.exitCode = 1; + return; + } + + const source = await loadLocale(SOURCE_LOCALE); + let targetLocaleCodes = files.filter((code) => code !== SOURCE_LOCALE); + + if (localeFilter) { + targetLocaleCodes = targetLocaleCodes.filter((code) => code === localeFilter); + if (targetLocaleCodes.length === 0) { + console.error(`[sync-locales] No locale found matching: ${localeFilter}`); + process.exitCode = 1; + return; + } + } + + const cache = new Map(); + + for (const localeCode of targetLocaleCodes) { + const localeData = await loadLocale(localeCode); + await processLocale(localeCode, source, localeData, cache, REGENERATE_CONFIG, true); + } +} + +/** + * Parses CLI locale filter for regenerate mode. + * Supports: --locale=, --locale , or a positional locale argument. + * + * @param {string[]} args CLI arguments excluding node and script paths. + * @returns {string | undefined} Parsed locale filter. + */ +function parseLocaleFilter(args) { + const isFlag = (arg) => arg.startsWith('-'); + const localeEq = args.find((arg) => arg.startsWith('--locale='))?.split('=')[1]; + const localeFlagIndex = args.indexOf('--locale'); + const localeNext = + localeFlagIndex !== -1 && args[localeFlagIndex + 1] && !isFlag(args[localeFlagIndex + 1]) + ? args[localeFlagIndex + 1] + : undefined; + const positional = args.find((arg) => arg !== '--regenerate' && !isFlag(arg)); + + const candidates = [localeEq, localeNext, positional].filter( + (value, index, values) => + typeof value === 'string' && value.length > 0 && values.indexOf(value) === index + ); + + if (localeFlagIndex !== -1 && !localeNext && !localeEq) { + console.error('[sync-locales] Missing locale value after --locale.'); + process.exitCode = 1; + return undefined; + } + + if (candidates.length > 1) { + console.error( + `[sync-locales] Conflicting locale values provided: ${candidates.join(', ')}. Use a single locale.` + ); + process.exitCode = 1; + return undefined; + } + + return candidates[0]; +} + +/** + * Collects locale codes from the locale directory (without `.js` extension). + * + * @returns {string[]} Locale codes discovered under `src/locales/locale`. + */ +function getLocaleCodes() { + return readdirSync(LOCALE_DIR) + .filter((file) => file.endsWith('.js')) + .map((file) => file.replace(/\.js$/u, '')); +} + +/** + * Builds a sync timing estimate used for pre-run warnings. + * + * Estimated formula: + * - minimum: 4 minutes ร— locale count + * - maximum: 7 minutes ร— locales needing translation + * + * @param {string} [localeFilter] Optional locale code to limit estimate scope. + * @returns {Promise<{ totalLocales: number, localesNeedingTranslation: number, estimatedMinMinutes: number, estimatedMaxMinutes: number } | null>} + */ +async function estimateSyncTiming(localeFilter) { + const files = getLocaleCodes(); + const source = await loadLocale(SOURCE_LOCALE); + let targetLocaleCodes = files.filter((code) => code !== SOURCE_LOCALE); + + if (localeFilter) { + if (localeFilter === SOURCE_LOCALE) { + console.error( + `[sync-locales] ${SOURCE_LOCALE} is the source locale and cannot be synchronized.` + ); + process.exitCode = 1; + return null; + } + + targetLocaleCodes = targetLocaleCodes.filter((code) => code === localeFilter); + if (targetLocaleCodes.length === 0) { + console.error(`[sync-locales] No locale found matching: ${localeFilter}`); + process.exitCode = 1; + return null; + } + } + + const localeMetadata = await Promise.all( + targetLocaleCodes.map(async (localeCode) => { + const localeData = await loadLocale(localeCode); + const needsTranslation = countTranslationNeeds(source, localeData); + return { localeCode, needsTranslation }; + }) + ); + + const localesNeedingTranslation = localeMetadata.filter( + ({ needsTranslation }) => needsTranslation > 0 + ).length; + + return { + totalLocales: targetLocaleCodes.length, + localesNeedingTranslation, + estimatedMinMinutes: targetLocaleCodes.length * 4, + estimatedMaxMinutes: localesNeedingTranslation * 7, + }; +} + +/** + * Prompts the user to confirm whether the run should proceed. + * + * @param {string[]} args CLI arguments excluding node and script paths. + * @param {string} message Prompt message shown before confirmation. + * @returns {Promise} True when user confirms, otherwise false. + */ +async function confirmProceed(args, message) { + if (args.includes('--yes') || args.includes('-y')) { + return true; + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.warn( + '[sync-locales] Non-interactive terminal detected; proceeding without confirmation.' + ); + return true; + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(`${message}\n[sync-locales] Proceed? (y/N): `); + return ['y', 'yes'].includes(answer.trim().toLowerCase()); + } finally { + rl.close(); + } +} + +/** + * If this module is executed directly, run syncLocales or regenerateLocales based on CLI flags. + * Flags: + * --regenerate Force re-translation of all strings (uses longer timeouts). + * --locale= Limit regeneration to a single locale (only valid with --regenerate). + * --yes / -y Skip the confirmation prompt. + */ +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const isRegenerate = args.includes('--regenerate'); + const localeFilter = parseLocaleFilter(args); + + if (process.exitCode) { + process.exit(process.exitCode); + } + + if (isRegenerate) { + const localeCodes = getLocaleCodes().filter((code) => code !== SOURCE_LOCALE); + const targetCount = localeFilter ? 1 : localeCodes.length; + const targetLabel = localeFilter ? `${localeFilter}` : `${targetCount} locales`; + const totalMin = targetCount * 30; + const totalMax = targetCount * 60; + + const shouldProceed = await confirmProceed( + args, + `[sync-locales] Regenerate mode selected for ${targetLabel}. This can take 30-60 minutes per locale (about ${totalMin}-${totalMax} minutes total).` + ); + + if (!shouldProceed) { + console.info('[sync-locales] Cancelled by user.'); + process.exit(0); + } + } else { + const syncEstimate = await estimateSyncTiming(localeFilter); + if (!syncEstimate) { + process.exit(process.exitCode || 1); + } + const shouldProceed = await confirmProceed( + args, + `[sync-locales] Sync mode selected. This usually takes several minutes. Estimated minimum: about ${syncEstimate.estimatedMinMinutes} minutes (4 ร— ${syncEstimate.totalLocales} locales). Estimated maximum: about ${syncEstimate.estimatedMaxMinutes} minutes (7 ร— ${syncEstimate.localesNeedingTranslation} locales needing translation).` + ); + + if (!shouldProceed) { + console.info('[sync-locales] Cancelled by user.'); + process.exit(0); + } + } + + if (isRegenerate) { + await regenerateLocales(localeFilter); + } else { + await syncLocales(localeFilter); + } +} From 4308a82863f56410e9acfc63dc7568829b33ed66 Mon Sep 17 00:00:00 2001 From: Steve Roberts Date: Thu, 29 Oct 2026 06:24:23 +0000 Subject: [PATCH 03/14] feat(TokenController): Rewrite for Jumpgate and Legacy games TokenController was reported as not working with Jumpgate and is no longer supported by its author. Rewrite it as version 5: - One !tokencontroller command, with !tc and !token-control kept so version 4 macros still work. - Routes from step codes, drawings (Jumpgate pathv2 and Legacy path) or recorded moves, run as loop, ping-pong or once. - Square, hex, isometric, dimetric and gridless pages, using grid geometry measured in a Jumpgate game and the page grid offset. - Combat detection from the turn tracker, a manual switch or both; lock modes (Roll20's lock, undo moves, hold still); single-file or formation following. - Help and Command Deck handouts, settings as One-Click options, and messages in 24 languages (English for now, others to follow). - Version 4 games are upgraded on startup, and the v4 usage guide, which players could see, is removed. Signed-off-by: Steve Roberts --- TokenController/src/chat.js | 242 +++ TokenController/src/commands.js | 1764 +++++++++++++++++++ TokenController/src/constants.js | 178 ++ TokenController/src/context.js | 44 + TokenController/src/drawings.js | 319 ++++ TokenController/src/engine.js | 919 ++++++++++ TokenController/src/grid.js | 480 +++++ TokenController/src/i18n.js | 123 ++ TokenController/src/index.js | 218 +++ TokenController/src/journals.js | 244 +++ TokenController/src/legacy.js | 155 ++ TokenController/src/locales/index.js | 61 + TokenController/src/locales/locale/af.js | 3 + TokenController/src/locales/locale/ca.js | 3 + TokenController/src/locales/locale/cs.js | 3 + TokenController/src/locales/locale/da.js | 3 + TokenController/src/locales/locale/de.js | 3 + TokenController/src/locales/locale/el.js | 3 + TokenController/src/locales/locale/en-US.js | 488 +++++ TokenController/src/locales/locale/es.js | 3 + TokenController/src/locales/locale/fi.js | 3 + TokenController/src/locales/locale/fr.js | 3 + TokenController/src/locales/locale/he.js | 3 + TokenController/src/locales/locale/hu.js | 3 + TokenController/src/locales/locale/it.js | 3 + TokenController/src/locales/locale/ja.js | 3 + TokenController/src/locales/locale/ko.js | 3 + TokenController/src/locales/locale/nl.js | 3 + TokenController/src/locales/locale/pl.js | 3 + TokenController/src/locales/locale/pt-BR.js | 3 + TokenController/src/locales/locale/pt-PT.js | 3 + TokenController/src/locales/locale/ru.js | 3 + TokenController/src/locales/locale/sv.js | 3 + TokenController/src/locales/locale/tr.js | 3 + TokenController/src/locales/locale/uk.js | 3 + TokenController/src/locales/locale/zh-TW.js | 3 + TokenController/src/locales/metadata.js | 245 +++ TokenController/src/routes.js | 410 +++++ TokenController/src/state.js | 907 ++++++++++ TokenController/src/utils.js | 219 +++ 40 files changed, 7085 insertions(+) create mode 100644 TokenController/src/chat.js create mode 100644 TokenController/src/commands.js create mode 100644 TokenController/src/constants.js create mode 100644 TokenController/src/context.js create mode 100644 TokenController/src/drawings.js create mode 100644 TokenController/src/engine.js create mode 100644 TokenController/src/grid.js create mode 100644 TokenController/src/i18n.js create mode 100644 TokenController/src/index.js create mode 100644 TokenController/src/journals.js create mode 100644 TokenController/src/legacy.js create mode 100644 TokenController/src/locales/index.js create mode 100644 TokenController/src/locales/locale/af.js create mode 100644 TokenController/src/locales/locale/ca.js create mode 100644 TokenController/src/locales/locale/cs.js create mode 100644 TokenController/src/locales/locale/da.js create mode 100644 TokenController/src/locales/locale/de.js create mode 100644 TokenController/src/locales/locale/el.js create mode 100644 TokenController/src/locales/locale/en-US.js create mode 100644 TokenController/src/locales/locale/es.js create mode 100644 TokenController/src/locales/locale/fi.js create mode 100644 TokenController/src/locales/locale/fr.js create mode 100644 TokenController/src/locales/locale/he.js create mode 100644 TokenController/src/locales/locale/hu.js create mode 100644 TokenController/src/locales/locale/it.js create mode 100644 TokenController/src/locales/locale/ja.js create mode 100644 TokenController/src/locales/locale/ko.js create mode 100644 TokenController/src/locales/locale/nl.js create mode 100644 TokenController/src/locales/locale/pl.js create mode 100644 TokenController/src/locales/locale/pt-BR.js create mode 100644 TokenController/src/locales/locale/pt-PT.js create mode 100644 TokenController/src/locales/locale/ru.js create mode 100644 TokenController/src/locales/locale/sv.js create mode 100644 TokenController/src/locales/locale/tr.js create mode 100644 TokenController/src/locales/locale/uk.js create mode 100644 TokenController/src/locales/locale/zh-TW.js create mode 100644 TokenController/src/locales/metadata.js create mode 100644 TokenController/src/routes.js create mode 100644 TokenController/src/state.js create mode 100644 TokenController/src/utils.js diff --git a/TokenController/src/chat.js b/TokenController/src/chat.js new file mode 100644 index 0000000000..0af0d04cc4 --- /dev/null +++ b/TokenController/src/chat.js @@ -0,0 +1,242 @@ +import { + COLOR_ACCENT, + COLOR_BORDER, + COLOR_BUTTON_ALT_BG, + COLOR_BUTTON_ALT_BORDER, + COLOR_BUTTON_BG, + COLOR_BUTTON_TEXT, + COLOR_CARD_BG_BOTTOM, + COLOR_CARD_BG_TOP, + COLOR_ERROR_BG, + COLOR_ERROR_BORDER, + COLOR_ERROR_TEXT, + COLOR_HEADER_BG, + COLOR_HEADER_TEXT, + COLOR_MUTED, + COLOR_TEXT, + COLOR_WARNING_BG, + COLOR_WARNING_BORDER, + COLOR_WARNING_TEXT, + SCRIPT_NAME, +} from './constants.js'; +import { escapeHtml } from './utils.js'; + +const CHAT_CARD_STYLE = [ + 'width:100%', + 'border-radius:4px', + 'box-shadow:1px 1px 3px rgba(0,0,0,0.4)', + 'text-align:left', + 'margin:2px auto', + `border:1px solid ${COLOR_BORDER}`, + `color:${COLOR_TEXT}`, + `background-image:-webkit-linear-gradient(135deg,${COLOR_CARD_BG_TOP} 0%,${COLOR_CARD_BG_BOTTOM} 100%)`, + `background-color:${COLOR_CARD_BG_BOTTOM}`, + 'overflow:hidden', +].join(';'); + +const CHAT_HEADER_STYLE = [ + `background:${COLOR_HEADER_BG}`, + `color:${COLOR_HEADER_TEXT}`, + 'padding:4px 8px', + `border-bottom:2px solid ${COLOR_ACCENT}`, + 'font-variant:small-caps', + 'font-weight:bold', + 'font-size:1.05em', + 'text-align:center', + 'letter-spacing:0.05em', +].join(';'); + +const CHAT_CONTENT_STYLE = 'padding:5px 8px;line-height:1.5'; + +const BUTTON_STYLE = [ + `background:${COLOR_BUTTON_BG}`, + `color:${COLOR_BUTTON_TEXT}`, + 'padding:2px 7px', + 'border-radius:3px', + 'text-decoration:none', + 'font-size:0.9em', + 'display:inline-block', + 'margin:1px 2px', +].join(';'); + +const BUTTON_SECONDARY_STYLE = [ + `background:${COLOR_BUTTON_ALT_BG}`, + `color:${COLOR_TEXT}`, + `border:1px solid ${COLOR_BUTTON_ALT_BORDER}`, + 'padding:2px 7px', + 'border-radius:3px', + 'text-decoration:none', + 'font-size:0.9em', + 'display:inline-block', + 'margin:1px 2px', +].join(';'); + +const SECTION_LABEL_STYLE = [ + `color:${COLOR_MUTED}`, + 'font-size:0.8em', + 'text-transform:uppercase', + 'letter-spacing:0.08em', + 'margin-top:4px', + 'margin-bottom:2px', +].join(';'); + +const DIVIDER_STYLE = `border:none;border-top:1px solid ${COLOR_BORDER};margin:4px 0`; + +/** + * Returns the whisper target for a player, falling back to all GMs. + * + * Double quotes are stripped so the name is safe inside /w "name". + * + * @param {string} [playerId] Roll20 player ID. + * @returns {string} Whisper target text. + */ +function whisperTarget(playerId) { + const player = playerId ? getObj('player', playerId) : null; + if (!player) { + return 'gm'; + } + return `"${String(player.get('displayname') || playerId).replaceAll('"', '')}"`; +} + +/** + * Sends a styled card whispered to a player, or to every GM when no player is given. + * + * @param {string|null} playerId Roll20 player ID. + * @param {string} title Card header text. + * @param {string|string[]} content HTML body content. + * @returns {void} + */ +export function whisper(playerId, title, content) { + const body = Array.isArray(content) ? content.join('') : content; + sendChat(SCRIPT_NAME, `/w ${whisperTarget(playerId)} ${buildCard(title, body)}`, null, { + noarchive: true, + }); +} + +/** + * Sends a styled card to everyone. + * + * @param {string} title Card header text. + * @param {string} body HTML body content. + * @returns {void} + */ +export function broadcast(title, body) { + sendChat(SCRIPT_NAME, buildCard(title, body)); +} + +/** + * Sends a warning whispered to a player (or every GM). + * + * @param {string|null} playerId Roll20 player ID. + * @param {string} message Warning text. + * @returns {void} + */ +export function whisperWarning(playerId, message) { + const style = [ + `background:${COLOR_WARNING_BG}`, + `color:${COLOR_WARNING_TEXT}`, + `border:1px solid ${COLOR_WARNING_BORDER}`, + 'padding:4px 8px', + 'border-radius:4px', + 'font-size:0.95em', + ].join(';'); + const html = `
${escapeHtml(message)}
`; + sendChat(SCRIPT_NAME, `/w ${whisperTarget(playerId)} ${html}`, null, { noarchive: true }); +} + +/** + * Sends an error whispered to a player (or every GM). + * + * @param {string|null} playerId Roll20 player ID. + * @param {string} message What went wrong. + * @param {string} [hint] How to fix it. + * @returns {void} + */ +export function whisperError(playerId, message, hint) { + const style = [ + `background:${COLOR_ERROR_BG}`, + `color:${COLOR_ERROR_TEXT}`, + `border:1px solid ${COLOR_ERROR_BORDER}`, + 'padding:4px 8px', + 'border-radius:4px', + 'font-size:0.95em', + ].join(';'); + const hintHtml = hint + ? `
${escapeHtml(hint)}
` + : ''; + const html = `
${escapeHtml(message)}${hintHtml}
`; + sendChat(SCRIPT_NAME, `/w ${whisperTarget(playerId)} ${html}`, null, { noarchive: true }); +} + +/** + * Builds a styled chat card. + * + * @param {string} title Header text (escaped). + * @param {string} body Body HTML (trusted). + * @returns {string} HTML string. + */ +export function buildCard(title, body) { + return [ + `
`, + `
${escapeHtml(title)}
`, + `
${body}
`, + '
', + ].join(''); +} + +/** + * Builds a primary action button. + * + * @param {string} label Button label. + * @param {string} command Chat command the button runs. + * @returns {string} HTML anchor. + */ +export function buildButton(label, command) { + return `${escapeHtml(label)}`; +} + +/** + * Builds a secondary action button. + * + * @param {string} label Button label. + * @param {string} command Chat command the button runs. + * @returns {string} HTML anchor. + */ +export function buildSecondaryButton(label, command) { + return `${escapeHtml(label)}`; +} + +/** + * Builds a section label. + * + * @param {string} text Label text. + * @returns {string} HTML string. + */ +export function buildSectionLabel(text) { + return `
${escapeHtml(text)}
`; +} + +/** + * Builds a horizontal divider. + * + * @returns {string} HTML string. + */ +export function buildDivider() { + return `
`; +} + +/** + * Builds a label/value row. The value may be trusted HTML (e.g. a button). + * + * @param {string} label Label text (escaped). + * @param {string} valueHtml Value HTML. + * @returns {string} HTML string. + */ +export function buildRow(label, valueHtml) { + return [ + '', + ``, + ``, + '
${escapeHtml(label)}${valueHtml}
', + ].join(''); +} diff --git a/TokenController/src/commands.js b/TokenController/src/commands.js new file mode 100644 index 0000000000..fb758c12e4 --- /dev/null +++ b/TokenController/src/commands.js @@ -0,0 +1,1764 @@ +import { + buildButton, + buildDivider, + buildRow, + buildSecondaryButton, + buildSectionLabel, + whisper, + whisperError, + whisperWarning, +} from './chat.js'; +import { + ANCHOR_PAGE, + ANCHOR_TOKEN, + COMBAT_BEHAVIOUR_OPTIONS, + COMBAT_DETECTION_OPTIONS, + COMMAND, + DEFAULT_CONFIG, + EXAMPLE_ROUTES, + FACING_OPTIONS, + FOLLOW_MODE_OPTIONS, + HANDOUT_DECK_NAME, + HELP_COMMANDS, + HANDOUT_HELP_NAME, + LEGACY_PREFIXES, + LOCK_MODE_OPTIONS, + MANUAL_MOVE_OPTIONS, + MOVEMENT_OPTIONS, + PATH_TYPE_LEGACY, + PATH_TYPE_V2, + ROUTE_KIND_CODE, + ROUTE_KIND_WAYPOINTS, + ROUTE_MODE_OPTIONS, + SCRIPT_NAME, + SCRIPT_VERSION, +} from './constants.js'; +import { getPageContext, pageGridOptions } from './context.js'; +import { drawingWaypoints } from './drawings.js'; +import { + clearTrackCache, + followLeader, + isCombatActive, + lockToken, + recordStep, + recordUndo, + resetPatrol, + startPatrol, + stopPatrol, + toggleStealth, + unfollow, + unlockToken, +} from './engine.js'; +import { calibrateGrid, DIRECTIONS } from './grid.js'; +import { LOCALE_LABELS, SUPPORTED_LOCALES, t } from './i18n.js'; +import { installHandouts } from './journals.js'; +import { translateLegacy } from './legacy.js'; +import { parseCode, recordingToRoute, stepsReturnToStart } from './routes.js'; +import { + addRecording, + addRoute, + cleanName, + deleteRoute, + findRecording, + findRoute, + getCalibration, + getConfig, + getFollows, + getLocks, + getPatrols, + getRecordingForToken, + getRecordings, + getState, + listRoutes, + setCalibration, + setConfigValue, + updateRoute, + validateRouteOption, +} from './state.js'; +import { + escapeHtml, + getPlayerPageId, + isToken, + parseStrictInt, + tokenize, + tokenLabel, + toText, +} from './utils.js'; + +// Settings shown in the menu, in order, with how to prompt for them. +const SETTING_ROWS = [ + { key: 'interval' }, + { key: 'movement', options: MOVEMENT_OPTIONS }, + { key: 'facing', options: FACING_OPTIONS }, + { key: 'combatDetection', options: COMBAT_DETECTION_OPTIONS }, + { key: 'combatBehaviour', options: COMBAT_BEHAVIOUR_OPTIONS }, + { key: 'lockMode', options: LOCK_MODE_OPTIONS }, + { key: 'lockMarker' }, + { key: 'followMode', options: FOLLOW_MODE_OPTIONS }, + { key: 'manualMove', options: MANUAL_MOVE_OPTIONS }, + { key: 'buttonStep' }, + { key: 'language' }, +]; + +// Settings whose values are shown with a translated label. +const OPTION_KEYS = SETTING_ROWS.filter((row) => row.options).map((row) => row.key); + +const RECORD_OPS = ['undo', 'save', 'cancel', 'w', 's']; +const MAX_LISTED_TOKENS = 30; + +/** + * Returns the active locale. + * + * @returns {string} Locale code. + */ +function lang() { + return getConfig().language; +} + +/** + * Translates a key in the active locale. + * + * @param {string} key Translation key. + * @param {object} [vars] Interpolation variables. + * @returns {string} Translated text. + */ +function tr(key, vars) { + return t(key, lang(), vars); +} + +/** + * Whispers a translated error with its hint. + * + * @param {string} playerId Player ID. + * @param {string} key Key under `errors.` (the hint lives under `hints.`). + * @param {object} [vars] Interpolation variables. + * @returns {void} + */ +function fail(playerId, key, vars) { + const hint = t(`hints.${key}`, lang(), vars); + whisperError(playerId, tr(`errors.${key}`, vars), hint === `hints.${key}` ? '' : hint); +} + +/** + * Whispers a short translated confirmation. + * + * @param {string} playerId Player ID. + * @param {string} key Key under `messages.`. + * @param {object} [vars] Interpolation variables. + * @returns {void} + */ +function done(playerId, key, vars) { + whisper(playerId, SCRIPT_NAME, `
${escapeHtml(tr(`messages.${key}`, vars))}
`); +} + +/** + * Builds a command line from the main command and arguments. + * + * @param {string} args Arguments. + * @returns {string} Command. + */ +function cmd(args) { + return `${COMMAND} ${args}`.trim(); +} + +/** + * Formats a setting value for display. + * + * @param {string} key Setting key. + * @param {*} value Setting value. + * @returns {string} Display text. + */ +function formatValue(key, value) { + if (key === 'language') { + return LOCALE_LABELS[value] || value; + } + if (key === 'interval') { + return tr('values.ms', { ms: value }); + } + if (key === 'lockMarker' && value === 'none') { + return tr('values.none'); + } + if (OPTION_KEYS.includes(key)) { + return tr(`values.${key}.${value}`); + } + return String(value); +} + +/** + * Builds a Roll20 query prompting for a value. + * + * @param {string} label Prompt label. + * @param {string[]|null} options Choices, or null for free text. + * @param {*} [current] Current value, offered as the default. + * @returns {string} Query text. + */ +function query(label, options, current) { + const safe = (text) => String(text).replaceAll(/[|}]/g, ''); + if (options) { + return `?{${safe(label)}|${options.map(safe).join('|')}}`; + } + const shown = current === null || current === undefined ? '' : safe(current); + return `?{${safe(label)}|${shown}}`; +} + +/** + * Joins words back into text. + * + * @param {{text: string}[]} words Words. + * @returns {string} Text. + */ +function joinWords(words) { + return words.map((w) => w.text).join(' '); +} + +/** + * Splits words into two names: a quoted or `#id` first name, otherwise + * everything but the last word, then the rest. + * + * @param {{text: string, quoted: boolean}[]} words Words. + * @returns {string[]|null} [first, second], or null when there are too few words. + */ +function splitTwoNames(words) { + if (words.length < 2) { + return null; + } + if (words[0].quoted || words[0].text.startsWith('#')) { + return [words[0].text, joinWords(words.slice(1))]; + } + return [joinWords(words.slice(0, -1)), words.at(-1).text]; +} + +/** + * Describes a route's path for display. + * + * @param {object} route Route record. + * @returns {string} Description. + */ +function describeRoute(route) { + if (route.kind === ROUTE_KIND_CODE) { + return route.code; + } + const points = route.points.filter((p) => 'x' in p).length; + return tr(route.anchor === ANCHOR_PAGE ? 'route.drawn' : 'route.recorded', { points }); +} + +/** + * Returns the tokens on a route. + * + * @param {string} routeId Route ID. + * @returns {string[]} Token IDs. + */ +function tokensOnRoute(routeId) { + return Object.entries(getPatrols()) + .filter(([, p]) => p.routeId === routeId) + .map(([id]) => id); +} + +// --------------------------------------------------------------------------- +// Selection +// --------------------------------------------------------------------------- + +/** + * Returns the other drawing type: `path` for `pathv2` and the reverse. + * + * @param {string} type Drawing type. + * @returns {string} The other drawing type. + */ +function otherPathType(type) { + return type === PATH_TYPE_V2 ? PATH_TYPE_LEGACY : PATH_TYPE_V2; +} + +/** + * Sorts the selection into tokens and drawings. + * + * @param {object} msg Chat message. + * @returns {{tokens: object[], drawings: object[]}} Selection. + */ +function readSelection(msg) { + const selection = { tokens: [], drawings: [] }; + for (const sel of msg.selected || []) { + if (sel._type === PATH_TYPE_V2 || sel._type === PATH_TYPE_LEGACY) { + // Jumpgate's chat reports its drawings as `path`, but the API knows + // them as `pathv2`, so look under both. + const drawing = getObj(sel._type, sel._id) || getObj(otherPathType(sel._type), sel._id); + if (drawing) { + selection.drawings.push(drawing); + } + continue; + } + const obj = getObj(sel._type, sel._id); + if (obj && isToken(obj)) { + selection.tokens.push(obj); + } + } + return selection; +} + +/** + * Returns the selected tokens, whispering an error when there are none. + * + * @param {object} msg Chat message. + * @returns {object[]|null} Tokens, or null. + */ +function requireTokens(msg) { + const { tokens } = readSelection(msg); + if (tokens.length === 0) { + fail(msg.playerid, 'noTokens'); + return null; + } + return tokens; +} + +/** + * Finds a route, whispering an error when it does not exist. + * + * @param {object} msg Chat message. + * @param {string} ref Route reference. + * @returns {object|null} Route. + */ +function requireRoute(msg, ref) { + if (!toText(ref)) { + fail(msg.playerid, 'noRouteName'); + return null; + } + const route = findRoute(ref); + if (!route) { + fail(msg.playerid, 'routeNotFound', { name: ref }); + } + return route; +} + +/** + * Finds a token by ID, or by name on the GM's current page. + * + * @param {object} msg Chat message. + * @param {string} ref Token ID or name. + * @returns {object|null} Token. + */ +function findToken(msg, ref) { + const text = toText(ref); + if (!text) { + return null; + } + const byId = getObj('graphic', text); + if (isToken(byId)) { + return byId; + } + const pageId = getPlayerPageId(msg.playerid); + const lower = text.toLowerCase(); + const named = findObjs({ _type: 'graphic', _subtype: 'token', _pageid: pageId }).filter( + (tok) => toText(tok.get('name')).toLowerCase() === lower + ); + return named[0] || null; +} + +/** + * Lists token names for a message, shortening long lists. + * + * @param {object[]} tokens Roll20 graphics. + * @returns {string} Comma-separated names. + */ +function namesOf(tokens) { + const names = tokens.slice(0, MAX_LISTED_TOKENS).map((tok) => tokenLabel(tok)); + if (tokens.length > MAX_LISTED_TOKENS) { + names.push(tr('values.andMore', { count: tokens.length - MAX_LISTED_TOKENS })); + } + return names.join(', '); +} + +// --------------------------------------------------------------------------- +// Menus and cards +// --------------------------------------------------------------------------- + +/** + * Shows the main menu. + * + * @param {string|null} playerId Player ID, or null for every GM. + * @returns {void} + */ +export function showMainMenu(playerId) { + const s = getState(); + const routes = listRoutes(); + const patrolCount = Object.keys(s.patrols).length; + const lines = []; + + let statusKey = 'menu.statusRunning'; + if (s.paused) { + statusKey = 'menu.statusPaused'; + } else if (isCombatActive() && getConfig().combatBehaviour !== 'ignore') { + statusKey = `menu.statusCombat.${getConfig().combatBehaviour}`; + } + lines.push( + `
${escapeHtml(tr(statusKey, { patrols: patrolCount, routes: routes.length }))}
` + ); + + lines.push(buildSectionLabel(tr('menu.routes'))); + if (routes.length === 0) { + lines.push(`
${escapeHtml(tr('menu.noRoutes'))}
`); + } + for (const route of routes) { + const count = tokensOnRoute(route.id).length; + const label = count > 0 ? `${route.name} (${count})` : route.name; + lines.push( + buildRow( + label, + buildButton(tr('buttons.start'), cmd(`start #${route.id}`)) + + buildSecondaryButton(tr('buttons.stop'), cmd(`stop #${route.id}`)) + + buildSecondaryButton('โ‹ฏ', cmd(`show #${route.id}`)) + ) + ); + } + + lines.push(buildSectionLabel(tr('menu.create'))); + lines.push( + [ + buildButton( + tr('buttons.add'), + cmd(`add ${query(tr('prompts.name'), null)} ${query(tr('prompts.code'), null, 'U2R2D2L2')}`) + ), + buildButton(tr('buttons.draw'), cmd(`draw ${query(tr('prompts.name'), null)}`)), + buildButton(tr('buttons.record'), cmd(`record ${query(tr('prompts.name'), null)}`)), + buildSecondaryButton(tr('buttons.examples'), cmd('examples')), + ].join('') + ); + + lines.push(buildSectionLabel(tr('menu.selected'))); + lines.push( + [ + buildSecondaryButton(tr('buttons.stop'), cmd('stop')), + buildSecondaryButton(tr('buttons.reset'), cmd('reset')), + buildSecondaryButton(tr('buttons.lock'), cmd('lock')), + buildSecondaryButton(tr('buttons.unlock'), cmd('unlock')), + buildSecondaryButton(tr('buttons.leader'), cmd('leader')), + buildSecondaryButton(tr('buttons.follow'), cmd('follow')), + buildSecondaryButton(tr('buttons.unfollow'), cmd('unfollow')), + ].join('') + ); + + lines.push(buildSectionLabel(tr('menu.tools'))); + const tools = [ + s.paused + ? buildButton(tr('buttons.resume'), cmd('resume')) + : buildSecondaryButton(tr('buttons.pause'), cmd('pause')), + ]; + if (getConfig().combatDetection !== 'tracker') { + tools.push( + buildSecondaryButton( + tr(s.combat.manual ? 'buttons.combatOff' : 'buttons.combatOn'), + cmd(`combat ${s.combat.manual ? 'off' : 'on'}`) + ) + ); + } + tools.push( + buildSecondaryButton(tr('buttons.stopAll'), cmd('stop all')), + buildSecondaryButton(tr('buttons.settings'), cmd('set')), + buildSecondaryButton(tr('buttons.status'), cmd('status')), + buildSecondaryButton(tr('buttons.help'), cmd('help')) + ); + lines.push(tools.join('')); + + const recordings = Object.values(getRecordings()); + if (recordings.length > 0) { + lines.push(buildSectionLabel(tr('menu.recordings'))); + for (const rec of recordings) { + lines.push(buildRow(rec.name, buildButton(tr('buttons.open'), cmd(`record #${rec.id}`)))); + } + } + + whisper(playerId, `${SCRIPT_NAME} v${SCRIPT_VERSION}`, lines); +} + +/** + * Shows a route's details and controls. + * + * @param {string} playerId Player ID. + * @param {object} route Route record. + * @returns {void} + */ +function showRoute(playerId, route) { + const ref = `#${route.id}`; + const tokens = tokensOnRoute(route.id).map( + (id) => getObj('graphic', id) || { id, get: () => id } + ); + const lines = [ + buildRow(tr('route.path'), `${escapeHtml(describeRoute(route))}`), + buildRow( + tr('route.mode'), + buildSecondaryButton( + tr(`values.mode.${route.mode}`), + cmd(`route ${ref} mode ${query(tr('route.mode'), ROUTE_MODE_OPTIONS)}`) + ) + ), + buildRow( + tr('route.interval'), + buildSecondaryButton( + route.interval ? tr('values.ms', { ms: route.interval }) : tr('values.default'), + cmd(`route ${ref} interval ${query(tr('prompts.interval'), null, route.interval || 0)}`) + ) + ), + buildRow( + tr('route.maxTokens'), + buildSecondaryButton( + route.maxTokens ? String(route.maxTokens) : tr('values.noLimit'), + cmd(`route ${ref} max ${query(tr('prompts.maxTokens'), null, route.maxTokens || 0)}`) + ) + ), + buildRow( + tr('route.randomStart'), + buildSecondaryButton( + tr(route.randomStart ? 'values.on' : 'values.off'), + cmd(`route ${ref} random ${route.randomStart ? 'off' : 'on'}`) + ) + ), + buildRow( + tr('route.start'), + route.start + ? buildSecondaryButton(tr('buttons.clearStart'), cmd(`route ${ref} start clear`)) + : buildSecondaryButton(tr('buttons.setStart'), cmd(`route ${ref} start set`)) + ), + buildRow( + tr('route.tokens'), + escapeHtml(tokens.length > 0 ? namesOf(tokens) : tr('values.none')) + ), + buildDivider(), + [ + buildButton(tr('buttons.start'), cmd(`start ${ref}`)), + buildSecondaryButton(tr('buttons.stop'), cmd(`stop ${ref}`)), + buildSecondaryButton(tr('buttons.reset'), cmd(`reset ${ref}`)), + route.kind === ROUTE_KIND_CODE + ? buildSecondaryButton( + tr('buttons.edit'), + cmd(`edit ${ref} ${query(tr('prompts.code'), null, route.code)}`) + ) + : '', + buildSecondaryButton( + tr('buttons.rename'), + cmd(`rename ${ref} ${query(tr('prompts.newName'), null, route.name)}`) + ), + buildSecondaryButton( + tr('buttons.copy'), + cmd(`copy ${ref} ${query(tr('prompts.newName'), null)}`) + ), + buildSecondaryButton(tr('buttons.export'), cmd(`export ${ref}`)), + buildSecondaryButton(tr('buttons.remove'), cmd(`remove ${ref}`)), + buildSecondaryButton(tr('buttons.menu'), cmd('')), + ].join(''), + ]; + whisper(playerId, route.name, lines); +} + +/** + * Shows a recording's controls. + * + * @param {string} playerId Player ID. + * @param {object} recording Recording record. + * @returns {void} + */ +function showRecording(playerId, recording) { + const ref = `#${recording.id}`; + const step = getConfig().buttonStep; + const token = getObj('graphic', recording.tokenId); + const moves = recording.entries.length - 1; + const dirButton = (dir) => buildButton(dir, cmd(`record ${ref} ${dir} ${step}`)); + const lines = [ + `
${escapeHtml(tr('record.intro', { token: tokenLabel(token, recording.tokenId), count: moves }))}
`, + `
${dirButton('UL')}${dirButton('U')}${dirButton('UR')}
`, + `
${dirButton('L')}${buildSecondaryButton(tr('buttons.wait'), cmd(`record ${ref} W`))}${dirButton('R')}
`, + `
${dirButton('DL')}${dirButton('D')}${dirButton('DR')}
`, + buildRow( + tr('settings.buttonStep.label'), + buildSecondaryButton( + String(step), + cmd(`set buttonStep ${query(tr('settings.buttonStep.label'), null, step)}`) + ) + ), + buildDivider(), + [ + buildSecondaryButton(tr('buttons.stealth'), cmd(`record ${ref} S`)), + buildSecondaryButton(tr('buttons.undo'), cmd(`record ${ref} undo`)), + buildButton(tr('buttons.save'), cmd(`record ${ref} save`)), + buildSecondaryButton(tr('buttons.cancel'), cmd(`record ${ref} cancel`)), + ].join(''), + ]; + whisper(playerId, tr('record.title', { name: recording.name }), lines); +} + +/** + * Shows every setting with a button to change it. + * + * @param {string} playerId Player ID. + * @returns {void} + */ +function showSettings(playerId) { + const config = getConfig(); + const lines = SETTING_ROWS.map((row) => { + const label = tr(`settings.${row.key}.label`); + const options = row.key === 'language' ? SUPPORTED_LOCALES : row.options || null; + return buildRow( + label, + buildSecondaryButton( + formatValue(row.key, config[row.key]), + cmd(`set ${row.key} ${query(label, options, config[row.key])}`) + ) + ); + }); + lines.push(buildDivider(), buildSecondaryButton(tr('buttons.menu'), cmd(''))); + whisper(playerId, tr('titles.settings'), lines); +} + +/** + * Shows the command list. + * + * @param {string} playerId Player ID. + * @returns {void} + */ +function showHelp(playerId) { + const rows = HELP_COMMANDS.map(([text, key]) => + buildRow(text ? `${COMMAND} ${text}` : COMMAND, escapeHtml(tr(`help.${key}`))) + ); + rows.push( + `
${escapeHtml(tr('help.footer', { help: HANDOUT_HELP_NAME, deck: HANDOUT_DECK_NAME, legacy: LEGACY_PREFIXES.join(', ') }))}
` + ); + whisper(playerId, tr('titles.help'), rows); +} + +// --------------------------------------------------------------------------- +// Route commands +// --------------------------------------------------------------------------- + +/** + * Picks the default mode for a new route: loop when it ends where it starts, + * ping-pong otherwise. + * + * @param {object} fields Route fields. + * @returns {string} Mode. + */ +function defaultMode(fields) { + if (fields.kind === ROUTE_KIND_CODE) { + const parsed = parseCode(fields.code); + return parsed.ok && stepsReturnToStart(parsed.steps) ? 'loop' : 'pingpong'; + } + const places = fields.points.filter((p) => 'x' in p); + const first = places[0]; + const last = places.at(-1); + return places.length > 2 && Math.hypot(first.x - last.x, first.y - last.y) < 1 + ? 'loop' + : 'pingpong'; +} + +/** + * Checks a new route name, whispering an error when it is empty or taken. + * + * @param {object} msg Chat message. + * @param {string} raw Name. + * @param {string} [exceptId] Route allowed to have the name (when renaming). + * @returns {string|null} Clean name, or null. + */ +function checkNewName(msg, raw, exceptId) { + const name = cleanName(raw); + if (!name || name.startsWith('#')) { + fail(msg.playerid, 'noRouteName'); + return null; + } + const existing = findRoute(name); + if (existing && existing.id !== exceptId) { + fail(msg.playerid, 'routeExists', { name }); + return null; + } + return name; +} + +/** + * Checks a step code, whispering an error when it is invalid. + * + * @param {object} msg Chat message. + * @param {string} code Step code. + * @returns {boolean} True when valid. + */ +function checkCode(msg, code) { + const parsed = parseCode(code); + if (!parsed.ok) { + fail(msg.playerid, `code.${parsed.error}`, { at: parsed.at || '', code }); + return false; + } + return true; +} + +/** + * Stores a new route and confirms it. + * + * @param {object} msg Chat message. + * @param {object} fields Route fields. + * @returns {object|null} Route. + */ +function createRoute(msg, fields) { + const route = addRoute({ mode: defaultMode(fields), ...fields }); + if (!route) { + fail(msg.playerid, 'routeInvalid'); + return null; + } + done(msg.playerid, 'routeAdded', { name: route.name, mode: tr(`values.mode.${route.mode}`) }); + showRoute(msg.playerid, route); + return route; +} + +/** + * `add `: create a route from a step code. + * + * @param {object} msg Chat message. + * @param {object[]} words Arguments. + * @returns {void} + */ +function commandAdd(msg, words) { + if (words.length < 2) { + fail(msg.playerid, 'usageAdd'); + return; + } + const code = words.at(-1).text; + const name = checkNewName(msg, joinWords(words.slice(0, -1))); + if (name && checkCode(msg, code)) { + createRoute(msg, { name, kind: ROUTE_KIND_CODE, code }); + } +} + +/** + * `edit `: change a route's step code. + * + * @param {object} msg Chat message. + * @param {object[]} words Arguments. + * @returns {void} + */ +function commandEdit(msg, words) { + if (words.length < 2) { + fail(msg.playerid, 'usageEdit'); + return; + } + const route = requireRoute(msg, joinWords(words.slice(0, -1))); + const code = words.at(-1).text; + if (!route || !checkCode(msg, code)) { + return; + } + const updated = updateRoute(route.id, { kind: ROUTE_KIND_CODE, code, anchor: undefined }); + clearTrackCache(); + done(msg.playerid, 'routeUpdated', { name: updated.name }); + showRoute(msg.playerid, updated); +} + +/** + * `draw `: create a route that follows the selected drawing. + * + * @param {object} msg Chat message. + * @param {object[]} words Arguments. + * @returns {void} + */ +function commandDraw(msg, words) { + const { drawings } = readSelection(msg); + if (drawings.length !== 1) { + fail(msg.playerid, 'selectOneDrawing'); + return; + } + const name = checkNewName(msg, joinWords(words)); + if (!name) { + return; + } + const drawing = drawings[0]; + const pageId = drawing.get('_pageid'); + const page = getObj('page', pageId); + const props = { + type: drawing.get('_type'), + _path: drawing.get('_path') ?? drawing.get('path'), + points: drawing.get('points'), + shape: drawing.get('shape'), + x: drawing.get('x'), + y: drawing.get('y'), + left: drawing.get('left'), + top: drawing.get('top'), + width: drawing.get('width'), + height: drawing.get('height'), + scaleX: drawing.get('scaleX'), + scaleY: drawing.get('scaleY'), + rotation: drawing.get('rotation'), + }; + const result = drawingWaypoints(props, { + width: page?.get('width'), + height: page?.get('height'), + }); + if (!result) { + fail(msg.playerid, 'drawingEmpty'); + return; + } + createRoute(msg, { + name, + kind: ROUTE_KIND_WAYPOINTS, + anchor: ANCHOR_PAGE, + pageId, + points: result.points.map(([x, y]) => ({ x, y })), + }); +} + +/** + * `route