From a825a871aae2738ff31b7fb28cb0de970707a9c9 Mon Sep 17 00:00:00 2001 From: Nic Bradley Date: Wed, 5 Nov 2025 00:03:40 +0000 Subject: [PATCH] ChatSetAttr v2 --- .types/index.d.ts | 4 +- ChatSetAttr/.tool-versions | 1 + ChatSetAttr/2.0/ChatSetAttr.js | 2159 ++++++++++ ChatSetAttr/ChatSetAttr.js | 2944 ++++++++++---- ChatSetAttr/README.md | 573 ++- ChatSetAttr/eslint.config.ts | 22 + ChatSetAttr/package-lock.json | 3597 +++++++++++++++++ ChatSetAttr/package.json | 41 + ChatSetAttr/rollup.config.ts | 39 + ChatSetAttr/script.json | 44 +- ChatSetAttr/src/__mocks__/apiObjects.mock.ts | 148 + .../src/__mocks__/beaconAttributes.mock.ts | 55 + .../src/__mocks__/eventHandling.mock.ts | 120 + ChatSetAttr/src/__mocks__/utility.mock.ts | 31 + .../integration/legacyAttributes.test.ts | 1153 ++++++ .../src/__tests__/legacy/ChatSetAttr.d.ts | 125 + .../src/__tests__/legacy/ChatSetAttr.js | 824 ++++ .../legacy/legacyIntegration.test.ts | 1136 ++++++ .../src/__tests__/templates/messages.test.ts | 358 ++ .../src/__tests__/unit/attributes.test.ts | 467 +++ ChatSetAttr/src/__tests__/unit/chat.test.ts | 316 ++ .../src/__tests__/unit/commands.test.ts | 558 +++ ChatSetAttr/src/__tests__/unit/config.test.ts | 407 ++ .../src/__tests__/unit/feedback.test.ts | 110 + .../src/__tests__/unit/helpers.test.ts | 82 + .../src/__tests__/unit/message.test.ts | 643 +++ .../src/__tests__/unit/modifications.test.ts | 351 ++ .../src/__tests__/unit/observer.test.ts | 164 + .../src/__tests__/unit/repeating.test.ts | 626 +++ .../src/__tests__/unit/targets.test.ts | 456 +++ ChatSetAttr/src/__tests__/unit/timer.test.ts | 356 ++ ChatSetAttr/src/__tests__/unit/update.test.ts | 990 +++++ .../src/__tests__/unit/versioning.test.ts | 335 ++ ChatSetAttr/src/__tests__/utils/chat.test.ts | 515 +++ ChatSetAttr/src/env.d.ts | 18 + ChatSetAttr/src/index.ts | 11 + ChatSetAttr/src/modules/attributes.ts | 93 + ChatSetAttr/src/modules/chat.ts | 50 + ChatSetAttr/src/modules/commands.ts | 426 ++ ChatSetAttr/src/modules/config.ts | 87 + ChatSetAttr/src/modules/feedback.ts | 48 + ChatSetAttr/src/modules/help.ts | 25 + ChatSetAttr/src/modules/helpers.ts | 38 + ChatSetAttr/src/modules/main.ts | 150 + ChatSetAttr/src/modules/message.ts | 136 + ChatSetAttr/src/modules/modifications.ts | 145 + ChatSetAttr/src/modules/observer.ts | 26 + ChatSetAttr/src/modules/permissions.ts | 45 + ChatSetAttr/src/modules/repeating.ts | 181 + ChatSetAttr/src/modules/targets.ts | 224 + ChatSetAttr/src/modules/timer.ts | 37 + ChatSetAttr/src/modules/updates.ts | 57 + ChatSetAttr/src/modules/versioning.ts | 84 + ChatSetAttr/src/templates/config.tsx | 92 + ChatSetAttr/src/templates/delay.tsx | 32 + ChatSetAttr/src/templates/help.tsx | 477 +++ ChatSetAttr/src/templates/messages.tsx | 79 + ChatSetAttr/src/templates/notification.tsx | 32 + ChatSetAttr/src/templates/styles.ts | 353 ++ ChatSetAttr/src/types.ts | 187 + ChatSetAttr/src/utils/chat.ts | 32 + ChatSetAttr/src/versions/version2.ts | 44 + ChatSetAttr/tsconfig.json | 14 + ChatSetAttr/tsconfig.script.json | 10 + ChatSetAttr/tsconfig.vitest.json | 15 + ChatSetAttr/vitest.config.ts | 13 + ChatSetAttr/vitest.setup.ts | 71 + 67 files changed, 22228 insertions(+), 854 deletions(-) create mode 100644 ChatSetAttr/.tool-versions create mode 100644 ChatSetAttr/2.0/ChatSetAttr.js create mode 100644 ChatSetAttr/eslint.config.ts create mode 100644 ChatSetAttr/package-lock.json create mode 100644 ChatSetAttr/package.json create mode 100644 ChatSetAttr/rollup.config.ts create mode 100644 ChatSetAttr/src/__mocks__/apiObjects.mock.ts create mode 100644 ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts create mode 100644 ChatSetAttr/src/__mocks__/eventHandling.mock.ts create mode 100644 ChatSetAttr/src/__mocks__/utility.mock.ts create mode 100644 ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts create mode 100644 ChatSetAttr/src/__tests__/legacy/ChatSetAttr.d.ts create mode 100644 ChatSetAttr/src/__tests__/legacy/ChatSetAttr.js create mode 100644 ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts create mode 100644 ChatSetAttr/src/__tests__/templates/messages.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/attributes.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/chat.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/commands.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/config.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/feedback.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/helpers.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/message.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/modifications.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/observer.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/repeating.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/targets.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/timer.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/update.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/versioning.test.ts create mode 100644 ChatSetAttr/src/__tests__/utils/chat.test.ts create mode 100644 ChatSetAttr/src/env.d.ts create mode 100644 ChatSetAttr/src/index.ts create mode 100644 ChatSetAttr/src/modules/attributes.ts create mode 100644 ChatSetAttr/src/modules/chat.ts create mode 100644 ChatSetAttr/src/modules/commands.ts create mode 100644 ChatSetAttr/src/modules/config.ts create mode 100644 ChatSetAttr/src/modules/feedback.ts create mode 100644 ChatSetAttr/src/modules/help.ts create mode 100644 ChatSetAttr/src/modules/helpers.ts create mode 100644 ChatSetAttr/src/modules/main.ts create mode 100644 ChatSetAttr/src/modules/message.ts create mode 100644 ChatSetAttr/src/modules/modifications.ts create mode 100644 ChatSetAttr/src/modules/observer.ts create mode 100644 ChatSetAttr/src/modules/permissions.ts create mode 100644 ChatSetAttr/src/modules/repeating.ts create mode 100644 ChatSetAttr/src/modules/targets.ts create mode 100644 ChatSetAttr/src/modules/timer.ts create mode 100644 ChatSetAttr/src/modules/updates.ts create mode 100644 ChatSetAttr/src/modules/versioning.ts create mode 100644 ChatSetAttr/src/templates/config.tsx create mode 100644 ChatSetAttr/src/templates/delay.tsx create mode 100644 ChatSetAttr/src/templates/help.tsx create mode 100644 ChatSetAttr/src/templates/messages.tsx create mode 100644 ChatSetAttr/src/templates/notification.tsx create mode 100644 ChatSetAttr/src/templates/styles.ts create mode 100644 ChatSetAttr/src/types.ts create mode 100644 ChatSetAttr/src/utils/chat.ts create mode 100644 ChatSetAttr/src/versions/version2.ts create mode 100644 ChatSetAttr/tsconfig.json create mode 100644 ChatSetAttr/tsconfig.script.json create mode 100644 ChatSetAttr/tsconfig.vitest.json create mode 100644 ChatSetAttr/vitest.config.ts create mode 100644 ChatSetAttr/vitest.setup.ts diff --git a/.types/index.d.ts b/.types/index.d.ts index e4d935deed..da73e5b7ef 100644 --- a/.types/index.d.ts +++ b/.types/index.d.ts @@ -8,7 +8,7 @@ type Prettify = { interface Roll20Object> { /** The unique ID of this object */ id: string; - properties: Prettify; + properties: Prettify; /** * Get an attribute of the object @@ -507,7 +507,7 @@ type FindObjsOptions = { * name: "target" * }, {caseInsensitive: true}); */ -declare function findObjs(attrs: Partial & { _type: T }, options?: FindObjsOptions): Roll20ObjectTypeToInstance[T][]; +declare function findObjs(attrs: Partial & { _type: T }, options?: FindObjsOptions): Roll20ObjectTypeToInstance[T][]; /** * Filters Roll20 objects by executing the callback function on each object diff --git a/ChatSetAttr/.tool-versions b/ChatSetAttr/.tool-versions new file mode 100644 index 0000000000..acb1cd0680 --- /dev/null +++ b/ChatSetAttr/.tool-versions @@ -0,0 +1 @@ +nodejs 22.12.0 \ No newline at end of file diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js new file mode 100644 index 0000000000..6d15e25c9b --- /dev/null +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -0,0 +1,2159 @@ +// ChatSetAttr v2.0 by Jakob, GUD Team +var ChatSetAttr = (function (exports) { + 'use strict'; + + var name = "ChatSetAttr"; + var version = "2.0"; + var authors = [ + "Jakob", + "GUD Team" + ]; + var scriptJson = { + name: name, + version: version, + authors: authors}; + + // #region Get Attributes + async function getSingleAttribute(target, attributeName) { + const isMax = attributeName.endsWith("_max"); + const type = isMax ? "max" : "current"; + if (isMax) { + attributeName = attributeName.slice(0, -4); // remove '_max' + } + try { + const attribute = await libSmartAttributes.getAttribute(target, attributeName, type); + return attribute; + } + catch { + return undefined; + } + } + async function getAttributes(target, attributeNames) { + const attributes = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + else { + for (const name in attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + return attributes; + } + + // #region Style Helpers + function convertCamelToKebab(camel) { + return camel.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function s(styleObject = {}) { + let style = ""; + for (const [key, value] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value};`; + } + return style; + } + function h(tagName, attributes = {}, ...children) { + const attrs = Object.entries(attributes ?? {}) + .map(([key, value]) => ` ${key}="${value}"`) + .join(""); + // Deeply flatten arrays and filter out null/undefined values + const flattenedChildren = children.flat(10).filter(child => child != null); + const childrenContent = flattenedChildren.join(""); + return `<${tagName}${attrs}>${childrenContent}`; + } + + const COLOR_RED = { + "50": "#ffebeb", + "300": "#ff7474", + "500": "#ff2020"}; + const COLOR_GREEN = { + "500": "#00e626"}; + const COLOR_EMERALD = { + "50": "#e6fff5", + "300": "#4dffc7"}; + const COLOR_BLUE = { + "50": "#e6f0ff", + "100": "#b3d1ff", + "300": "#4d94ff", + "400": "#1a75ff", + "600": "#0052b4", + "800": "#002952", + "900": "#001421", + }; + const COLOR_STONE = { + "50": "#fafaf9", + "400": "#a8a29e", + "700": "#44403c", + "900": "#1c1917", + }; + const COLOR_WHITE = "#ffffff"; + const PADDING = { + XS: "2px", + SM: "4px", + MD: "8px"}; + const MARGIN = { + SM: "4px", + MD: "8px"}; + const BORDER_RADIUS = { + SM: "2px", + MD: "4px"}; + const FONT_SIZE = { + SM: "0.875rem", + MD: "1rem", + LG: "1.125rem"}; + const FONT_WEIGHT = { + MEDIUM: "500", + BOLD: "700"}; + const WRAPPER_STYLE = s({ + fontSize: FONT_SIZE.MD, + }); + const LI_STYLE = s({ + fontSize: FONT_SIZE.MD, + marginBottom: MARGIN.SM, + }); + s({ + fontSize: FONT_SIZE.LG, + fontWeight: FONT_WEIGHT.BOLD, + marginBottom: MARGIN.MD, + }); + const BUTTON_STYLE = s({ + padding: `${PADDING.SM} ${PADDING.MD}`, + borderRadius: BORDER_RADIUS.MD, + fontSize: FONT_SIZE.MD, + fontWeight: FONT_WEIGHT.MEDIUM, + color: COLOR_WHITE, + backgroundColor: COLOR_BLUE["600"], + border: "none", + textDecoration: "none", + }); + const PARAGRAPH_SPACING_STYLE = s({ + marginBottom: MARGIN.MD, + }); + + const DELAY_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_STONE["400"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + color: COLOR_STONE["900"], + backgroundColor: COLOR_STONE["50"], + }); + const DELAY_HEADER_STYLE = s({ + color: COLOR_STONE["700"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const DELAY_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + function createDelayMessage() { + return (h("div", { style: DELAY_WRAPPER_STYLE }, + h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), + h("div", { style: DELAY_BODY_STYLE }, "The operation is taking a long time to execute. This may be due to a large number of targets or attributes being processed. Please be patient as the operation completes."))); + } + + // #region Chat Styles + const CHAT_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_EMERALD["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_EMERALD["50"], + }); + const CHAT_HEADER_STYLE = s({ + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const CHAT_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + // #region Error Styles + const ERROR_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_RED["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_RED["50"], + }); + const ERROR_HEADER_STYLE = s({ + color: COLOR_RED["500"], + fontWeight: "bold", + fontSize: FONT_SIZE.LG, + }); + const ERROR_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + // #region Generic Message Creation Function + function createMessage(header, messages, styles) { + return (h("div", { style: styles.wrapper }, + h("h3", { style: styles.header }, header), + h("div", { style: styles.body }, messages.map(message => h("p", null, message))))); + } + // #region Chat Message Function + function createChatMessage(header, messages) { + return createMessage(header, messages, { + wrapper: CHAT_WRAPPER_STYLE, + header: CHAT_HEADER_STYLE, + body: CHAT_BODY_STYLE + }); + } + // #region Error Message Function + function createErrorMessage(header, errors) { + return createMessage(header, errors, { + wrapper: ERROR_WRAPPER_STYLE, + header: ERROR_HEADER_STYLE, + body: ERROR_BODY_STYLE + }); + } + + const NOTIFY_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_BLUE["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + color: COLOR_BLUE["800"], + backgroundColor: COLOR_BLUE["100"], + }); + const NOTIFY_HEADER_STYLE = s({ + color: COLOR_BLUE["900"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const NOTIFY_BODY_STYLE = s({ + fontSize: FONT_SIZE.MD, + }); + function createNotifyMessage(title, content) { + return (h("div", { style: NOTIFY_WRAPPER_STYLE }, + h("div", { style: NOTIFY_HEADER_STYLE }, title), + h("div", { style: NOTIFY_BODY_STYLE }, content))); + } + + function getPlayerName(playerID) { + const player = getObj("player", playerID); + return player?.get("_displayname") ?? "Unknown Player"; + } + function sendMessages(playerID, header, messages, from = "ChatSetAttr") { + const newMessage = createChatMessage(header, messages); + sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + } + function sendErrors(playerID, header, errors, from = "ChatSetAttr") { + if (errors.length === 0) + return; + const newMessage = createErrorMessage(header, errors); + sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + } + function sendDelayMessage(silent = false) { + if (silent) + return; + const delayMessage = createDelayMessage(); + sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); + } + function sendNotification(title, content, archive) { + const notifyMessage = createNotifyMessage(title, content); + sendChat("ChatSetAttr", "/w gm " + notifyMessage, undefined, { noarchive: archive }); + } + function sendWelcomeMessage() { + const welcomeMessage = ` +

Thank you for installing ChatSetAttr.

+

To get started, use the command !setattr-config to configure the script to your needs.

+

For detailed documentation and examples, please use the !setattr-help command or click the button below:

+

Create Journal Handout

`; + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); + } + + function createFeedbackMessage(characterName, feedback, startingValues, targetValues) { + let message = feedback?.content ?? ""; + // _NAMEJ_: will insert the attribute name. + // _TCURJ_: will insert what you are changing the current value to (or changing by, if you're using --mod or --modb). + // _TMAXJ_: will insert what you are changing the maximum value to (or changing by, if you're using --mod or --modb). + // _CHARNAME_: will insert the character name. + // _CURJ_: will insert the final current value of the attribute, for this character. + // _MAXJ_: will insert the final maximum value of the attribute, for this character. + const targetValueKeys = Object.keys(targetValues).filter(key => !key.endsWith("_max")); + message = message.replace("_CHARNAME_", characterName); + message = message.replace(/_(NAME|TCUR|TMAX|CUR|MAX)(\d+)_/g, (_, key, num) => { + const index = parseInt(num, 10); + const attributeName = targetValueKeys[index]; + if (!attributeName) + return ""; + const targetCurrent = startingValues[attributeName]; + const targetMax = startingValues[`${attributeName}_max`]; + const startingCurrent = targetValues[attributeName]; + const startingMax = targetValues[`${attributeName}_max`]; + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return `${targetCurrent}`; + case "TMAX": + return `${targetMax}`; + case "CUR": + return `${startingCurrent}`; + case "MAX": + return `${startingMax}`; + default: + return ""; + } + }); + return message; + } + + function cleanValue(value) { + return value.trim().replace(/^['"](.*)['"]$/g, "$1"); + } + function getCharName(targetID) { + const character = getObj("character", targetID); + if (character) { + return character.get("name"); + } + return `ID: ${targetID}`; + } + + const observers = {}; + function registerObserver(event, callback) { + if (!observers[event]) { + observers[event] = []; + } + observers[event].push(callback); + } + function notifyObservers(event, targetID, attributeName, newValue, oldValue) { + const callbacks = observers[event] || []; + callbacks.forEach(callback => { + callback(event, targetID, attributeName, newValue, oldValue); + }); + } + + // region Command Handlers + async function setattr(changes, target, referenced = [], noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const request = createRequestList(referenced, changes, false); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; // skip if no name provided + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Missing attribute ${name} not created for ${characterName}.`); + continue; + } + const event = undefinedAttributes.includes(name) ? "add" : "change"; + if (current !== undefined) { + result[name] = current; + notifyObservers(event, target, name, result[name], currentValues?.[name] ?? undefined); + } + if (max !== undefined) { + result[`${name}_max`] = max; + notifyObservers(event, target, `${name}_max`, result[`${name}_max`], currentValues?.[`${name}_max`] ?? undefined); + } + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function modattr(changes, target, referenced, noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const currentValues = await getCurrentValues(target, referenced, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name] ?? 0); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + notifyObservers("change", target, name, result[name], currentValues[name]); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + notifyObservers("change", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function modbattr(changes, target, referenced, noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name]); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + notifyObservers("change", target, name, result[name], currentValues[name]); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + notifyObservers("change", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + const newMax = result[`${name}_max`] ?? currentValues[`${name}_max`]; + if (newMax !== undefined) { + const start = currentValues[name]; + result[name] = calculateBoundValue(result[name] ?? start, newMax); + } + let newMessage = `Modified attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function resetattr(changes, target, referenced, noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be reset.`); + continue; + } + const maxName = `${name}_max`; + if (currentValues[maxName] !== undefined) { + const maxAsNumber = Number(currentValues[maxName]); + if (isNaN(maxAsNumber)) { + errors.push(`Attribute '${maxName}' is not number-valued and so cannot be used to reset '${name}'.`); + continue; + } + result[name] = maxAsNumber; + } + else { + result[name] = 0; + } + notifyObservers("change", target, name, result[name], currentValues[name]); + let newMessage = `Reset attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function delattr(changes, target, referenced, _, feedback) { + const result = {}; + const messages = []; + const currentValues = await getCurrentValues(target, referenced, changes); + const characterName = getCharName(target); + for (const change of changes) { + const { name } = change; + if (!name) + continue; + result[name] = undefined; + result[`${name}_max`] = undefined; + let newMessage = `Deleted attribute '${name}' on ${characterName}.`; + notifyObservers("destroy", target, name, result[name], currentValues[name]); + if (currentValues[`${name}_max`] !== undefined) { + notifyObservers("destroy", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors: [], + }; + } + const handlers = { + setattr, + modattr, + modbattr, + resetattr, + delattr, + }; + // #region Helper Functions + function createRequestList(referenced, changes, includeMax = true) { + const requestSet = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + requestSet.add(change.name); + if (includeMax) { + requestSet.add(`${change.name}_max`); + } + } + } + return Array.from(requestSet); + } + function extractUndefinedAttributes(attributes) { + const names = []; + for (const name in attributes) { + if (name.endsWith("_max")) + continue; + if (attributes[name] === undefined) { + names.push(name); + } + } + return names; + } + async function getCurrentValues(target, referenced, changes) { + const queriedAttributes = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + queriedAttributes.add(change.name); + if (change.max !== undefined) { + queriedAttributes.add(`${change.name}_max`); + } + } + } + const attributes = await getAttributes(target, Array.from(queriedAttributes)); + return attributes; + } + function calculateModifiedValue(baseValue, modification) { + const operator = getOperator(modification); + baseValue = Number(baseValue); + if (operator) { + modification = Number(String(modification).substring(1)); + } + else { + modification = Number(modification); + } + if (isNaN(baseValue)) + baseValue = 0; + if (isNaN(modification)) + modification = 0; + return applyCalculation(baseValue, modification, operator); + } + function getOperator(value) { + if (typeof value === "string") { + const match = value.match(/^([+\-*/])/); + if (match) { + return match[1]; + } + } + return; + } + function applyCalculation(baseValue, modification, operator = "+") { + modification = Number(modification); + switch (operator) { + case "+": + return baseValue + modification; + case "-": + return baseValue - modification; + case "*": + return baseValue * modification; + case "/": + return modification !== 0 ? baseValue / modification : baseValue; + default: + return baseValue + modification; + } + } + function calculateBoundValue(currentValue, maxValue) { + currentValue = Number(currentValue); + maxValue = Number(maxValue); + if (isNaN(currentValue)) + currentValue = 0; + if (isNaN(maxValue)) + return currentValue; + return Math.max(Math.min(currentValue, maxValue), 0); + } + + const CONFIG_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_BLUE["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_BLUE["50"], + }); + const CONFIG_HEADER_STYLE = s({ + color: COLOR_BLUE["400"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const CONFIG_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", + }); + const CONFIG_ROW_STYLE = s({ + marginBottom: PADDING.XS, + }); + const CONFIG_BUTTON_SHARED = { + color: COLOR_WHITE, + border: "none", + borderRadius: BORDER_RADIUS.SM, + fontSize: FONT_SIZE.SM, + padding: `${PADDING.XS} ${PADDING.SM}`, + textAlign: "center", + width: "100%", + }; + const CONFIG_BUTTON_STYLE_ON = s({ + backgroundColor: COLOR_GREEN["500"], + ...CONFIG_BUTTON_SHARED, + }); + const CONFIG_BUTTON_STYLE_OFF = s({ + backgroundColor: COLOR_RED["300"], + ...CONFIG_BUTTON_SHARED, + }); + const CONFIG_CLEAR_FIX_STYLE = s({ + clear: "both", + }); + function camelToKebabCase(str) { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function createConfigMessage() { + const config = getConfig(); + const configEntries = Object.entries(config); + const relevantEntries = configEntries.filter(([key]) => key !== "version" && key !== "globalconfigCache" && key !== "flags"); + return (h("div", { style: CONFIG_WRAPPER_STYLE }, + h("div", { style: CONFIG_HEADER_STYLE }, "ChatSetAttr Configuration"), + h("div", { style: CONFIG_BODY_STYLE }, + h("table", { style: CONFIG_TABLE_STYLE }, relevantEntries.map(([key, value]) => (h("tr", { style: CONFIG_ROW_STYLE }, + h("td", null, + h("strong", null, + key, + ":")), + h("td", null, + h("a", { href: `!setattr-config --${camelToKebabCase(key)}`, style: value ? CONFIG_BUTTON_STYLE_ON : CONFIG_BUTTON_STYLE_OFF }, value ? "Enabled" : "Disabled")))))), + h("div", { style: CONFIG_CLEAR_FIX_STYLE })))); + } + + const SCHEMA_VERSION = "2.0"; + const DEFAULT_CONFIG = { + version: SCHEMA_VERSION, + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + flags: [] + }; + function getConfig() { + const stateConfig = state?.ChatSetAttr || {}; + return { + ...DEFAULT_CONFIG, + ...stateConfig, + }; + } + function setConfig(newConfig) { + const stateConfig = state.ChatSetAttr || {}; + state.ChatSetAttr = { + ...stateConfig, + ...newConfig, + globalconfigCache: { + lastsaved: Date.now() + } + }; + } + function hasFlag(flag) { + const config = getConfig(); + return config.flags.includes(flag); + } + function setFlag(flag) { + const config = getConfig(); + if (!hasFlag(flag)) { + config.flags.push(flag); + setConfig({ flags: config.flags }); + } + } + function checkConfigMessage(message) { + return message.startsWith("!setattr-config"); + } + const FLAG_MAP = { + "--players-can-modify": "playersCanModify", + "--players-can-evaluate": "playersCanEvaluate", + "--players-can-target-party": "playersCanTargetParty", + "--use-workers": "useWorkers", + }; + function handleConfigCommand(message) { + message = message.replace("!setattr-config", "").trim(); + const args = message.split(/\s+/); + const newConfig = {}; + for (const arg of args) { + const cleanArg = arg.toLowerCase(); + const flag = FLAG_MAP[cleanArg]; + if (flag !== undefined) { + newConfig[flag] = !getConfig()[flag]; + log(`Toggled config option: ${flag} to ${newConfig[flag]}`); + } + } + setConfig(newConfig); + const configMessage = createConfigMessage(); + sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); + } + + function createHelpHandout(handoutID) { + const contents = [ + "Basic Usage", + "Available Commands", + "Target Selection", + "Attribute Syntax", + "Modifier Options", + "Output Control Options", + "Inline Roll Integration", + "Repeating Section Support", + "Special Value Expressions", + "Global Configuration", + "Complete Examples", + "For Developers", + ]; + function createTableOfContents() { + return (h("ol", null, contents.map(section => (h("li", { key: section }, + h("a", { href: `http://journal.roll20.net/handout/${handoutID}/#${section.replace(/\s+/g, "%20")}` }, section)))))); + } + return (h("div", null, + h("h1", null, "ChatSetAttr"), + h("p", null, "ChatSetAttr is a Roll20 API script that allows users to create, modify, or delete character sheet attributes through chat commands macros. Whether you need to update a single character attribute or make bulk changes across multiple characters, ChatSetAttr provides flexible options to streamline your game management."), + h("h2", null, "Table of Contents"), + createTableOfContents(), + h("h2", { id: "basic-usage" }, "Basic Usage"), + h("p", null, "The script provides several command formats:"), + h("ul", null, + h("li", null, + h("code", null, "!setattr [--options]"), + " - Create or modify attributes"), + h("li", null, + h("code", null, "!modattr [--options]"), + " - Shortcut for ", + h("code", null, "!setattr --mod"), + " (adds to existing values)"), + h("li", null, + h("code", null, "!modbattr [--options]"), + " - Shortcut for ", + h("code", null, "!setattr --modb"), + " (adds to values with bounds)"), + h("li", null, + h("code", null, "!resetattr [--options]"), + " - Shortcut for ", + h("code", null, "!setattr --reset"), + " (resets to max values)"), + h("li", null, + h("code", null, "!delattr [--options]"), + " - Delete attributes")), + h("p", null, "Each command requires a target selection option and one or more attributes to modify."), + h("p", null, + h("strong", null, "Basic structure:")), + h("pre", null, + h("code", null, "!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2")), + h("h2", { id: "available-commands" }, "Available Commands"), + h("h3", null, "!setattr"), + h("p", null, + "Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless ", + h("code", null, "--nocreate"), + " is specified)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --hp|25|50 --xp|0|800")), + h("p", null, + "This would set ", + h("code", null, "hp"), + " to 25, ", + h("code", null, "hp_max"), + " to 50, ", + h("code", null, "xp"), + " to 0 and ", + h("code", null, "xp_max"), + " to 800."), + h("h3", null, "!modattr"), + h("p", null, + "Adds to existing attribute values (works only with numeric values). Shorthand for ", + h("code", null, "!setattr --mod"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!modattr --sel --hp|-5 --xp|100")), + h("p", null, + "This subtracts 5 from ", + h("code", null, "hp"), + " and adds 100 to ", + h("code", null, "xp"), + "."), + h("h3", null, "!modbattr"), + h("p", null, + "Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for ", + h("code", null, "!setattr --modb"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!modbattr --sel --hp|-25 --xp|2500")), + h("p", null, + "This subtracts 5 from ", + h("code", null, "hp"), + " but won't reduce it below 0 and increase ", + h("code", null, "xp"), + " by 25, but won't increase it above ", + h("code", null, "mp_xp"), + "."), + h("h3", null, "!resetattr"), + h("p", null, + "Resets attributes to their maximum value. Shorthand for ", + h("code", null, "!setattr --reset"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!resetattr --sel --hp --xp")), + h("p", null, + "This resets ", + h("code", null, "hp"), + ", and ", + h("code", null, "xp"), + " to their respective maximum values."), + h("h3", null, "!delattr"), + h("p", null, "Deletes the specified attributes."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!delattr --sel --hp --xp")), + h("p", null, + "This removes the ", + h("code", null, "hp"), + " and ", + h("code", null, "xp"), + " attributes."), + h("h2", { id: "target-selection" }, "Target Selection"), + h("p", null, "One of these options must be specified to determine which characters will be affected:"), + h("h3", null, "--all"), + h("p", null, + "Affects all characters in the campaign. ", + h("strong", null, "GM only"), + " and should be used with caution, especially in large campaigns."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --all --hp|15")), + h("h3", null, "--allgm"), + h("p", null, + "Affects all characters without player controllers (typically NPCs). ", + h("strong", null, "GM only"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --allgm --xp|150")), + h("h3", null, "--allplayers"), + h("p", null, "Affects all characters with player controllers (typically PCs)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --allplayers --hp|15")), + h("h3", null, "--charid"), + h("p", null, "Affects characters with the specified character IDs. Non-GM players can only affect characters they control."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --charid --xp|150")), + h("h3", null, "--name"), + h("p", null, "Affects characters with the specified names. Non-GM players can only affect characters they control."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"")), + h("h3", null, "--sel"), + h("p", null, "Affects characters represented by currently selected tokens."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --hp|25 --xp|30")), + h("h3", null, "--sel-party"), + h("p", null, + "Affects only party characters represented by currently selected tokens (characters with ", + h("code", null, "inParty"), + " set to true)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel-party --inspiration|1")), + h("h3", null, "--sel-noparty"), + h("p", null, + "Affects only non-party characters represented by currently selected tokens (characters with ", + h("code", null, "inParty"), + " set to false or not set)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel-noparty --npc_status|\"Hostile\"")), + h("h3", null, "--party"), + h("p", null, + "Affects all characters marked as party members (characters with ", + h("code", null, "inParty"), + " set to true). ", + h("strong", null, "GM only by default"), + ", but can be enabled for players with configuration."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --party --rest_complete|1")), + h("h2", { id: "attribute-syntax" }, "Attribute Syntax"), + h("p", null, "The syntax for specifying attributes is:"), + h("pre", null, + h("code", null, "--attributeName|currentValue|maxValue")), + h("ul", null, + h("li", null, + h("code", null, "attributeName"), + " is the name of the attribute to modify"), + h("li", null, + h("code", null, "currentValue"), + " is the value to set (optional for some commands)"), + h("li", null, + h("code", null, "maxValue"), + " is the maximum value to set (optional)")), + h("h3", null, "Examples:"), + h("ol", null, + h("li", null, + "Set current value only:", + h("pre", null, + h("code", null, "--strength|15"))), + h("li", null, + "Set both current and maximum values:", + h("pre", null, + h("code", null, "--hp|27|35"))), + h("li", null, + "Set only the maximum value (leave current unchanged):", + h("pre", null, + h("code", null, "--hp||50"))), + h("li", null, + "Create empty attribute or set to empty:", + h("pre", null, + h("code", null, "--notes|"))), + h("li", null, + "Use ", + h("code", null, "#"), + " instead of ", + h("code", null, "|"), + " (useful in roll queries):", + h("pre", null, + h("code", null, "--strength#15")))), + h("h2", { id: "modifier-options" }, "Modifier Options"), + h("p", null, "These options change how attributes are processed:"), + h("h3", null, "--mod"), + h("p", null, + "See ", + h("code", null, "!modattr"), + " command."), + h("h3", null, "--modb"), + h("p", null, + "See ", + h("code", null, "!modbattr"), + " command."), + h("h3", null, "--reset"), + h("p", null, + "See ", + h("code", null, "!resetattr"), + " command."), + h("h3", null, "--nocreate"), + h("p", null, "Prevents creation of new attributes, only updates existing ones."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --nocreate --perception|20 --xp|15")), + h("p", null, + "This will only update ", + h("code", null, "perception"), + " or ", + h("code", null, "xp"), + " if it already exists."), + h("h3", null, "--evaluate"), + h("p", null, + "Evaluates JavaScript expressions in attribute values. ", + h("strong", null, "GM only by default"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --evaluate --hp|2 * 3")), + h("p", null, + "This will set the ", + h("code", null, "hp"), + " attribute to 6."), + h("h3", null, "--replace"), + h("p", null, "Replaces special characters to prevent Roll20 from evaluating them:"), + h("ul", null, + h("li", null, "< becomes ["), + h("li", null, "> becomes ]"), + h("li", null, "~ becomes -"), + h("li", null, "; becomes ?"), + h("li", null, "` becomes @")), + h("p", null, "Also supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"")), + h("p", null, "This stores \"Roll [[1d6]] to succeed\" without evaluating the roll."), + h("h2", { id: "output-control-options" }, "Output Control Options"), + h("p", null, "These options control the feedback messages generated by the script:"), + h("h3", null, "--silent"), + h("p", null, "Suppresses normal output messages (error messages will still appear)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --silent --stealth|20")), + h("h3", null, "--mute"), + h("p", null, "Suppresses all output messages, including errors."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --mute --nocreate --new_value|42")), + h("h3", null, "--fb-public"), + h("p", null, "Sends output publicly to the chat instead of whispering to the command sender."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"")), + h("h3", null, "--fb-from "), + h("p", null, "Changes the name of the sender for output messages (default is \"ChatSetAttr\")."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-from \"Healing Potion\" --hp|25")), + h("h3", null, "--fb-header "), + h("p", null, "Customizes the header of the output message."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5")), + h("h3", null, "--fb-content "), + h("p", null, "Customizes the content of the output message."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10")), + h("h3", null, "Special Placeholders"), + h("p", null, + "For use in ", + h("code", null, "--fb-header"), + " and ", + h("code", null, "--fb-content"), + ":"), + h("ul", null, + h("li", null, + h("code", null, "_NAMEJ_"), + " - Name of the Jth attribute being changed"), + h("li", null, + h("code", null, "_TCURJ_"), + " - Target current value of the Jth attribute"), + h("li", null, + h("code", null, "_TMAXJ_"), + " - Target maximum value of the Jth attribute")), + h("p", null, + "For use in ", + h("code", null, "--fb-content"), + " only:"), + h("ul", null, + h("li", null, + h("code", null, "_CHARNAME_"), + " - Name of the character"), + h("li", null, + h("code", null, "_CURJ_"), + " - Final current value of the Jth attribute"), + h("li", null, + h("code", null, "_MAXJ_"), + " - Final maximum value of the Jth attribute")), + h("p", null, + h("strong", null, "Important:"), + " The Jth index starts with 0 at the first item."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10")), + h("h2", { id: "inline-roll-integration" }, "Inline Roll Integration"), + h("p", null, "ChatSetAttr can be used within roll templates or combined with inline rolls:"), + h("h3", null, "Within Roll Templates"), + h("p", null, + "Place the command between roll template properties and end it with ", + h("code", null, "!!!"), + ":"), + h("pre", null, + h("code", null, "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}")), + h("h3", null, "Using Inline Rolls in Values"), + h("p", null, "Inline rolls can be used for attribute values:"), + h("pre", null, + h("code", null, "!setattr --sel --hp|[[2d6+5]]")), + h("h3", null, "Roll Queries"), + h("p", null, "Roll queries can determine attribute values:"), + h("pre", null, + h("code", null, "!setattr --sel --hp|?{Set strength to what value?|100}")), + h("h2", { id: "repeating-section-support" }, "Repeating Section Support"), + h("p", null, "ChatSetAttr supports working with repeating sections:"), + h("h3", null, "Creating New Repeating Items"), + h("p", null, + "Use ", + h("code", null, "-CREATE"), + " to create a new row in a repeating section:"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_-CREATE_itemname|\"Magic Sword\" --repeating_inventory_-CREATE_itemweight|2")), + h("h3", null, "Modifying Existing Repeating Items"), + h("p", null, "Access by row ID:"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_-ID_itemname|\"Enchanted Magic Sword\"")), + h("p", null, "Access by index (starts at 0):"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"")), + h("h3", null, "Deleting Repeating Rows"), + h("p", null, "Delete by row ID:"), + h("pre", null, + h("code", null, "!delattr --sel --repeating_inventory_-ID")), + h("p", null, "Delete by index:"), + h("pre", null, + h("code", null, "!delattr --sel --repeating_inventory_$0")), + h("h2", { id: "special-value-expressions" }, "Special Value Expressions"), + h("h3", null, "Attribute References"), + h("p", null, + "Reference other attribute values using ", + h("code", null, "%attribute_name%"), + ":"), + h("pre", null, + h("code", null, "!setattr --sel --evaluate --temp_hp|%hp% / 2")), + h("h3", null, "Resetting to Maximum"), + h("p", null, "Reset an attribute to its maximum value:"), + h("pre", null, + h("code", null, "!setattr --sel --hp|%hp_max%")), + h("h2", { id: "global-configuration" }, "Global Configuration"), + h("p", null, + "The script has four global configuration options that can be toggled with ", + h("code", null, "!setattr-config"), + ":"), + h("h3", null, "--players-can-modify"), + h("p", null, "Allows players to modify attributes on characters they don't control."), + h("pre", null, + h("code", null, "!setattr-config --players-can-modify")), + h("h3", null, "--players-can-evaluate"), + h("p", null, + "Allows players to use the ", + h("code", null, "--evaluate"), + " option."), + h("pre", null, + h("code", null, "!setattr-config --players-can-evaluate")), + h("h3", null, "--players-can-target-party"), + h("p", null, + "Allows players to use the ", + h("code", null, "--party"), + " target option. ", + h("strong", null, "GM only by default"), + "."), + h("pre", null, + h("code", null, "!setattr-config --players-can-target-party")), + h("h3", null, "--use-workers"), + h("p", null, "Toggles whether the script triggers sheet workers when setting attributes."), + h("pre", null, + h("code", null, "!setattr-config --use-workers")), + h("h2", { id: "complete-examples" }, "Complete Examples"), + h("h3", null, "Basic Combat Example"), + h("p", null, "Reduce a character's HP and status after taking damage:"), + h("pre", null, + h("code", null, "!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"")), + h("h3", null, "Leveling Up a Character"), + h("p", null, "Update multiple stats when a character gains a level:"), + h("pre", null, + h("code", null, "!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public")), + h("h3", null, "Create New Item in Inventory"), + h("p", null, "Add a new item to a character's inventory:"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_-CREATE_itemname|\"Healing Potion\" --repeating_inventory_-CREATE_itemcount|3 --repeating_inventory_-CREATE_itemweight|0.5 --repeating_inventory_-CREATE_itemcontent|\"Restores 2d8+2 hit points when consumed\"")), + h("h3", null, "Apply Status Effects During Combat"), + h("p", null, "Apply a debuff to selected enemies in the middle of combat:"), + h("pre", null, + h("code", null, "&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}")), + h("h3", null, "Party Management Examples"), + h("p", null, "Give inspiration to all party members after a great roleplay moment:"), + h("pre", null, + h("code", null, "!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"")), + h("p", null, "Apply a long rest to only party characters among selected tokens:"), + h("pre", null, + h("code", null, "!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"")), + h("p", null, "Set hostile status for non-party characters among selected tokens:"), + h("pre", null, + h("code", null, "!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"")), + h("h2", { id: "for-developers" }, "For Developers"), + h("h3", null, "Registering Observers"), + h("p", null, "If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:"), + h("pre", null, + h("code", null, "ChatSetAttr.registerObserver(event, observer);")), + h("p", null, + "Where ", + h("code", null, "event"), + " is one of:"), + h("ul", null, + h("li", null, + h("code", null, "\"add\""), + " - Called when attributes are created"), + h("li", null, + h("code", null, "\"change\""), + " - Called when attributes are modified"), + h("li", null, + h("code", null, "\"destroy\""), + " - Called when attributes are deleted")), + h("p", null, + "And ", + h("code", null, "observer"), + " is an event handler function similar to Roll20's built-in event handlers."), + h("p", null, "This allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface."))); + } + + function checkHelpMessage(msg) { + return msg.trim().toLowerCase().startsWith("!setattrs-help"); + } + function handleHelpCommand() { + let handout = findObjs({ + _type: "handout", + name: "ChatSetAttr Help", + })[0]; + if (!handout) { + handout = createObj("handout", { + name: "ChatSetAttr Help", + }); + } + const helpContent = createHelpHandout(handout.id); + handout.set({ + "inplayerjournals": "all", + "notes": helpContent, + }); + } + + // #region Commands + const COMMAND_TYPE = [ + "setattr", + "modattr", + "modbattr", + "resetattr", + "delattr" + ]; + function isCommand(command) { + return COMMAND_TYPE.includes(command); + } + // #region Command Options + const COMMAND_OPTIONS = [ + "mod", + "modb", + "reset" + ]; + const OVERRIDE_DICTIONARY = { + "mod": "modattr", + "modb": "modbattr", + "reset": "resetattr", + }; + function isCommandOption(option) { + return COMMAND_OPTIONS.includes(option); + } + // #region Targets + const TARGETS = [ + "all", + "allgm", + "allplayers", + "charid", + "name", + "sel", + "sel-noparty", + "sel-party", + "party", + ]; + // #region Feedback + const FEEDBACK_OPTIONS = [ + "fb-public", + "fb-from", + "fb-header", + "fb-content", + ]; + function isFeedbackOption(option) { + for (const fbOption of FEEDBACK_OPTIONS) { + if (option.startsWith(fbOption)) + return true; + } + return false; + } + function extractFeedbackKey(option) { + if (option === "fb-public") + return "public"; + if (option === "fb-from") + return "from"; + if (option === "fb-header") + return "header"; + if (option === "fb-content") + return "content"; + return false; + } + // #region Options + const OPTIONS = [ + "nocreate", + "evaluate", + "replace", + "silent", + "mute", + ]; + function isOption(option) { + return OPTIONS.includes(option); + } + // #region Alias Characters + const ALIAS_CHARACTERS = { + "<": "[", + ">": "]", + "~": "-", + ";": "?", + "`": "@", + }; + + // #region Inline Message Extraction and Validation + function validateMessage(content) { + for (const command of COMMAND_TYPE) { + const messageCommand = content.split(" ")[0]; + if (messageCommand === `!${command}`) { + return true; + } + } + return false; + } + function extractMessageFromRollTemplate(msg) { + for (const command of COMMAND_TYPE) { + if (msg.content.includes(command)) { + const regex = new RegExp(`(!${command}.*?)!!!`, "gi"); + const match = regex.exec(msg.content); + if (match) + return match[1].trim(); + } + } + return false; + } + // #region Message Parsing + function extractOperation(parts) { + if (parts.length === 0) + throw new Error("Empty command"); + const command = parts.shift().slice(1); // remove the leading '!' + const isValidCommand = isCommand(command); + if (!isValidCommand) + throw new Error(`Invalid command: ${command}`); + return command; + } + function extractReferences(value) { + if (typeof value !== "string") + return []; + const matches = value.matchAll(/%[a-zA-Z0-9_]+%/g); + return Array.from(matches, m => m[0]); + } + function splitMessage(content) { + const split = content.split("--").map(part => part.trim()); + return split; + } + function includesATarget(part) { + if (part.includes("|") || part.includes("#")) + return false; + [part] = part.split(" ").map(p => p.trim()); + for (const target of TARGETS) { + const isMatch = part.toLowerCase() === target.toLowerCase(); + if (isMatch) + return true; + } + return false; + } + function parseMessage(content) { + const parts = splitMessage(content); + let operation = extractOperation(parts); + const targeting = []; + const options = {}; + const changes = []; + const references = []; + const feedback = { public: false }; + for (const part of parts) { + if (isCommandOption(part)) { + operation = OVERRIDE_DICTIONARY[part]; + } + else if (isOption(part)) { + options[part] = true; + } + else if (includesATarget(part)) { + targeting.push(part); + } + else if (isFeedbackOption(part)) { + const [key, ...valueParts] = part.split(" "); + const value = valueParts.join(" "); + const feedbackKey = extractFeedbackKey(key); + if (!feedbackKey) + continue; + if (feedbackKey === "public") { + feedback.public = true; + } + else { + feedback[feedbackKey] = cleanValue(value); + } + } + else if (part.includes("|") || part.includes("#")) { + const split = part.split(/[|#]/g).map(p => p.trim()); + const [attrName, attrCurrent, attrMax] = split; + if (!attrName && !attrCurrent && !attrMax) { + continue; + } + const attribute = {}; + if (attrName) + attribute.name = attrName; + if (attrCurrent) + attribute.current = cleanValue(attrCurrent); + if (attrMax) + attribute.max = cleanValue(attrMax); + changes.push(attribute); + const currentMatches = extractReferences(attrCurrent); + const maxMatches = extractReferences(attrMax); + references.push(...currentMatches, ...maxMatches); + } + else { + const suspectedAttribute = part.replace(/[^a-zA-Z0-9_$]/g, ""); + if (!suspectedAttribute) + continue; + changes.push({ name: suspectedAttribute }); + } + } + return { + operation, + options, + targeting, + changes, + references, + feedback, + }; + } + + function extractRepeatingParts(attributeName) { + const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); + if (repeating !== "repeating") { + return null; + } + const field = fieldParts.join("_"); + if (!section || !identifier || !field) { + return null; + } + return { + section, + identifier, + field + }; + } + function isRepeatingAttribute(attributeName) { + const parts = extractRepeatingParts(attributeName); + return parts !== null; + } + function hasCreateIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (parts) { + const hasIndentifier = parts.identifier.toLowerCase().includes("create"); + return hasIndentifier; + } + const hasIndentifier = attributeName.toLowerCase().includes("create"); + return hasIndentifier; + } + function convertRepOrderToArray(repOrder) { + return repOrder.split(",").map(id => id.trim()); + } + async function getRepOrderForSection(characterID, section) { + const repOrderAttribute = `_reporder_repeating_${section}`; + const repOrder = await libSmartAttributes.getAttribute(characterID, repOrderAttribute); + return repOrder; + } + function extractRepeatingAttributes(attributes) { + return attributes.filter(attr => attr.name && isRepeatingAttribute(attr.name)); + } + function getAllSectionNames(attributes) { + const sectionNames = new Set(); + const repeatingAttributes = extractRepeatingAttributes(attributes); + for (const attr of repeatingAttributes) { + if (!attr.name) + continue; + const parts = extractRepeatingParts(attr.name); + if (!parts) + continue; + sectionNames.add(parts.section); + } + return Array.from(sectionNames); + } + async function getAllRepOrders(characterID, sectionNames) { + const repOrders = {}; + for (const section of sectionNames) { + const repOrderString = await getRepOrderForSection(characterID, section); + if (repOrderString && typeof repOrderString === "string") { + repOrders[section] = convertRepOrderToArray(repOrderString); + } + else { + repOrders[section] = []; + } + } + return repOrders; + } + + function processModifierValue(modification, resolvedAttributes, { shouldEvaluate = false, shouldAlias = false } = {}) { + let finalValue = replacePlaceholders(modification, resolvedAttributes); + if (shouldAlias) { + finalValue = replaceAliasCharacters(finalValue); + } + if (shouldEvaluate) { + finalValue = evaluateExpression(finalValue); + } + return finalValue; + } + function replaceAliasCharacters(modification) { + let result = modification; + for (const alias in ALIAS_CHARACTERS) { + const original = ALIAS_CHARACTERS[alias]; + const regex = new RegExp(`\\${alias}`, "g"); + result = result.replace(regex, original); + } + return result; + } + function replacePlaceholders(value, attributes) { + if (typeof value !== "string") + return value; + return value.replace(/%([a-zA-Z0-9_]+)%/g, (match, name) => { + const replacement = attributes[name]; + return replacement !== undefined ? String(replacement) : match; + }); + } + function evaluateExpression(expression) { + try { + const stringValue = String(expression); + const result = eval(stringValue); + return result; + } + catch { + return expression; + } + } + function processModifierName(name, { repeatingID, repOrder }) { + let result = name; + const hasCreate = result.includes("CREATE"); + if (hasCreate && repeatingID) { + result = result.replace("CREATE", repeatingID); + } + const rowIndexMatch = result.match(/\$(\d+)/); + if (rowIndexMatch && repOrder) { + const rowIndex = parseInt(rowIndexMatch[1], 10); + const rowID = repOrder[rowIndex]; + if (!rowID) + return result; + result = result.replace(`$${rowIndex}`, rowID); + } + return result; + } + function processModifications(modifications, resolved, options, repOrders) { + const processedModifications = []; + const repeatingID = libUUID.generateRowID(); + for (const mod of modifications) { + if (!mod.name) + continue; + let processedName = mod.name; + const parts = extractRepeatingParts(mod.name); + if (parts) { + const hasCreate = hasCreateIdentifier(parts.identifier); + const repOrder = repOrders[parts.section] || []; + processedName = processModifierName(mod.name, { + repeatingID: hasCreate ? repeatingID : parts.identifier, + repOrder, + }); + } + let processedCurrent = undefined; + if (mod.current !== "undefined") { + processedCurrent = String(mod.current); + processedCurrent = processModifierValue(processedCurrent, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + let processedMax = undefined; + if (mod.max !== undefined) { + processedMax = String(mod.max); + processedMax = processModifierValue(processedMax, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + const processedMod = { + name: processedName, + }; + if (processedCurrent !== undefined) { + processedMod.current = processedCurrent; + } + if (processedMax !== undefined) { + processedMod.max = processedMax; + } + processedModifications.push(processedMod); + } + return processedModifications; + } + + const permissions = { + playerID: "", + isGM: false, + canModify: false, + }; + function checkPermissions(playerID) { + const player = getObj("player", playerID); + if (!player) { + throw new Error(`Player with ID ${playerID} not found.`); + } + const isGM = playerIsGM(playerID); + const config = state.ChatSetAttr?.config || {}; + const playersCanModify = config.playersCanModify || false; + const canModify = isGM || playersCanModify; + setPermissions(playerID, isGM, canModify); + } + function setPermissions(playerID, isGM, canModify) { + permissions.playerID = playerID; + permissions.isGM = isGM; + permissions.canModify = canModify; + } + function getPermissions() { + return { ...permissions }; + } + function checkPermissionForTarget(playerID, target) { + const player = getObj("player", playerID); + if (!player) { + return false; + } + const isGM = playerIsGM(playerID); + if (isGM) { + return true; + } + const character = getObj("character", target); + if (!character) { + return false; + } + const controlledBy = (character.get("controlledby") || "").split(","); + return controlledBy.includes(playerID); + } + + function generateSelectedTargets(message, type) { + const errors = []; + const targets = []; + if (!message.selected) + return { targets, errors }; + for (const token of message.selected) { + const tokenObj = getObj("graphic", token._id); + if (!tokenObj) { + errors.push(`Selected token with ID ${token._id} not found.`); + continue; + } + if (tokenObj.get("_subtype") !== "token") { + errors.push(`Selected object with ID ${token._id} is not a token.`); + continue; + } + const represents = tokenObj.get("represents"); + const character = getObj("character", represents); + if (!character) { + errors.push(`Token with ID ${token._id} does not represent a character.`); + continue; + } + const inParty = character.get("inParty"); + if (type === "sel-noparty" && inParty) { + continue; + } + if (type === "sel-party" && !inParty) { + continue; + } + targets.push(character.id); + } + return { + targets, + errors, + }; + } + function generateAllTargets(type) { + const { isGM } = getPermissions(); + const errors = []; + if (!isGM) { + errors.push(`Only GMs can use the '${type}' target option.`); + return { + targets: [], + errors, + }; + } + const characters = findObjs({ _type: "character" }); + if (type === "all") { + return { + targets: characters.map(char => char.id), + errors, + }; + } + else if (type === "allgm") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + else if (type === "allplayers") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !!controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + return { + targets: [], + errors: [`Unknown target type '${type}'.`], + }; + } + function generateCharacterIDTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const charID of values) { + const character = getObj("character", charID); + if (!character) { + errors.push(`Character with ID ${charID} not found.`); + continue; + } + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with ID ${charID}.`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generatePartyTargets() { + const { isGM } = getPermissions(); + const { playersCanTargetParty } = getConfig(); + const targets = []; + const errors = []; + if (!isGM && !playersCanTargetParty) { + errors.push("Only GMs can use the 'party' target option."); + return { + targets, + errors, + }; + } + const characters = findObjs({ _type: "character", inParty: true }); + for (const character of characters) { + const characterID = character.id; + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generateNameTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const name of values) { + const characters = findObjs({ _type: "character", name: name }); + if (characters.length === 0) { + errors.push(`Character with name "${name}" not found.`); + continue; + } + if (characters.length > 1) { + errors.push(`Multiple characters found with name "${name}". Please use character ID instead.`); + continue; + } + const character = characters[0]; + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with name "${name}".`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generateTargets(message, targetOptions) { + const characterIDs = []; + const errors = []; + for (const option of targetOptions) { + const [type, ...values] = option.split(/[, ]/).map(v => v.trim()).filter(v => v.length > 0); + if (type === "sel" || type === "sel-noparty" || type === "sel-party") { + const results = generateSelectedTargets(message, type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "all" || type === "allgm" || type === "allplayers") { + const results = generateAllTargets(type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "charid") { + const results = generateCharacterIDTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "name") { + const results = generateNameTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "party") { + const results = generatePartyTargets(); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + } + const targets = Array.from(new Set(characterIDs)); + return { + targets, + errors, + }; + } + + const timerMap = new Map(); + function startTimer(key, duration = 50, callback) { + // Clear any existing timer for the same key + const existingTimer = timerMap.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + callback(); + timerMap.delete(key); + }, duration); + timerMap.set(key, timer); + } + function clearTimer(key) { + const timer = timerMap.get(key); + if (timer) { + clearTimeout(timer); + timerMap.delete(key); + } + } + + async function makeUpdate(operation, results, options) { + const isSetting = operation !== "delattr"; + const errors = []; + const messages = []; + const { noCreate = false } = {}; + const { setWithWorker = false } = getConfig() || {}; + const setOptions = { + noCreate, + setWithWorker, + }; + for (const target in results) { + for (const name in results[target]) { + const isMax = name.endsWith("_max"); + const type = isMax ? "max" : "current"; + const actualName = isMax ? name.slice(0, -4) : name; + if (isSetting) { + const value = results[target][name] ?? ""; + try { + await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + } + catch (error) { + errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); + } + } + else { + try { + await libSmartAttributes.deleteAttribute(target, actualName, type); + } + catch (error) { + errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); + } + } + } + } + return { errors, messages }; + } + + function broadcastHeader() { + log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); + } + function checkDependencies() { + if (libSmartAttributes === undefined) { + throw new Error("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + } + if (libUUID === undefined) { + throw new Error("libUUID is required but not found. Please ensure the libUUID script is installed."); + } + } + async function acceptMessage(msg) { + // State + const errors = []; + const messages = []; + const result = {}; + // Parse Message + const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); + // Start Timer + startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + // Preprocess + const { targets, errors: targetErrors } = generateTargets(msg, targeting); + errors.push(...targetErrors); + const request = generateRequest(references, changes); + const command = handlers[operation]; + if (!command) { + errors.push(`No handler found for operation: ${operation}`); + sendErrors(msg.playerid, "Errors", errors); + return; + } + // Execute + for (const target of targets) { + const attrs = await getAttributes(target, request); + const sectionNames = getAllSectionNames(changes); + const repOrders = await getAllRepOrders(target, sectionNames); + const modifications = processModifications(changes, attrs, options, repOrders); + const response = await command(modifications, target, references, options.nocreate, feedback); + if (response.errors.length > 0) { + errors.push(...response.errors); + continue; + } + messages.push(...response.messages); + result[target] = response.result; + } + const updateResult = await makeUpdate(operation, result); + clearTimer("chatsetattr"); + messages.push(...updateResult.messages); + errors.push(...updateResult.errors); + if (options.silent) + return; + sendErrors(msg.playerid, "Errors", errors, feedback?.from); + if (options.mute) + return; + const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; + const feedbackTitle = feedback?.header ?? delSetTitle; + sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); + } + function generateRequest(references, changes) { + const referenceSet = new Set(references); + for (const change of changes) { + if (change.name && !referenceSet.has(change.name)) { + referenceSet.add(change.name); + } + if (change.max !== undefined) { + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); + } + } + } + return Array.from(referenceSet); + } + function registerHandlers() { + broadcastHeader(); + checkDependencies(); + on("chat:message", (msg) => { + if (msg.type !== "api") { + const inlineMessage = extractMessageFromRollTemplate(msg); + if (!inlineMessage) + return; + msg.content = inlineMessage; + } + const debugReset = msg.content.startsWith("!setattrs-debugreset"); + if (debugReset) { + log("ChatSetAttr: Debug - resetting state."); + state.ChatSetAttr = {}; + return; + } + const debugVersion = msg.content.startsWith("!setattrs-debugversion"); + if (debugVersion) { + log("ChatSetAttr: Debug - setting version to 1.10."); + state.ChatSetAttr.version = "1.10"; + return; + } + const isHelpMessage = checkHelpMessage(msg.content); + if (isHelpMessage) { + handleHelpCommand(); + return; + } + const isConfigMessage = checkConfigMessage(msg.content); + if (isConfigMessage) { + handleConfigCommand(msg.content); + return; + } + const validMessage = validateMessage(msg.content); + if (!validMessage) + return; + checkPermissions(msg.playerid); + acceptMessage(msg); + }); + } + + const v2_0 = { + appliesTo: "<=1.10", + version: "2.0", + update: () => { + // Update state data + const config = getConfig(); + config.version = "2.0"; + config.playersCanTargetParty = true; + setConfig(config); + // Send message explaining update + const title = "ChatSetAttr Updated to Version 2.0"; + const content = ` +
+

ChatSetAttr has been updated to version 2.0!

+

This update includes important changes to improve compatibility and performance.

+ + Changelog: +
    +
  • Added compatibility for Beacon sheets, including the new Dungeons and Dragons character sheet.
  • +
  • Added support for targeting party members with the --party flag.
  • +
  • Added support for excluding party members when targeting selected tokens with the --sel-noparty flag.
  • +
  • Added support for including only party members when targeting selected tokens with the --sel-party flag.
  • +
+ +

Please review the updated documentation for details on these new features and how to use them.

+
+ If you encounter any bugs or issues, please report them via the Roll20 Helpdesk +
+
+ If you want to create a handout with the updated documentation, use the command !setattrs-help or click the button below + Create Help Handout +
+
+ `; + sendNotification(title, content, false); + }, + }; + + const VERSION_HISTORY = [ + v2_0, + ]; + function welcome() { + const hasWelcomed = hasFlag("welcome"); + if (hasWelcomed) { + return; + } + sendWelcomeMessage(); + setFlag("welcome"); + } + function update() { + log("ChatSetAttr: Checking for updates..."); + const config = getConfig(); + let currentVersion = config.version || "1.10"; + log(`ChatSetAttr: Current version: ${currentVersion}`); + if (currentVersion === 3) { + currentVersion = "1.10"; + } + log(`ChatSetAttr: Normalized current version: ${currentVersion}`); + checkForUpdates(currentVersion); + } + function checkForUpdates(currentVersion) { + for (const version of VERSION_HISTORY) { + log(`ChatSetAttr: Evaluating version update to ${version.version} (appliesTo: ${version.appliesTo})`); + const applies = version.appliesTo; + const versionString = applies.replace(/(<=|<|>=|>|=)/, "").trim(); + const comparison = applies.replace(versionString, "").trim(); + const compared = compareVersions(currentVersion, versionString); + let shouldApply = false; + switch (comparison) { + case "<=": + shouldApply = compared <= 0; + break; + case "<": + shouldApply = compared < 0; + break; + case ">=": + shouldApply = compared >= 0; + break; + case ">": + shouldApply = compared > 0; + break; + case "=": + shouldApply = compared === 0; + break; + } + if (shouldApply) { + version.update(); + currentVersion = version.version; + updateVersionInState(currentVersion); + } + } + } + function compareVersions(v1, v2) { + const [major1, minor1 = 0, patch1 = 0] = v1.split(".").map(Number); + const [major2, minor2 = 0, patch2 = 0] = v2.split(".").map(Number); + if (major1 !== major2) { + return major1 - major2; + } + if (minor1 !== minor2) { + return minor1 - minor2; + } + return patch1 - patch2; + } + function updateVersionInState(newVersion) { + const config = getConfig(); + config.version = newVersion; + setConfig(config); + } + + on("ready", () => { + registerHandlers(); + update(); + welcome(); + }); + + exports.registerObserver = registerObserver; + + return exports; + +})({}); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index e4cd91e789..6d15e25c9b 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1,785 +1,2159 @@ -// ChatSetAttr version 1.10 -// Last Updated: 2020-09-03 -// A script to create, modify, or delete character attributes from the chat area or macros. -// If you don't like my choices for --replace, you can edit the replacers variable at your own peril to change them. - -/* global log, state, globalconfig, getObj, sendChat, _, getAttrByName, findObjs, createObj, playerIsGM, on */ -const ChatSetAttr = (function () { - "use strict"; - const version = "1.10", - observers = { - "add": [], - "change": [], - "destroy": [] - }, - schemaVersion = 3, - replacers = [ - [//g, "]"], - [/\\rbrak/g, "]"], - [/;/g, "?"], - [/\\ques/g, "?"], - [/`/g, "@"], - [/\\at/g, "@"], - [/~/g, "-"], - [/\\n/g, "\n"], - ], - // Basic Setup - checkInstall = function () { - log(`-=> ChatSetAttr v${version} <=-`); - if (!state.ChatSetAttr || state.ChatSetAttr.version !== schemaVersion) { - log(` > Updating ChatSetAttr Schema to v${schemaVersion} <`); - state.ChatSetAttr = { - version: schemaVersion, - globalconfigCache: { - lastsaved: 0 - }, - playersCanModify: false, - playersCanEvaluate: false, - useWorkers: true - }; - } - checkGlobalConfig(); - }, - checkGlobalConfig = function () { - const s = state.ChatSetAttr, - g = globalconfig && globalconfig.chatsetattr; - if (g && g.lastsaved && g.lastsaved > s.globalconfigCache.lastsaved) { - log(" > Updating ChatSetAttr from Global Config < [" + - (new Date(g.lastsaved * 1000)) + "]"); - s.playersCanModify = "playersCanModify" === g["Players can modify all characters"]; - s.playersCanEvaluate = "playersCanEvaluate" === g["Players can use --evaluate"]; - s.useWorkers = "useWorkers" === g["Trigger sheet workers when setting attributes"]; - s.globalconfigCache = globalconfig.chatsetattr; - } - }, - // Utility functions - isDef = function (value) { - return value !== undefined; - }, - getWhisperPrefix = function (playerid) { - const player = getObj("player", playerid); - if (player && player.get("_displayname")) { - return "/w \"" + player.get("_displayname") + "\" "; - } else { - return "/w GM "; - } - }, - sendChatMessage = function (msg, from) { - if (from === undefined) from = "ChatSetAttr"; - sendChat(from, msg, null, { - noarchive: true - }); - }, - setAttribute = function (attr, value) { - if (state.ChatSetAttr.useWorkers) attr.setWithWorker(value); - else attr.set(value); - }, - handleErrors = function (whisper, errors) { - if (errors.length) { - const output = whisper + - "
" + - "

Errors

" + - `

${errors.join("
")}

` + - "
"; - sendChatMessage(output); - errors.splice(0, errors.length); - } - }, - showConfig = function (whisper) { - const optionsText = [{ - name: "playersCanModify", - command: "players-can-modify", - desc: "Determines if players can use --name and --charid to " + - "change attributes of characters they do not control." - }, { - name: "playersCanEvaluate", - command: "players-can-evaluate", - desc: "Determines if players can use the --evaluate option. " + - "Be careful in giving players access to this option, because " + - "it potentially gives players access to your full API sandbox." - }, { - name: "useWorkers", - command: "use-workers", - desc: "Determines if setting attributes should trigger sheet worker operations." - }].map(getConfigOptionText).join(""), - output = whisper + "
ChatSetAttr Configuration
" + - "

!setattr-config can be invoked in the following format:

!setattr-config --option
" + - "

Specifying an option toggles the current setting. There are currently two" + - " configuration options:

" + optionsText + "
"; - sendChatMessage(output); - }, - getConfigOptionText = function (o) { - const button = state.ChatSetAttr[o.name] ? - "ON" : - "OFF"; - return "
    " + - "
  • " + - "
    ${button}
    ` + - `${o.command}${htmlReplace("-")}` + - `${o.desc}
${o.name} is currently ${button}` + - `Toggle
`; - }, - getCharNameById = function (id) { - const character = getObj("character", id); - return (character) ? character.get("name") : ""; - }, - escapeRegExp = function (str) { - return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); - }, - htmlReplace = function (str) { - const entities = { - "<": "lt", - ">": "gt", - "'": "#39", - "*": "#42", - "@": "#64", - "{": "#123", - "|": "#124", - "}": "#125", - "[": "#91", - "]": "#93", - "_": "#95", - "\"": "quot" - }; - return String(str).split("").map(c => (entities[c]) ? ("&" + entities[c] + ";") : c).join(""); - }, - processInlinerolls = function (msg) { - if (msg.inlinerolls && msg.inlinerolls.length) { - return msg.inlinerolls.map(v => { - const ti = v.results.rolls.filter(v2 => v2.table) - .map(v2 => v2.results.map(v3 => v3.tableItem.name).join(", ")) - .join(", "); - return (ti.length && ti) || v.results.total || 0; - }) - .reduce((m, v, k) => m.replace(`$[[${k}]]`, v), msg.content); - } else { - return msg.content; - } - }, - notifyAboutDelay = function (whisper) { - const chatFunction = () => sendChatMessage(whisper + "Your command is taking a " + - "long time to execute. Please be patient, the process will finish eventually."); - return setTimeout(chatFunction, 8000); - }, - getCIKey = function (obj, name) { - const nameLower = name.toLowerCase(); - let result = false; - Object.entries(obj).forEach(([k, ]) => { - if (k.toLowerCase() === nameLower) { - result = k; - } - }); - return result; - }, - generateUUID = function () { - var a = 0, - b = []; - return function () { - var c = (new Date()).getTime() + 0, - d = c === a; - a = c; - for (var e = new Array(8), f = 7; 0 <= f; f--) { - e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(c % 64); - c = Math.floor(c / 64); - } - c = e.join(""); - if (d) { - for (f = 11; 0 <= f && 63 === b[f]; f--) { - b[f] = 0; - } - b[f]++; - } else { - for (f = 0; 12 > f; f++) { - b[f] = Math.floor(64 * Math.random()); - } - } - for (f = 0; 12 > f; f++) { - c += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(b[f]); - } - return c; - }; - }(), - generateRowID = function () { - return generateUUID().replace(/_/g, "Z"); - }, - // Setting attributes happens in a delayed recursive way to prevent the sandbox - // from overheating. - delayedGetAndSetAttributes = function (whisper, list, setting, errors, rData, opts) { - const timeNotification = notifyAboutDelay(whisper), - cList = [].concat(list), - feedback = [], - dWork = function (charid) { - const attrs = getCharAttributes(charid, setting, errors, rData, opts); - setCharAttributes(charid, setting, errors, feedback, attrs, opts); - if (cList.length) { - setTimeout(dWork, 50, cList.shift()); - } else { - clearTimeout(timeNotification); - if (!opts.mute) handleErrors(whisper, errors); - if (!opts.silent) sendFeedback(whisper, feedback, opts); - } - }; - dWork(cList.shift()); - }, - setCharAttributes = function (charid, setting, errors, feedback, attrs, opts) { - const charFeedback = {}; - Object.entries(attrs).forEach(([attrName, attr]) => { - let newValue; - charFeedback[attrName] = {}; - const fillInAttrs = setting[attrName].fillin, - settingValue = _.pick(setting[attrName], ["current", "max"]); - if (opts.reset) { - newValue = { - current: attr.get("max") - }; - } else { - newValue = (fillInAttrs) ? - _.mapObject(settingValue, v => fillInAttrValues(charid, v)) : Object.assign({}, settingValue); - } - if (opts.evaluate) { - try { - newValue = _.mapObject(newValue, function (v) { - const parsed = eval(v); - if (_.isString(parsed) || Number.isFinite(parsed) || _.isBoolean(parsed)) { - return parsed.toString(); - } else return v; - }); - } catch (err) { - errors.push("Something went wrong with --evaluate" + - ` for the character ${getCharNameById(charid)}.` + - ` You were warned. The error message was: ${err}.` + - ` Attribute ${attrName} left unchanged.`); - return; - } - } - if (opts.mod || opts.modb) { - Object.entries(newValue).forEach(([k, v]) => { - let moddedValue = parseFloat(v) + parseFloat(attr.get(k) || "0"); - if (!_.isNaN(moddedValue)) { - if (opts.modb && k === "current") { - const parsedMax = parseFloat(attr.get("max")); - moddedValue = Math.min(Math.max(moddedValue, 0), _.isNaN(parsedMax) ? Infinity : parsedMax); - } - newValue[k] = moddedValue; - } else { - delete newValue[k]; - const type = (k === "max") ? "maximum " : ""; - errors.push(`Attribute ${type}${attrName} is not number-valued for ` + - `character ${getCharNameById(charid)}. Attribute ${type}left unchanged.`); - } - }); - } - newValue = _.mapObject(newValue, v => String(v)); - charFeedback[attrName] = newValue; - const oldAttr = JSON.parse(JSON.stringify(attr)); - setAttribute(attr, newValue); - notifyObservers("change", attr, oldAttr); - }); - // Feedback - if (!opts.silent) { - if ("fb-content" in opts) { - const finalFeedback = Object.entries(setting).reduce((m, [attrName, value], k) => { - if (!charFeedback[attrName]) return m; - else return m.replace(`_NAME${k}_`, attrName) - .replace(`_TCUR${k}_`, () => htmlReplace(value.current || "")) - .replace(`_TMAX${k}_`, () => htmlReplace(value.max || "")) - .replace(`_CUR${k}_`, () => htmlReplace(charFeedback[attrName].current || attrs[attrName].get("current") || "")) - .replace(`_MAX${k}_`, () => htmlReplace(charFeedback[attrName].max || attrs[attrName].get("max") || "")); - }, String(opts["fb-content"]).replace("_CHARNAME_", getCharNameById(charid))) - .replace(/_(?:TCUR|TMAX|CUR|MAX|NAME)\d*_/g, ""); - feedback.push(finalFeedback); - } else { - const finalFeedback = Object.entries(charFeedback).map(([k, o]) => { - if ("max" in o && "current" in o) - return `${k} to ${htmlReplace(o.current) || "(empty)"} / ${htmlReplace(o.max) || "(empty)"}`; - else if ("current" in o) return `${k} to ${htmlReplace(o.current) || "(empty)"}`; - else if ("max" in o) return `${k} to ${htmlReplace(o.max) || "(empty)"} (max)`; - else return null; - }).filter(x => !!x).join(", ").replace(/\n/g, "
"); - if (finalFeedback.length) { - feedback.push(`Setting ${finalFeedback} for character ${getCharNameById(charid)}.`); - } else { - feedback.push(`Nothing to do for character ${getCharNameById(charid)}.`); - } - } - } - return; - }, - fillInAttrValues = function (charid, expression) { - let match = expression.match(/%(\S.*?)(?:_(max))?%/), - replacer; - while (match) { - replacer = getAttrByName(charid, match[1], match[2] || "current") || ""; - expression = expression.replace(/%(\S.*?)(?:_(max))?%/, replacer); - match = expression.match(/%(\S.*?)(?:_(max))?%/); - } - return expression; - }, - // Getting attributes for a specific character - getCharAttributes = function (charid, setting, errors, rData, opts) { - const standardAttrNames = Object.keys(setting).filter(x => !setting[x].repeating), - rSetting = _.omit(setting, standardAttrNames); - return Object.assign({}, - getCharStandardAttributes(charid, standardAttrNames, errors, opts), - getCharRepeatingAttributes(charid, rSetting, errors, rData, opts) - ); - }, - getCharStandardAttributes = function (charid, attrNames, errors, opts) { - const attrs = {}, - attrNamesUpper = attrNames.map(x => x.toUpperCase()); - if (attrNames.length === 0) return {}; - findObjs({ - _type: "attribute", - _characterid: charid - }).forEach(attr => { - const nameIndex = attrNamesUpper.indexOf(attr.get("name").toUpperCase()); - if (nameIndex !== -1) attrs[attrNames[nameIndex]] = attr; - }); - _.difference(attrNames, Object.keys(attrs)).forEach(attrName => { - if (!opts.nocreate && !opts.deletemode) { - attrs[attrName] = createObj("attribute", { - characterid: charid, - name: attrName - }); - notifyObservers("add", attrs[attrName]); - } else if (!opts.deletemode) { - errors.push(`Missing attribute ${attrName} not created for` + - ` character ${getCharNameById(charid)}.`); - } - }); - return attrs; - }, - getCharRepeatingAttributes = function (charid, setting, errors, rData, opts) { - const allRepAttrs = {}, - attrs = {}, - repRowIds = {}, - repOrders = {}; - if (rData.sections.size === 0) return {}; - rData.sections.forEach(prefix => allRepAttrs[prefix] = {}); - // Get attributes - findObjs({ - _type: "attribute", - _characterid: charid - }).forEach(o => { - const attrName = o.get("name"); - rData.sections.forEach((prefix, k) => { - if (attrName.search(rData.regExp[k]) === 0) { - allRepAttrs[prefix][attrName] = o; - } else if (attrName === "_reporder_" + prefix) { - repOrders[prefix] = o.get("current").split(","); - } - }); - }); - // Get list of repeating row ids by prefix from allRepAttrs - rData.sections.forEach((prefix, k) => { - repRowIds[prefix] = [...new Set(Object.keys(allRepAttrs[prefix]) - .map(n => n.match(rData.regExp[k])) - .filter(x => !!x) - .map(a => a[1]))]; - if (repOrders[prefix]) { - repRowIds[prefix] = _.chain(repOrders[prefix]) - .intersection(repRowIds[prefix]) - .union(repRowIds[prefix]) - .value(); - } - }); - const repRowIdsLo = _.mapObject(repRowIds, l => l.map(n => n.toLowerCase())); - rData.toCreate.forEach(prefix => repRowIds[prefix].push(generateRowID())); - Object.entries(setting).forEach(([attrName, value]) => { - const p = value.repeating; - let finalId; - if (isDef(p.rowNum) && isDef(repRowIds[p.splitName[0]][p.rowNum])) { - finalId = repRowIds[p.splitName[0]][p.rowNum]; - } else if (p.rowIdLo === "-create" && !opts.deletemode) { - finalId = repRowIds[p.splitName[0]][repRowIds[p.splitName[0]].length - 1]; - } else if (isDef(p.rowIdLo) && repRowIdsLo[p.splitName[0]].includes(p.rowIdLo)) { - finalId = repRowIds[p.splitName[0]][repRowIdsLo[p.splitName[0]].indexOf(p.rowIdLo)]; - } else if (isDef(p.rowNum)) { - errors.push(`Repeating row number ${p.rowNum} invalid for` + - ` character ${getCharNameById(charid)}` + - ` and repeating section ${p.splitName[0]}.`); - } else { - errors.push(`Repeating row id ${p.rowIdLo} invalid for` + - ` character ${getCharNameById(charid)}` + - ` and repeating section ${p.splitName[0]}.`); - } - if (finalId && p.rowMatch) { - const repRowUpper = (p.splitName[0] + "_" + finalId).toUpperCase(); - Object.entries(allRepAttrs[p.splitName[0]]).forEach(([name, attr]) => { - if (name.toUpperCase().indexOf(repRowUpper) === 0) { - attrs[name] = attr; - } - }); - } else if (finalId) { - const finalName = p.splitName[0] + "_" + finalId + "_" + p.splitName[1], - attrNameCased = getCIKey(allRepAttrs[p.splitName[0]], finalName); - if (attrNameCased) { - attrs[attrName] = allRepAttrs[p.splitName[0]][attrNameCased]; - } else if (!opts.nocreate && !opts.deletemode) { - attrs[attrName] = createObj("attribute", { - characterid: charid, - name: finalName - }); - notifyObservers("add", attrs[attrName]); - } else if (!opts.deletemode) { - errors.push(`Missing attribute ${finalName} not created` + - ` for character ${getCharNameById(charid)}.`); - } - } - }); - return attrs; - }, - // Deleting attributes - delayedDeleteAttributes = function (whisper, list, setting, errors, rData, opts) { - const timeNotification = notifyAboutDelay(whisper), - cList = [].concat(list), - feedback = {}, - dWork = function (charid) { - const attrs = getCharAttributes(charid, setting, errors, rData, opts); - feedback[charid] = []; - deleteCharAttributes(charid, attrs, feedback); - if (cList.length) { - setTimeout(dWork, 50, cList.shift()); - } else { - clearTimeout(timeNotification); - if (!opts.silent) sendDeleteFeedback(whisper, feedback, opts); - } - }; - dWork(cList.shift()); - }, - deleteCharAttributes = function (charid, attrs, feedback) { - Object.keys(attrs).forEach(name => { - attrs[name].remove(); - notifyObservers("destroy", attrs[name]); - feedback[charid].push(name); - }); - }, - // These functions parse the chat input. - parseOpts = function (content, hasValue) { - // Input: content - string of the form command --opts1 --opts2 value --opts3. - // values come separated by whitespace. - // hasValue - array of all options which come with a value - // Output: object containing key:true if key is not in hasValue. and containing - // key:value otherwise - return content.replace(//g, "") // delete added HTML line breaks - .replace(/\s+$/g, "") // delete trailing whitespace - .replace(/\s*{{((?:.|\n)*)\s+}}$/, " $1") // replace content wrapped in curly brackets - .replace(/\\([{}])/g, "$1") // add escaped brackets - .split(/\s+--/) - .slice(1) - .reduce((m, arg) => { - const kv = arg.split(/\s(.+)/); - if (hasValue.includes(kv[0])) { - m[kv[0]] = kv[1] || ""; - } else { - m[arg] = true; - } - return m; - }, {}); - }, - parseAttributes = function (args, opts, errors) { - // Input: args - array containing comma-separated list of strings, every one of which contains - // an expression of the form key|value or key|value|maxvalue - // replace - true if characters from the replacers array should be replaced - // Output: Object containing key|value for all expressions. - const globalRepeatingData = { - regExp: new Set(), - toCreate: new Set(), - sections: new Set(), - }, - setting = args.map(str => { - return str.split(/(\\?(?:#|\|))/g) - .reduce((m, s) => { - if ((s === "#" || s === "|")) m[m.length] = ""; - else if ((s === "\\#" || s === "\\|")) m[m.length - 1] += s.slice(-1); - else m[m.length - 1] += s; - return m; - }, [""]); - }) - .filter(v => !!v) - // Replace for --replace - .map(arr => { - return arr.map((str, k) => { - if (opts.replace && k > 0) return replacers.reduce((m, rep) => m.replace(rep[0], rep[1]), str); - else return str; - }); - }) - // parse out current/max value - .map(arr => { - const value = {}; - if (arr.length < 3 || arr[1] !== "") { - value.current = (arr[1] || "").replace(/^'((?:.|\n)*)'$/, "$1"); - } - if (arr.length > 2) { - value.max = arr[2].replace(/^'((?:.|\n)*)'$/, "$1"); - } - return [arr[0].trim(), value]; - }) - // Find out if we need to run %_% replacement - .map(([name, value]) => { - if ((value.current && value.current.search(/%(\S.*?)(?:_(max))?%/) !== -1) || - (value.max && value.max.search(/%(\S.*?)(?:_(max))?%/) !== -1)) value.fillin = true; - else value.fillin = false; - return [name, value]; - }) - // Do repeating section stuff - .map(([name, value]) => { - if (name.search(/^repeating_/) === 0) { - value.repeating = getRepeatingData(name, globalRepeatingData, opts, errors); - } else value.repeating = false; - return [name, value]; - }) - .filter(([, value]) => value.repeating !== null) - .reduce((p, c) => { - p[c[0]] = Object.assign(p[c[0]] || {}, c[1]); - return p; - }, {}); - globalRepeatingData.sections.forEach(s => { - globalRepeatingData.regExp.add(new RegExp(`^${escapeRegExp(s)}_(-[-A-Za-z0-9]+?|\\d+)_`, "i")); - }); - globalRepeatingData.regExp = [...globalRepeatingData.regExp]; - globalRepeatingData.toCreate = [...globalRepeatingData.toCreate]; - globalRepeatingData.sections = [...globalRepeatingData.sections]; - return [setting, globalRepeatingData]; - }, - getRepeatingData = function (name, globalData, opts, errors) { - const match = name.match(/_(\$\d+|-[-A-Za-z0-9]+|\d+)(_)?/); - let output = {}; - if (match && match[1][0] === "$" && match[2] === "_") { - output.rowNum = parseInt(match[1].slice(1)); - } else if (match && match[2] === "_") { - output.rowId = match[1]; - output.rowIdLo = match[1].toLowerCase(); - } else if (match && match[1][0] === "$" && opts.deletemode) { - output.rowNum = parseInt(match[1].slice(1)); - output.rowMatch = true; - } else if (match && opts.deletemode) { - output.rowId = match[1]; - output.rowIdLo = match[1].toLowerCase(); - output.rowMatch = true; - } else { - errors.push(`Could not understand repeating attribute name ${name}.`); - output = null; - } - if (output) { - output.splitName = name.split(match[0]); - globalData.sections.add(output.splitName[0]); - if (output.rowIdLo === "-create" && !opts.deletemode) { - globalData.toCreate.add(output.splitName[0]); - } - } - return output; - }, - // These functions are used to get a list of character ids from the input, - // and check for permissions. - checkPermissions = function (list, errors, playerid, isGM) { - return list.filter(id => { - const character = getObj("character", id); - if (character) { - const control = character.get("controlledby").split(/,/); - if (!(isGM || control.includes("all") || control.includes(playerid) || state.ChatSetAttr.playersCanModify)) { - errors.push(`Permission error for character ${character.get("name")}.`); - return false; - } else return true; - } else { - errors.push(`Invalid character id ${id}.`); - return false; - } - }); - }, - getIDsFromTokens = function (selected) { - return (selected || []).map(obj => getObj("graphic", obj._id)) - .filter(x => !!x) - .map(token => token.get("represents")) - .filter(id => getObj("character", id || "")); - }, - getIDsFromNames = function (charNames, errors) { - return charNames.split(/\s*,\s*/) - .map(name => { - const character = findObjs({ - _type: "character", - name: name - }, { - caseInsensitive: true - })[0]; - if (character) { - return character.id; - } else { - errors.push(`No character named ${name} found.`); - return null; - } - }) - .filter(x => !!x); - }, - sendFeedback = function (whisper, feedback, opts) { - const output = (opts["fb-public"] ? "" : whisper) + - "
" + - "

" + (("fb-header" in opts) ? opts["fb-header"] : "Setting attributes") + "

" + - "

" + (feedback.join("
") || "Nothing to do.") + "

"; - sendChatMessage(output, opts["fb-from"]); - }, - sendDeleteFeedback = function (whisper, feedback, opts) { - let output = (opts["fb-public"] ? "" : whisper) + - "
" + - "

" + (("fb-header" in opts) ? opts["fb-header"] : "Deleting attributes") + "

"; - output += Object.entries(feedback) - .filter(([, arr]) => arr.length) - .map(([charid, arr]) => `Deleting attribute(s) ${arr.join(", ")} for character ${getCharNameById(charid)}.`) - .join("
") || "Nothing to do."; - output += "

"; - sendChatMessage(output, opts["fb-from"]); - }, - handleCommand = (content, playerid, selected, pre) => { - // Parsing input - let charIDList = [], - errors = []; - const hasValue = ["charid", "name", "fb-header", "fb-content", "fb-from"], - optsArray = ["all", "allgm", "charid", "name", "allplayers", "sel", "deletemode", - "replace", "nocreate", "mod", "modb", "evaluate", "silent", "reset", "mute", - "fb-header", "fb-content", "fb-from", "fb-public" - ], - opts = parseOpts(content, hasValue), - isGM = playerid === "API" || playerIsGM(playerid), - whisper = getWhisperPrefix(playerid); - opts.mod = opts.mod || (pre === "mod"); - opts.modb = opts.modb || (pre === "modb"); - opts.reset = opts.reset || (pre === "reset"); - opts.silent = opts.silent || opts.mute; - opts.deletemode = (pre === "del"); - // Sanitise feedback - if ("fb-from" in opts) opts["fb-from"] = String(opts["fb-from"]); - // Parse desired attribute values - const [setting, rData] = parseAttributes(Object.keys(_.omit(opts, optsArray)), opts, errors); - // Fill in header info - if ("fb-header" in opts) { - opts["fb-header"] = Object.entries(setting).reduce((m, [n, v], k) => { - return m.replace(`_NAME${k}_`, n) - .replace(`_TCUR${k}_`, htmlReplace(v.current || "")) - .replace(`_TMAX${k}_`, htmlReplace(v.max || "")); - }, String(opts["fb-header"])).replace(/_(?:TCUR|TMAX|NAME)\d*_/g, ""); - } - if (opts.evaluate && !isGM && !state.ChatSetAttr.playersCanEvaluate) { - if (!opts.mute) handleErrors(whisper, ["The --evaluate option is only available to the GM."]); - return; - } - // Get list of character IDs - if (opts.all && isGM) { - charIDList = findObjs({ - _type: "character" - }).map(c => c.id); - } else if (opts.allgm && isGM) { - charIDList = findObjs({ - _type: "character" - }).filter(c => c.get("controlledby") === "") - .map(c => c.id); - } else if (opts.allplayers && isGM) { - charIDList = findObjs({ - _type: "character" - }).filter(c => c.get("controlledby") !== "") - .map(c => c.id); - } else { - if (opts.charid) charIDList.push(...opts.charid.split(/\s*,\s*/)); - if (opts.name) charIDList.push(...getIDsFromNames(opts.name, errors)); - if (opts.sel) charIDList.push(...getIDsFromTokens(selected)); - charIDList = checkPermissions([...new Set(charIDList)], errors, playerid, isGM); - } - if (charIDList.length === 0) { - errors.push("No target characters. You need to supply one of --all, --allgm, --sel," + - " --allplayers, --charid, or --name."); - } - if (Object.keys(setting).length === 0) { - errors.push("No attributes supplied."); - } - // Get attributes - if (!opts.mute) handleErrors(whisper, errors); - // Set or delete attributes - if (charIDList.length > 0 && Object.keys(setting).length > 0) { - if (opts.deletemode) { - delayedDeleteAttributes(whisper, charIDList, setting, errors, rData, opts); - } else { - delayedGetAndSetAttributes(whisper, charIDList, setting, errors, rData, opts); - } - } - }, - handleInlineCommand = (msg) => { - const command = msg.content.match(/!(set|mod|modb)attr .*?!!!/); - - if (command) { - const mode = command[1], - newMsgContent = command[0].slice(0, -3).replace(/{{[^}[\]]+\$\[\[(\d+)\]\].*?}}/g, (_, number) => { - return `$[[${number}]]`; - }); - const newMsg = { - content: newMsgContent, - inlinerolls: msg.inlinerolls, - }; - handleCommand( - processInlinerolls(newMsg), - msg.playerid, - msg.selected, - mode - ); - } - }, - // Main function, called after chat message input - handleInput = function (msg) { - if (msg.type !== "api") handleInlineCommand(msg); - else { - const mode = msg.content.match(/^!(reset|set|del|mod|modb)attr\b(?:-|\s|$)(config)?/); - - if (mode && mode[2]) { - if (playerIsGM(msg.playerid)) { - const whisper = getWhisperPrefix(msg.playerid), - opts = parseOpts(msg.content, []); - if (opts["players-can-modify"]) { - state.ChatSetAttr.playersCanModify = !state.ChatSetAttr.playersCanModify; - } - if (opts["players-can-evaluate"]) { - state.ChatSetAttr.playersCanEvaluate = !state.ChatSetAttr.playersCanEvaluate; - } - if (opts["use-workers"]) { - state.ChatSetAttr.useWorkers = !state.ChatSetAttr.useWorkers; - } - showConfig(whisper); - } - } else if (mode) { - handleCommand( - processInlinerolls(msg), - msg.playerid, - msg.selected, - mode[1] - ); - } - } - return; - }, - notifyObservers = function(event, obj, prev) { - observers[event].forEach(observer => observer(obj, prev)); - }, - registerObserver = function (event, observer) { - if(observer && _.isFunction(observer) && observers.hasOwnProperty(event)) { - observers[event].push(observer); - } else { - log("ChatSetAttr event registration unsuccessful. Please check the documentation."); - } - }, - registerEventHandlers = function () { - on("chat:message", handleInput); - }; - return { - checkInstall, - registerObserver, - registerEventHandlers - }; -}()); - -on("ready", function () { - "use strict"; - ChatSetAttr.checkInstall(); - ChatSetAttr.registerEventHandlers(); -}); \ No newline at end of file +// ChatSetAttr v2.0 by Jakob, GUD Team +var ChatSetAttr = (function (exports) { + 'use strict'; + + var name = "ChatSetAttr"; + var version = "2.0"; + var authors = [ + "Jakob", + "GUD Team" + ]; + var scriptJson = { + name: name, + version: version, + authors: authors}; + + // #region Get Attributes + async function getSingleAttribute(target, attributeName) { + const isMax = attributeName.endsWith("_max"); + const type = isMax ? "max" : "current"; + if (isMax) { + attributeName = attributeName.slice(0, -4); // remove '_max' + } + try { + const attribute = await libSmartAttributes.getAttribute(target, attributeName, type); + return attribute; + } + catch { + return undefined; + } + } + async function getAttributes(target, attributeNames) { + const attributes = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + else { + for (const name in attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + return attributes; + } + + // #region Style Helpers + function convertCamelToKebab(camel) { + return camel.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function s(styleObject = {}) { + let style = ""; + for (const [key, value] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value};`; + } + return style; + } + function h(tagName, attributes = {}, ...children) { + const attrs = Object.entries(attributes ?? {}) + .map(([key, value]) => ` ${key}="${value}"`) + .join(""); + // Deeply flatten arrays and filter out null/undefined values + const flattenedChildren = children.flat(10).filter(child => child != null); + const childrenContent = flattenedChildren.join(""); + return `<${tagName}${attrs}>${childrenContent}`; + } + + const COLOR_RED = { + "50": "#ffebeb", + "300": "#ff7474", + "500": "#ff2020"}; + const COLOR_GREEN = { + "500": "#00e626"}; + const COLOR_EMERALD = { + "50": "#e6fff5", + "300": "#4dffc7"}; + const COLOR_BLUE = { + "50": "#e6f0ff", + "100": "#b3d1ff", + "300": "#4d94ff", + "400": "#1a75ff", + "600": "#0052b4", + "800": "#002952", + "900": "#001421", + }; + const COLOR_STONE = { + "50": "#fafaf9", + "400": "#a8a29e", + "700": "#44403c", + "900": "#1c1917", + }; + const COLOR_WHITE = "#ffffff"; + const PADDING = { + XS: "2px", + SM: "4px", + MD: "8px"}; + const MARGIN = { + SM: "4px", + MD: "8px"}; + const BORDER_RADIUS = { + SM: "2px", + MD: "4px"}; + const FONT_SIZE = { + SM: "0.875rem", + MD: "1rem", + LG: "1.125rem"}; + const FONT_WEIGHT = { + MEDIUM: "500", + BOLD: "700"}; + const WRAPPER_STYLE = s({ + fontSize: FONT_SIZE.MD, + }); + const LI_STYLE = s({ + fontSize: FONT_SIZE.MD, + marginBottom: MARGIN.SM, + }); + s({ + fontSize: FONT_SIZE.LG, + fontWeight: FONT_WEIGHT.BOLD, + marginBottom: MARGIN.MD, + }); + const BUTTON_STYLE = s({ + padding: `${PADDING.SM} ${PADDING.MD}`, + borderRadius: BORDER_RADIUS.MD, + fontSize: FONT_SIZE.MD, + fontWeight: FONT_WEIGHT.MEDIUM, + color: COLOR_WHITE, + backgroundColor: COLOR_BLUE["600"], + border: "none", + textDecoration: "none", + }); + const PARAGRAPH_SPACING_STYLE = s({ + marginBottom: MARGIN.MD, + }); + + const DELAY_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_STONE["400"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + color: COLOR_STONE["900"], + backgroundColor: COLOR_STONE["50"], + }); + const DELAY_HEADER_STYLE = s({ + color: COLOR_STONE["700"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const DELAY_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + function createDelayMessage() { + return (h("div", { style: DELAY_WRAPPER_STYLE }, + h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), + h("div", { style: DELAY_BODY_STYLE }, "The operation is taking a long time to execute. This may be due to a large number of targets or attributes being processed. Please be patient as the operation completes."))); + } + + // #region Chat Styles + const CHAT_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_EMERALD["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_EMERALD["50"], + }); + const CHAT_HEADER_STYLE = s({ + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const CHAT_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + // #region Error Styles + const ERROR_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_RED["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_RED["50"], + }); + const ERROR_HEADER_STYLE = s({ + color: COLOR_RED["500"], + fontWeight: "bold", + fontSize: FONT_SIZE.LG, + }); + const ERROR_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + // #region Generic Message Creation Function + function createMessage(header, messages, styles) { + return (h("div", { style: styles.wrapper }, + h("h3", { style: styles.header }, header), + h("div", { style: styles.body }, messages.map(message => h("p", null, message))))); + } + // #region Chat Message Function + function createChatMessage(header, messages) { + return createMessage(header, messages, { + wrapper: CHAT_WRAPPER_STYLE, + header: CHAT_HEADER_STYLE, + body: CHAT_BODY_STYLE + }); + } + // #region Error Message Function + function createErrorMessage(header, errors) { + return createMessage(header, errors, { + wrapper: ERROR_WRAPPER_STYLE, + header: ERROR_HEADER_STYLE, + body: ERROR_BODY_STYLE + }); + } + + const NOTIFY_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_BLUE["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + color: COLOR_BLUE["800"], + backgroundColor: COLOR_BLUE["100"], + }); + const NOTIFY_HEADER_STYLE = s({ + color: COLOR_BLUE["900"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const NOTIFY_BODY_STYLE = s({ + fontSize: FONT_SIZE.MD, + }); + function createNotifyMessage(title, content) { + return (h("div", { style: NOTIFY_WRAPPER_STYLE }, + h("div", { style: NOTIFY_HEADER_STYLE }, title), + h("div", { style: NOTIFY_BODY_STYLE }, content))); + } + + function getPlayerName(playerID) { + const player = getObj("player", playerID); + return player?.get("_displayname") ?? "Unknown Player"; + } + function sendMessages(playerID, header, messages, from = "ChatSetAttr") { + const newMessage = createChatMessage(header, messages); + sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + } + function sendErrors(playerID, header, errors, from = "ChatSetAttr") { + if (errors.length === 0) + return; + const newMessage = createErrorMessage(header, errors); + sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + } + function sendDelayMessage(silent = false) { + if (silent) + return; + const delayMessage = createDelayMessage(); + sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); + } + function sendNotification(title, content, archive) { + const notifyMessage = createNotifyMessage(title, content); + sendChat("ChatSetAttr", "/w gm " + notifyMessage, undefined, { noarchive: archive }); + } + function sendWelcomeMessage() { + const welcomeMessage = ` +

Thank you for installing ChatSetAttr.

+

To get started, use the command !setattr-config to configure the script to your needs.

+

For detailed documentation and examples, please use the !setattr-help command or click the button below:

+

Create Journal Handout

`; + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); + } + + function createFeedbackMessage(characterName, feedback, startingValues, targetValues) { + let message = feedback?.content ?? ""; + // _NAMEJ_: will insert the attribute name. + // _TCURJ_: will insert what you are changing the current value to (or changing by, if you're using --mod or --modb). + // _TMAXJ_: will insert what you are changing the maximum value to (or changing by, if you're using --mod or --modb). + // _CHARNAME_: will insert the character name. + // _CURJ_: will insert the final current value of the attribute, for this character. + // _MAXJ_: will insert the final maximum value of the attribute, for this character. + const targetValueKeys = Object.keys(targetValues).filter(key => !key.endsWith("_max")); + message = message.replace("_CHARNAME_", characterName); + message = message.replace(/_(NAME|TCUR|TMAX|CUR|MAX)(\d+)_/g, (_, key, num) => { + const index = parseInt(num, 10); + const attributeName = targetValueKeys[index]; + if (!attributeName) + return ""; + const targetCurrent = startingValues[attributeName]; + const targetMax = startingValues[`${attributeName}_max`]; + const startingCurrent = targetValues[attributeName]; + const startingMax = targetValues[`${attributeName}_max`]; + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return `${targetCurrent}`; + case "TMAX": + return `${targetMax}`; + case "CUR": + return `${startingCurrent}`; + case "MAX": + return `${startingMax}`; + default: + return ""; + } + }); + return message; + } + + function cleanValue(value) { + return value.trim().replace(/^['"](.*)['"]$/g, "$1"); + } + function getCharName(targetID) { + const character = getObj("character", targetID); + if (character) { + return character.get("name"); + } + return `ID: ${targetID}`; + } + + const observers = {}; + function registerObserver(event, callback) { + if (!observers[event]) { + observers[event] = []; + } + observers[event].push(callback); + } + function notifyObservers(event, targetID, attributeName, newValue, oldValue) { + const callbacks = observers[event] || []; + callbacks.forEach(callback => { + callback(event, targetID, attributeName, newValue, oldValue); + }); + } + + // region Command Handlers + async function setattr(changes, target, referenced = [], noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const request = createRequestList(referenced, changes, false); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; // skip if no name provided + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Missing attribute ${name} not created for ${characterName}.`); + continue; + } + const event = undefinedAttributes.includes(name) ? "add" : "change"; + if (current !== undefined) { + result[name] = current; + notifyObservers(event, target, name, result[name], currentValues?.[name] ?? undefined); + } + if (max !== undefined) { + result[`${name}_max`] = max; + notifyObservers(event, target, `${name}_max`, result[`${name}_max`], currentValues?.[`${name}_max`] ?? undefined); + } + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function modattr(changes, target, referenced, noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const currentValues = await getCurrentValues(target, referenced, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name] ?? 0); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + notifyObservers("change", target, name, result[name], currentValues[name]); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + notifyObservers("change", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function modbattr(changes, target, referenced, noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name, current, max } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name]); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + notifyObservers("change", target, name, result[name], currentValues[name]); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + notifyObservers("change", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + const newMax = result[`${name}_max`] ?? currentValues[`${name}_max`]; + if (newMax !== undefined) { + const start = currentValues[name]; + result[name] = calculateBoundValue(result[name] ?? start, newMax); + } + let newMessage = `Modified attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function resetattr(changes, target, referenced, noCreate = false, feedback) { + const result = {}; + const errors = []; + const messages = []; + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + for (const change of changes) { + const { name } = change; + if (!name) + continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be reset.`); + continue; + } + const maxName = `${name}_max`; + if (currentValues[maxName] !== undefined) { + const maxAsNumber = Number(currentValues[maxName]); + if (isNaN(maxAsNumber)) { + errors.push(`Attribute '${maxName}' is not number-valued and so cannot be used to reset '${name}'.`); + continue; + } + result[name] = maxAsNumber; + } + else { + result[name] = 0; + } + notifyObservers("change", target, name, result[name], currentValues[name]); + let newMessage = `Reset attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors, + }; + } + async function delattr(changes, target, referenced, _, feedback) { + const result = {}; + const messages = []; + const currentValues = await getCurrentValues(target, referenced, changes); + const characterName = getCharName(target); + for (const change of changes) { + const { name } = change; + if (!name) + continue; + result[name] = undefined; + result[`${name}_max`] = undefined; + let newMessage = `Deleted attribute '${name}' on ${characterName}.`; + notifyObservers("destroy", target, name, result[name], currentValues[name]); + if (currentValues[`${name}_max`] !== undefined) { + notifyObservers("destroy", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messages.push(newMessage); + } + return { + result, + messages, + errors: [], + }; + } + const handlers = { + setattr, + modattr, + modbattr, + resetattr, + delattr, + }; + // #region Helper Functions + function createRequestList(referenced, changes, includeMax = true) { + const requestSet = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + requestSet.add(change.name); + if (includeMax) { + requestSet.add(`${change.name}_max`); + } + } + } + return Array.from(requestSet); + } + function extractUndefinedAttributes(attributes) { + const names = []; + for (const name in attributes) { + if (name.endsWith("_max")) + continue; + if (attributes[name] === undefined) { + names.push(name); + } + } + return names; + } + async function getCurrentValues(target, referenced, changes) { + const queriedAttributes = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + queriedAttributes.add(change.name); + if (change.max !== undefined) { + queriedAttributes.add(`${change.name}_max`); + } + } + } + const attributes = await getAttributes(target, Array.from(queriedAttributes)); + return attributes; + } + function calculateModifiedValue(baseValue, modification) { + const operator = getOperator(modification); + baseValue = Number(baseValue); + if (operator) { + modification = Number(String(modification).substring(1)); + } + else { + modification = Number(modification); + } + if (isNaN(baseValue)) + baseValue = 0; + if (isNaN(modification)) + modification = 0; + return applyCalculation(baseValue, modification, operator); + } + function getOperator(value) { + if (typeof value === "string") { + const match = value.match(/^([+\-*/])/); + if (match) { + return match[1]; + } + } + return; + } + function applyCalculation(baseValue, modification, operator = "+") { + modification = Number(modification); + switch (operator) { + case "+": + return baseValue + modification; + case "-": + return baseValue - modification; + case "*": + return baseValue * modification; + case "/": + return modification !== 0 ? baseValue / modification : baseValue; + default: + return baseValue + modification; + } + } + function calculateBoundValue(currentValue, maxValue) { + currentValue = Number(currentValue); + maxValue = Number(maxValue); + if (isNaN(currentValue)) + currentValue = 0; + if (isNaN(maxValue)) + return currentValue; + return Math.max(Math.min(currentValue, maxValue), 0); + } + + const CONFIG_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_BLUE["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_BLUE["50"], + }); + const CONFIG_HEADER_STYLE = s({ + color: COLOR_BLUE["400"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, + }); + const CONFIG_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, + }); + const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", + }); + const CONFIG_ROW_STYLE = s({ + marginBottom: PADDING.XS, + }); + const CONFIG_BUTTON_SHARED = { + color: COLOR_WHITE, + border: "none", + borderRadius: BORDER_RADIUS.SM, + fontSize: FONT_SIZE.SM, + padding: `${PADDING.XS} ${PADDING.SM}`, + textAlign: "center", + width: "100%", + }; + const CONFIG_BUTTON_STYLE_ON = s({ + backgroundColor: COLOR_GREEN["500"], + ...CONFIG_BUTTON_SHARED, + }); + const CONFIG_BUTTON_STYLE_OFF = s({ + backgroundColor: COLOR_RED["300"], + ...CONFIG_BUTTON_SHARED, + }); + const CONFIG_CLEAR_FIX_STYLE = s({ + clear: "both", + }); + function camelToKebabCase(str) { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + } + function createConfigMessage() { + const config = getConfig(); + const configEntries = Object.entries(config); + const relevantEntries = configEntries.filter(([key]) => key !== "version" && key !== "globalconfigCache" && key !== "flags"); + return (h("div", { style: CONFIG_WRAPPER_STYLE }, + h("div", { style: CONFIG_HEADER_STYLE }, "ChatSetAttr Configuration"), + h("div", { style: CONFIG_BODY_STYLE }, + h("table", { style: CONFIG_TABLE_STYLE }, relevantEntries.map(([key, value]) => (h("tr", { style: CONFIG_ROW_STYLE }, + h("td", null, + h("strong", null, + key, + ":")), + h("td", null, + h("a", { href: `!setattr-config --${camelToKebabCase(key)}`, style: value ? CONFIG_BUTTON_STYLE_ON : CONFIG_BUTTON_STYLE_OFF }, value ? "Enabled" : "Disabled")))))), + h("div", { style: CONFIG_CLEAR_FIX_STYLE })))); + } + + const SCHEMA_VERSION = "2.0"; + const DEFAULT_CONFIG = { + version: SCHEMA_VERSION, + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + flags: [] + }; + function getConfig() { + const stateConfig = state?.ChatSetAttr || {}; + return { + ...DEFAULT_CONFIG, + ...stateConfig, + }; + } + function setConfig(newConfig) { + const stateConfig = state.ChatSetAttr || {}; + state.ChatSetAttr = { + ...stateConfig, + ...newConfig, + globalconfigCache: { + lastsaved: Date.now() + } + }; + } + function hasFlag(flag) { + const config = getConfig(); + return config.flags.includes(flag); + } + function setFlag(flag) { + const config = getConfig(); + if (!hasFlag(flag)) { + config.flags.push(flag); + setConfig({ flags: config.flags }); + } + } + function checkConfigMessage(message) { + return message.startsWith("!setattr-config"); + } + const FLAG_MAP = { + "--players-can-modify": "playersCanModify", + "--players-can-evaluate": "playersCanEvaluate", + "--players-can-target-party": "playersCanTargetParty", + "--use-workers": "useWorkers", + }; + function handleConfigCommand(message) { + message = message.replace("!setattr-config", "").trim(); + const args = message.split(/\s+/); + const newConfig = {}; + for (const arg of args) { + const cleanArg = arg.toLowerCase(); + const flag = FLAG_MAP[cleanArg]; + if (flag !== undefined) { + newConfig[flag] = !getConfig()[flag]; + log(`Toggled config option: ${flag} to ${newConfig[flag]}`); + } + } + setConfig(newConfig); + const configMessage = createConfigMessage(); + sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); + } + + function createHelpHandout(handoutID) { + const contents = [ + "Basic Usage", + "Available Commands", + "Target Selection", + "Attribute Syntax", + "Modifier Options", + "Output Control Options", + "Inline Roll Integration", + "Repeating Section Support", + "Special Value Expressions", + "Global Configuration", + "Complete Examples", + "For Developers", + ]; + function createTableOfContents() { + return (h("ol", null, contents.map(section => (h("li", { key: section }, + h("a", { href: `http://journal.roll20.net/handout/${handoutID}/#${section.replace(/\s+/g, "%20")}` }, section)))))); + } + return (h("div", null, + h("h1", null, "ChatSetAttr"), + h("p", null, "ChatSetAttr is a Roll20 API script that allows users to create, modify, or delete character sheet attributes through chat commands macros. Whether you need to update a single character attribute or make bulk changes across multiple characters, ChatSetAttr provides flexible options to streamline your game management."), + h("h2", null, "Table of Contents"), + createTableOfContents(), + h("h2", { id: "basic-usage" }, "Basic Usage"), + h("p", null, "The script provides several command formats:"), + h("ul", null, + h("li", null, + h("code", null, "!setattr [--options]"), + " - Create or modify attributes"), + h("li", null, + h("code", null, "!modattr [--options]"), + " - Shortcut for ", + h("code", null, "!setattr --mod"), + " (adds to existing values)"), + h("li", null, + h("code", null, "!modbattr [--options]"), + " - Shortcut for ", + h("code", null, "!setattr --modb"), + " (adds to values with bounds)"), + h("li", null, + h("code", null, "!resetattr [--options]"), + " - Shortcut for ", + h("code", null, "!setattr --reset"), + " (resets to max values)"), + h("li", null, + h("code", null, "!delattr [--options]"), + " - Delete attributes")), + h("p", null, "Each command requires a target selection option and one or more attributes to modify."), + h("p", null, + h("strong", null, "Basic structure:")), + h("pre", null, + h("code", null, "!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2")), + h("h2", { id: "available-commands" }, "Available Commands"), + h("h3", null, "!setattr"), + h("p", null, + "Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless ", + h("code", null, "--nocreate"), + " is specified)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --hp|25|50 --xp|0|800")), + h("p", null, + "This would set ", + h("code", null, "hp"), + " to 25, ", + h("code", null, "hp_max"), + " to 50, ", + h("code", null, "xp"), + " to 0 and ", + h("code", null, "xp_max"), + " to 800."), + h("h3", null, "!modattr"), + h("p", null, + "Adds to existing attribute values (works only with numeric values). Shorthand for ", + h("code", null, "!setattr --mod"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!modattr --sel --hp|-5 --xp|100")), + h("p", null, + "This subtracts 5 from ", + h("code", null, "hp"), + " and adds 100 to ", + h("code", null, "xp"), + "."), + h("h3", null, "!modbattr"), + h("p", null, + "Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for ", + h("code", null, "!setattr --modb"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!modbattr --sel --hp|-25 --xp|2500")), + h("p", null, + "This subtracts 5 from ", + h("code", null, "hp"), + " but won't reduce it below 0 and increase ", + h("code", null, "xp"), + " by 25, but won't increase it above ", + h("code", null, "mp_xp"), + "."), + h("h3", null, "!resetattr"), + h("p", null, + "Resets attributes to their maximum value. Shorthand for ", + h("code", null, "!setattr --reset"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!resetattr --sel --hp --xp")), + h("p", null, + "This resets ", + h("code", null, "hp"), + ", and ", + h("code", null, "xp"), + " to their respective maximum values."), + h("h3", null, "!delattr"), + h("p", null, "Deletes the specified attributes."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!delattr --sel --hp --xp")), + h("p", null, + "This removes the ", + h("code", null, "hp"), + " and ", + h("code", null, "xp"), + " attributes."), + h("h2", { id: "target-selection" }, "Target Selection"), + h("p", null, "One of these options must be specified to determine which characters will be affected:"), + h("h3", null, "--all"), + h("p", null, + "Affects all characters in the campaign. ", + h("strong", null, "GM only"), + " and should be used with caution, especially in large campaigns."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --all --hp|15")), + h("h3", null, "--allgm"), + h("p", null, + "Affects all characters without player controllers (typically NPCs). ", + h("strong", null, "GM only"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --allgm --xp|150")), + h("h3", null, "--allplayers"), + h("p", null, "Affects all characters with player controllers (typically PCs)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --allplayers --hp|15")), + h("h3", null, "--charid"), + h("p", null, "Affects characters with the specified character IDs. Non-GM players can only affect characters they control."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --charid --xp|150")), + h("h3", null, "--name"), + h("p", null, "Affects characters with the specified names. Non-GM players can only affect characters they control."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"")), + h("h3", null, "--sel"), + h("p", null, "Affects characters represented by currently selected tokens."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --hp|25 --xp|30")), + h("h3", null, "--sel-party"), + h("p", null, + "Affects only party characters represented by currently selected tokens (characters with ", + h("code", null, "inParty"), + " set to true)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel-party --inspiration|1")), + h("h3", null, "--sel-noparty"), + h("p", null, + "Affects only non-party characters represented by currently selected tokens (characters with ", + h("code", null, "inParty"), + " set to false or not set)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel-noparty --npc_status|\"Hostile\"")), + h("h3", null, "--party"), + h("p", null, + "Affects all characters marked as party members (characters with ", + h("code", null, "inParty"), + " set to true). ", + h("strong", null, "GM only by default"), + ", but can be enabled for players with configuration."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --party --rest_complete|1")), + h("h2", { id: "attribute-syntax" }, "Attribute Syntax"), + h("p", null, "The syntax for specifying attributes is:"), + h("pre", null, + h("code", null, "--attributeName|currentValue|maxValue")), + h("ul", null, + h("li", null, + h("code", null, "attributeName"), + " is the name of the attribute to modify"), + h("li", null, + h("code", null, "currentValue"), + " is the value to set (optional for some commands)"), + h("li", null, + h("code", null, "maxValue"), + " is the maximum value to set (optional)")), + h("h3", null, "Examples:"), + h("ol", null, + h("li", null, + "Set current value only:", + h("pre", null, + h("code", null, "--strength|15"))), + h("li", null, + "Set both current and maximum values:", + h("pre", null, + h("code", null, "--hp|27|35"))), + h("li", null, + "Set only the maximum value (leave current unchanged):", + h("pre", null, + h("code", null, "--hp||50"))), + h("li", null, + "Create empty attribute or set to empty:", + h("pre", null, + h("code", null, "--notes|"))), + h("li", null, + "Use ", + h("code", null, "#"), + " instead of ", + h("code", null, "|"), + " (useful in roll queries):", + h("pre", null, + h("code", null, "--strength#15")))), + h("h2", { id: "modifier-options" }, "Modifier Options"), + h("p", null, "These options change how attributes are processed:"), + h("h3", null, "--mod"), + h("p", null, + "See ", + h("code", null, "!modattr"), + " command."), + h("h3", null, "--modb"), + h("p", null, + "See ", + h("code", null, "!modbattr"), + " command."), + h("h3", null, "--reset"), + h("p", null, + "See ", + h("code", null, "!resetattr"), + " command."), + h("h3", null, "--nocreate"), + h("p", null, "Prevents creation of new attributes, only updates existing ones."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --nocreate --perception|20 --xp|15")), + h("p", null, + "This will only update ", + h("code", null, "perception"), + " or ", + h("code", null, "xp"), + " if it already exists."), + h("h3", null, "--evaluate"), + h("p", null, + "Evaluates JavaScript expressions in attribute values. ", + h("strong", null, "GM only by default"), + "."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --evaluate --hp|2 * 3")), + h("p", null, + "This will set the ", + h("code", null, "hp"), + " attribute to 6."), + h("h3", null, "--replace"), + h("p", null, "Replaces special characters to prevent Roll20 from evaluating them:"), + h("ul", null, + h("li", null, "< becomes ["), + h("li", null, "> becomes ]"), + h("li", null, "~ becomes -"), + h("li", null, "; becomes ?"), + h("li", null, "` becomes @")), + h("p", null, "Also supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"")), + h("p", null, "This stores \"Roll [[1d6]] to succeed\" without evaluating the roll."), + h("h2", { id: "output-control-options" }, "Output Control Options"), + h("p", null, "These options control the feedback messages generated by the script:"), + h("h3", null, "--silent"), + h("p", null, "Suppresses normal output messages (error messages will still appear)."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --silent --stealth|20")), + h("h3", null, "--mute"), + h("p", null, "Suppresses all output messages, including errors."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --mute --nocreate --new_value|42")), + h("h3", null, "--fb-public"), + h("p", null, "Sends output publicly to the chat instead of whispering to the command sender."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"")), + h("h3", null, "--fb-from "), + h("p", null, "Changes the name of the sender for output messages (default is \"ChatSetAttr\")."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-from \"Healing Potion\" --hp|25")), + h("h3", null, "--fb-header "), + h("p", null, "Customizes the header of the output message."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5")), + h("h3", null, "--fb-content "), + h("p", null, "Customizes the content of the output message."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10")), + h("h3", null, "Special Placeholders"), + h("p", null, + "For use in ", + h("code", null, "--fb-header"), + " and ", + h("code", null, "--fb-content"), + ":"), + h("ul", null, + h("li", null, + h("code", null, "_NAMEJ_"), + " - Name of the Jth attribute being changed"), + h("li", null, + h("code", null, "_TCURJ_"), + " - Target current value of the Jth attribute"), + h("li", null, + h("code", null, "_TMAXJ_"), + " - Target maximum value of the Jth attribute")), + h("p", null, + "For use in ", + h("code", null, "--fb-content"), + " only:"), + h("ul", null, + h("li", null, + h("code", null, "_CHARNAME_"), + " - Name of the character"), + h("li", null, + h("code", null, "_CURJ_"), + " - Final current value of the Jth attribute"), + h("li", null, + h("code", null, "_MAXJ_"), + " - Final maximum value of the Jth attribute")), + h("p", null, + h("strong", null, "Important:"), + " The Jth index starts with 0 at the first item."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10")), + h("h2", { id: "inline-roll-integration" }, "Inline Roll Integration"), + h("p", null, "ChatSetAttr can be used within roll templates or combined with inline rolls:"), + h("h3", null, "Within Roll Templates"), + h("p", null, + "Place the command between roll template properties and end it with ", + h("code", null, "!!!"), + ":"), + h("pre", null, + h("code", null, "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}")), + h("h3", null, "Using Inline Rolls in Values"), + h("p", null, "Inline rolls can be used for attribute values:"), + h("pre", null, + h("code", null, "!setattr --sel --hp|[[2d6+5]]")), + h("h3", null, "Roll Queries"), + h("p", null, "Roll queries can determine attribute values:"), + h("pre", null, + h("code", null, "!setattr --sel --hp|?{Set strength to what value?|100}")), + h("h2", { id: "repeating-section-support" }, "Repeating Section Support"), + h("p", null, "ChatSetAttr supports working with repeating sections:"), + h("h3", null, "Creating New Repeating Items"), + h("p", null, + "Use ", + h("code", null, "-CREATE"), + " to create a new row in a repeating section:"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_-CREATE_itemname|\"Magic Sword\" --repeating_inventory_-CREATE_itemweight|2")), + h("h3", null, "Modifying Existing Repeating Items"), + h("p", null, "Access by row ID:"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_-ID_itemname|\"Enchanted Magic Sword\"")), + h("p", null, "Access by index (starts at 0):"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"")), + h("h3", null, "Deleting Repeating Rows"), + h("p", null, "Delete by row ID:"), + h("pre", null, + h("code", null, "!delattr --sel --repeating_inventory_-ID")), + h("p", null, "Delete by index:"), + h("pre", null, + h("code", null, "!delattr --sel --repeating_inventory_$0")), + h("h2", { id: "special-value-expressions" }, "Special Value Expressions"), + h("h3", null, "Attribute References"), + h("p", null, + "Reference other attribute values using ", + h("code", null, "%attribute_name%"), + ":"), + h("pre", null, + h("code", null, "!setattr --sel --evaluate --temp_hp|%hp% / 2")), + h("h3", null, "Resetting to Maximum"), + h("p", null, "Reset an attribute to its maximum value:"), + h("pre", null, + h("code", null, "!setattr --sel --hp|%hp_max%")), + h("h2", { id: "global-configuration" }, "Global Configuration"), + h("p", null, + "The script has four global configuration options that can be toggled with ", + h("code", null, "!setattr-config"), + ":"), + h("h3", null, "--players-can-modify"), + h("p", null, "Allows players to modify attributes on characters they don't control."), + h("pre", null, + h("code", null, "!setattr-config --players-can-modify")), + h("h3", null, "--players-can-evaluate"), + h("p", null, + "Allows players to use the ", + h("code", null, "--evaluate"), + " option."), + h("pre", null, + h("code", null, "!setattr-config --players-can-evaluate")), + h("h3", null, "--players-can-target-party"), + h("p", null, + "Allows players to use the ", + h("code", null, "--party"), + " target option. ", + h("strong", null, "GM only by default"), + "."), + h("pre", null, + h("code", null, "!setattr-config --players-can-target-party")), + h("h3", null, "--use-workers"), + h("p", null, "Toggles whether the script triggers sheet workers when setting attributes."), + h("pre", null, + h("code", null, "!setattr-config --use-workers")), + h("h2", { id: "complete-examples" }, "Complete Examples"), + h("h3", null, "Basic Combat Example"), + h("p", null, "Reduce a character's HP and status after taking damage:"), + h("pre", null, + h("code", null, "!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"")), + h("h3", null, "Leveling Up a Character"), + h("p", null, "Update multiple stats when a character gains a level:"), + h("pre", null, + h("code", null, "!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public")), + h("h3", null, "Create New Item in Inventory"), + h("p", null, "Add a new item to a character's inventory:"), + h("pre", null, + h("code", null, "!setattr --sel --repeating_inventory_-CREATE_itemname|\"Healing Potion\" --repeating_inventory_-CREATE_itemcount|3 --repeating_inventory_-CREATE_itemweight|0.5 --repeating_inventory_-CREATE_itemcontent|\"Restores 2d8+2 hit points when consumed\"")), + h("h3", null, "Apply Status Effects During Combat"), + h("p", null, "Apply a debuff to selected enemies in the middle of combat:"), + h("pre", null, + h("code", null, "&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}")), + h("h3", null, "Party Management Examples"), + h("p", null, "Give inspiration to all party members after a great roleplay moment:"), + h("pre", null, + h("code", null, "!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"")), + h("p", null, "Apply a long rest to only party characters among selected tokens:"), + h("pre", null, + h("code", null, "!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"")), + h("p", null, "Set hostile status for non-party characters among selected tokens:"), + h("pre", null, + h("code", null, "!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"")), + h("h2", { id: "for-developers" }, "For Developers"), + h("h3", null, "Registering Observers"), + h("p", null, "If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:"), + h("pre", null, + h("code", null, "ChatSetAttr.registerObserver(event, observer);")), + h("p", null, + "Where ", + h("code", null, "event"), + " is one of:"), + h("ul", null, + h("li", null, + h("code", null, "\"add\""), + " - Called when attributes are created"), + h("li", null, + h("code", null, "\"change\""), + " - Called when attributes are modified"), + h("li", null, + h("code", null, "\"destroy\""), + " - Called when attributes are deleted")), + h("p", null, + "And ", + h("code", null, "observer"), + " is an event handler function similar to Roll20's built-in event handlers."), + h("p", null, "This allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface."))); + } + + function checkHelpMessage(msg) { + return msg.trim().toLowerCase().startsWith("!setattrs-help"); + } + function handleHelpCommand() { + let handout = findObjs({ + _type: "handout", + name: "ChatSetAttr Help", + })[0]; + if (!handout) { + handout = createObj("handout", { + name: "ChatSetAttr Help", + }); + } + const helpContent = createHelpHandout(handout.id); + handout.set({ + "inplayerjournals": "all", + "notes": helpContent, + }); + } + + // #region Commands + const COMMAND_TYPE = [ + "setattr", + "modattr", + "modbattr", + "resetattr", + "delattr" + ]; + function isCommand(command) { + return COMMAND_TYPE.includes(command); + } + // #region Command Options + const COMMAND_OPTIONS = [ + "mod", + "modb", + "reset" + ]; + const OVERRIDE_DICTIONARY = { + "mod": "modattr", + "modb": "modbattr", + "reset": "resetattr", + }; + function isCommandOption(option) { + return COMMAND_OPTIONS.includes(option); + } + // #region Targets + const TARGETS = [ + "all", + "allgm", + "allplayers", + "charid", + "name", + "sel", + "sel-noparty", + "sel-party", + "party", + ]; + // #region Feedback + const FEEDBACK_OPTIONS = [ + "fb-public", + "fb-from", + "fb-header", + "fb-content", + ]; + function isFeedbackOption(option) { + for (const fbOption of FEEDBACK_OPTIONS) { + if (option.startsWith(fbOption)) + return true; + } + return false; + } + function extractFeedbackKey(option) { + if (option === "fb-public") + return "public"; + if (option === "fb-from") + return "from"; + if (option === "fb-header") + return "header"; + if (option === "fb-content") + return "content"; + return false; + } + // #region Options + const OPTIONS = [ + "nocreate", + "evaluate", + "replace", + "silent", + "mute", + ]; + function isOption(option) { + return OPTIONS.includes(option); + } + // #region Alias Characters + const ALIAS_CHARACTERS = { + "<": "[", + ">": "]", + "~": "-", + ";": "?", + "`": "@", + }; + + // #region Inline Message Extraction and Validation + function validateMessage(content) { + for (const command of COMMAND_TYPE) { + const messageCommand = content.split(" ")[0]; + if (messageCommand === `!${command}`) { + return true; + } + } + return false; + } + function extractMessageFromRollTemplate(msg) { + for (const command of COMMAND_TYPE) { + if (msg.content.includes(command)) { + const regex = new RegExp(`(!${command}.*?)!!!`, "gi"); + const match = regex.exec(msg.content); + if (match) + return match[1].trim(); + } + } + return false; + } + // #region Message Parsing + function extractOperation(parts) { + if (parts.length === 0) + throw new Error("Empty command"); + const command = parts.shift().slice(1); // remove the leading '!' + const isValidCommand = isCommand(command); + if (!isValidCommand) + throw new Error(`Invalid command: ${command}`); + return command; + } + function extractReferences(value) { + if (typeof value !== "string") + return []; + const matches = value.matchAll(/%[a-zA-Z0-9_]+%/g); + return Array.from(matches, m => m[0]); + } + function splitMessage(content) { + const split = content.split("--").map(part => part.trim()); + return split; + } + function includesATarget(part) { + if (part.includes("|") || part.includes("#")) + return false; + [part] = part.split(" ").map(p => p.trim()); + for (const target of TARGETS) { + const isMatch = part.toLowerCase() === target.toLowerCase(); + if (isMatch) + return true; + } + return false; + } + function parseMessage(content) { + const parts = splitMessage(content); + let operation = extractOperation(parts); + const targeting = []; + const options = {}; + const changes = []; + const references = []; + const feedback = { public: false }; + for (const part of parts) { + if (isCommandOption(part)) { + operation = OVERRIDE_DICTIONARY[part]; + } + else if (isOption(part)) { + options[part] = true; + } + else if (includesATarget(part)) { + targeting.push(part); + } + else if (isFeedbackOption(part)) { + const [key, ...valueParts] = part.split(" "); + const value = valueParts.join(" "); + const feedbackKey = extractFeedbackKey(key); + if (!feedbackKey) + continue; + if (feedbackKey === "public") { + feedback.public = true; + } + else { + feedback[feedbackKey] = cleanValue(value); + } + } + else if (part.includes("|") || part.includes("#")) { + const split = part.split(/[|#]/g).map(p => p.trim()); + const [attrName, attrCurrent, attrMax] = split; + if (!attrName && !attrCurrent && !attrMax) { + continue; + } + const attribute = {}; + if (attrName) + attribute.name = attrName; + if (attrCurrent) + attribute.current = cleanValue(attrCurrent); + if (attrMax) + attribute.max = cleanValue(attrMax); + changes.push(attribute); + const currentMatches = extractReferences(attrCurrent); + const maxMatches = extractReferences(attrMax); + references.push(...currentMatches, ...maxMatches); + } + else { + const suspectedAttribute = part.replace(/[^a-zA-Z0-9_$]/g, ""); + if (!suspectedAttribute) + continue; + changes.push({ name: suspectedAttribute }); + } + } + return { + operation, + options, + targeting, + changes, + references, + feedback, + }; + } + + function extractRepeatingParts(attributeName) { + const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); + if (repeating !== "repeating") { + return null; + } + const field = fieldParts.join("_"); + if (!section || !identifier || !field) { + return null; + } + return { + section, + identifier, + field + }; + } + function isRepeatingAttribute(attributeName) { + const parts = extractRepeatingParts(attributeName); + return parts !== null; + } + function hasCreateIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (parts) { + const hasIndentifier = parts.identifier.toLowerCase().includes("create"); + return hasIndentifier; + } + const hasIndentifier = attributeName.toLowerCase().includes("create"); + return hasIndentifier; + } + function convertRepOrderToArray(repOrder) { + return repOrder.split(",").map(id => id.trim()); + } + async function getRepOrderForSection(characterID, section) { + const repOrderAttribute = `_reporder_repeating_${section}`; + const repOrder = await libSmartAttributes.getAttribute(characterID, repOrderAttribute); + return repOrder; + } + function extractRepeatingAttributes(attributes) { + return attributes.filter(attr => attr.name && isRepeatingAttribute(attr.name)); + } + function getAllSectionNames(attributes) { + const sectionNames = new Set(); + const repeatingAttributes = extractRepeatingAttributes(attributes); + for (const attr of repeatingAttributes) { + if (!attr.name) + continue; + const parts = extractRepeatingParts(attr.name); + if (!parts) + continue; + sectionNames.add(parts.section); + } + return Array.from(sectionNames); + } + async function getAllRepOrders(characterID, sectionNames) { + const repOrders = {}; + for (const section of sectionNames) { + const repOrderString = await getRepOrderForSection(characterID, section); + if (repOrderString && typeof repOrderString === "string") { + repOrders[section] = convertRepOrderToArray(repOrderString); + } + else { + repOrders[section] = []; + } + } + return repOrders; + } + + function processModifierValue(modification, resolvedAttributes, { shouldEvaluate = false, shouldAlias = false } = {}) { + let finalValue = replacePlaceholders(modification, resolvedAttributes); + if (shouldAlias) { + finalValue = replaceAliasCharacters(finalValue); + } + if (shouldEvaluate) { + finalValue = evaluateExpression(finalValue); + } + return finalValue; + } + function replaceAliasCharacters(modification) { + let result = modification; + for (const alias in ALIAS_CHARACTERS) { + const original = ALIAS_CHARACTERS[alias]; + const regex = new RegExp(`\\${alias}`, "g"); + result = result.replace(regex, original); + } + return result; + } + function replacePlaceholders(value, attributes) { + if (typeof value !== "string") + return value; + return value.replace(/%([a-zA-Z0-9_]+)%/g, (match, name) => { + const replacement = attributes[name]; + return replacement !== undefined ? String(replacement) : match; + }); + } + function evaluateExpression(expression) { + try { + const stringValue = String(expression); + const result = eval(stringValue); + return result; + } + catch { + return expression; + } + } + function processModifierName(name, { repeatingID, repOrder }) { + let result = name; + const hasCreate = result.includes("CREATE"); + if (hasCreate && repeatingID) { + result = result.replace("CREATE", repeatingID); + } + const rowIndexMatch = result.match(/\$(\d+)/); + if (rowIndexMatch && repOrder) { + const rowIndex = parseInt(rowIndexMatch[1], 10); + const rowID = repOrder[rowIndex]; + if (!rowID) + return result; + result = result.replace(`$${rowIndex}`, rowID); + } + return result; + } + function processModifications(modifications, resolved, options, repOrders) { + const processedModifications = []; + const repeatingID = libUUID.generateRowID(); + for (const mod of modifications) { + if (!mod.name) + continue; + let processedName = mod.name; + const parts = extractRepeatingParts(mod.name); + if (parts) { + const hasCreate = hasCreateIdentifier(parts.identifier); + const repOrder = repOrders[parts.section] || []; + processedName = processModifierName(mod.name, { + repeatingID: hasCreate ? repeatingID : parts.identifier, + repOrder, + }); + } + let processedCurrent = undefined; + if (mod.current !== "undefined") { + processedCurrent = String(mod.current); + processedCurrent = processModifierValue(processedCurrent, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + let processedMax = undefined; + if (mod.max !== undefined) { + processedMax = String(mod.max); + processedMax = processModifierValue(processedMax, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + const processedMod = { + name: processedName, + }; + if (processedCurrent !== undefined) { + processedMod.current = processedCurrent; + } + if (processedMax !== undefined) { + processedMod.max = processedMax; + } + processedModifications.push(processedMod); + } + return processedModifications; + } + + const permissions = { + playerID: "", + isGM: false, + canModify: false, + }; + function checkPermissions(playerID) { + const player = getObj("player", playerID); + if (!player) { + throw new Error(`Player with ID ${playerID} not found.`); + } + const isGM = playerIsGM(playerID); + const config = state.ChatSetAttr?.config || {}; + const playersCanModify = config.playersCanModify || false; + const canModify = isGM || playersCanModify; + setPermissions(playerID, isGM, canModify); + } + function setPermissions(playerID, isGM, canModify) { + permissions.playerID = playerID; + permissions.isGM = isGM; + permissions.canModify = canModify; + } + function getPermissions() { + return { ...permissions }; + } + function checkPermissionForTarget(playerID, target) { + const player = getObj("player", playerID); + if (!player) { + return false; + } + const isGM = playerIsGM(playerID); + if (isGM) { + return true; + } + const character = getObj("character", target); + if (!character) { + return false; + } + const controlledBy = (character.get("controlledby") || "").split(","); + return controlledBy.includes(playerID); + } + + function generateSelectedTargets(message, type) { + const errors = []; + const targets = []; + if (!message.selected) + return { targets, errors }; + for (const token of message.selected) { + const tokenObj = getObj("graphic", token._id); + if (!tokenObj) { + errors.push(`Selected token with ID ${token._id} not found.`); + continue; + } + if (tokenObj.get("_subtype") !== "token") { + errors.push(`Selected object with ID ${token._id} is not a token.`); + continue; + } + const represents = tokenObj.get("represents"); + const character = getObj("character", represents); + if (!character) { + errors.push(`Token with ID ${token._id} does not represent a character.`); + continue; + } + const inParty = character.get("inParty"); + if (type === "sel-noparty" && inParty) { + continue; + } + if (type === "sel-party" && !inParty) { + continue; + } + targets.push(character.id); + } + return { + targets, + errors, + }; + } + function generateAllTargets(type) { + const { isGM } = getPermissions(); + const errors = []; + if (!isGM) { + errors.push(`Only GMs can use the '${type}' target option.`); + return { + targets: [], + errors, + }; + } + const characters = findObjs({ _type: "character" }); + if (type === "all") { + return { + targets: characters.map(char => char.id), + errors, + }; + } + else if (type === "allgm") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + else if (type === "allplayers") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !!controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + return { + targets: [], + errors: [`Unknown target type '${type}'.`], + }; + } + function generateCharacterIDTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const charID of values) { + const character = getObj("character", charID); + if (!character) { + errors.push(`Character with ID ${charID} not found.`); + continue; + } + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with ID ${charID}.`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generatePartyTargets() { + const { isGM } = getPermissions(); + const { playersCanTargetParty } = getConfig(); + const targets = []; + const errors = []; + if (!isGM && !playersCanTargetParty) { + errors.push("Only GMs can use the 'party' target option."); + return { + targets, + errors, + }; + } + const characters = findObjs({ _type: "character", inParty: true }); + for (const character of characters) { + const characterID = character.id; + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generateNameTargets(values) { + const { playerID } = getPermissions(); + const targets = []; + const errors = []; + for (const name of values) { + const characters = findObjs({ _type: "character", name: name }); + if (characters.length === 0) { + errors.push(`Character with name "${name}" not found.`); + continue; + } + if (characters.length > 1) { + errors.push(`Multiple characters found with name "${name}". Please use character ID instead.`); + continue; + } + const character = characters[0]; + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with name "${name}".`); + continue; + } + targets.push(characterID); + } + return { + targets, + errors, + }; + } + function generateTargets(message, targetOptions) { + const characterIDs = []; + const errors = []; + for (const option of targetOptions) { + const [type, ...values] = option.split(/[, ]/).map(v => v.trim()).filter(v => v.length > 0); + if (type === "sel" || type === "sel-noparty" || type === "sel-party") { + const results = generateSelectedTargets(message, type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "all" || type === "allgm" || type === "allplayers") { + const results = generateAllTargets(type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "charid") { + const results = generateCharacterIDTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "name") { + const results = generateNameTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + else if (type === "party") { + const results = generatePartyTargets(); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + } + const targets = Array.from(new Set(characterIDs)); + return { + targets, + errors, + }; + } + + const timerMap = new Map(); + function startTimer(key, duration = 50, callback) { + // Clear any existing timer for the same key + const existingTimer = timerMap.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + callback(); + timerMap.delete(key); + }, duration); + timerMap.set(key, timer); + } + function clearTimer(key) { + const timer = timerMap.get(key); + if (timer) { + clearTimeout(timer); + timerMap.delete(key); + } + } + + async function makeUpdate(operation, results, options) { + const isSetting = operation !== "delattr"; + const errors = []; + const messages = []; + const { noCreate = false } = {}; + const { setWithWorker = false } = getConfig() || {}; + const setOptions = { + noCreate, + setWithWorker, + }; + for (const target in results) { + for (const name in results[target]) { + const isMax = name.endsWith("_max"); + const type = isMax ? "max" : "current"; + const actualName = isMax ? name.slice(0, -4) : name; + if (isSetting) { + const value = results[target][name] ?? ""; + try { + await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + } + catch (error) { + errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); + } + } + else { + try { + await libSmartAttributes.deleteAttribute(target, actualName, type); + } + catch (error) { + errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); + } + } + } + } + return { errors, messages }; + } + + function broadcastHeader() { + log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); + } + function checkDependencies() { + if (libSmartAttributes === undefined) { + throw new Error("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + } + if (libUUID === undefined) { + throw new Error("libUUID is required but not found. Please ensure the libUUID script is installed."); + } + } + async function acceptMessage(msg) { + // State + const errors = []; + const messages = []; + const result = {}; + // Parse Message + const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); + // Start Timer + startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + // Preprocess + const { targets, errors: targetErrors } = generateTargets(msg, targeting); + errors.push(...targetErrors); + const request = generateRequest(references, changes); + const command = handlers[operation]; + if (!command) { + errors.push(`No handler found for operation: ${operation}`); + sendErrors(msg.playerid, "Errors", errors); + return; + } + // Execute + for (const target of targets) { + const attrs = await getAttributes(target, request); + const sectionNames = getAllSectionNames(changes); + const repOrders = await getAllRepOrders(target, sectionNames); + const modifications = processModifications(changes, attrs, options, repOrders); + const response = await command(modifications, target, references, options.nocreate, feedback); + if (response.errors.length > 0) { + errors.push(...response.errors); + continue; + } + messages.push(...response.messages); + result[target] = response.result; + } + const updateResult = await makeUpdate(operation, result); + clearTimer("chatsetattr"); + messages.push(...updateResult.messages); + errors.push(...updateResult.errors); + if (options.silent) + return; + sendErrors(msg.playerid, "Errors", errors, feedback?.from); + if (options.mute) + return; + const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; + const feedbackTitle = feedback?.header ?? delSetTitle; + sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); + } + function generateRequest(references, changes) { + const referenceSet = new Set(references); + for (const change of changes) { + if (change.name && !referenceSet.has(change.name)) { + referenceSet.add(change.name); + } + if (change.max !== undefined) { + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); + } + } + } + return Array.from(referenceSet); + } + function registerHandlers() { + broadcastHeader(); + checkDependencies(); + on("chat:message", (msg) => { + if (msg.type !== "api") { + const inlineMessage = extractMessageFromRollTemplate(msg); + if (!inlineMessage) + return; + msg.content = inlineMessage; + } + const debugReset = msg.content.startsWith("!setattrs-debugreset"); + if (debugReset) { + log("ChatSetAttr: Debug - resetting state."); + state.ChatSetAttr = {}; + return; + } + const debugVersion = msg.content.startsWith("!setattrs-debugversion"); + if (debugVersion) { + log("ChatSetAttr: Debug - setting version to 1.10."); + state.ChatSetAttr.version = "1.10"; + return; + } + const isHelpMessage = checkHelpMessage(msg.content); + if (isHelpMessage) { + handleHelpCommand(); + return; + } + const isConfigMessage = checkConfigMessage(msg.content); + if (isConfigMessage) { + handleConfigCommand(msg.content); + return; + } + const validMessage = validateMessage(msg.content); + if (!validMessage) + return; + checkPermissions(msg.playerid); + acceptMessage(msg); + }); + } + + const v2_0 = { + appliesTo: "<=1.10", + version: "2.0", + update: () => { + // Update state data + const config = getConfig(); + config.version = "2.0"; + config.playersCanTargetParty = true; + setConfig(config); + // Send message explaining update + const title = "ChatSetAttr Updated to Version 2.0"; + const content = ` +
+

ChatSetAttr has been updated to version 2.0!

+

This update includes important changes to improve compatibility and performance.

+ + Changelog: +
    +
  • Added compatibility for Beacon sheets, including the new Dungeons and Dragons character sheet.
  • +
  • Added support for targeting party members with the --party flag.
  • +
  • Added support for excluding party members when targeting selected tokens with the --sel-noparty flag.
  • +
  • Added support for including only party members when targeting selected tokens with the --sel-party flag.
  • +
+ +

Please review the updated documentation for details on these new features and how to use them.

+
+ If you encounter any bugs or issues, please report them via the Roll20 Helpdesk +
+
+ If you want to create a handout with the updated documentation, use the command !setattrs-help or click the button below + Create Help Handout +
+
+ `; + sendNotification(title, content, false); + }, + }; + + const VERSION_HISTORY = [ + v2_0, + ]; + function welcome() { + const hasWelcomed = hasFlag("welcome"); + if (hasWelcomed) { + return; + } + sendWelcomeMessage(); + setFlag("welcome"); + } + function update() { + log("ChatSetAttr: Checking for updates..."); + const config = getConfig(); + let currentVersion = config.version || "1.10"; + log(`ChatSetAttr: Current version: ${currentVersion}`); + if (currentVersion === 3) { + currentVersion = "1.10"; + } + log(`ChatSetAttr: Normalized current version: ${currentVersion}`); + checkForUpdates(currentVersion); + } + function checkForUpdates(currentVersion) { + for (const version of VERSION_HISTORY) { + log(`ChatSetAttr: Evaluating version update to ${version.version} (appliesTo: ${version.appliesTo})`); + const applies = version.appliesTo; + const versionString = applies.replace(/(<=|<|>=|>|=)/, "").trim(); + const comparison = applies.replace(versionString, "").trim(); + const compared = compareVersions(currentVersion, versionString); + let shouldApply = false; + switch (comparison) { + case "<=": + shouldApply = compared <= 0; + break; + case "<": + shouldApply = compared < 0; + break; + case ">=": + shouldApply = compared >= 0; + break; + case ">": + shouldApply = compared > 0; + break; + case "=": + shouldApply = compared === 0; + break; + } + if (shouldApply) { + version.update(); + currentVersion = version.version; + updateVersionInState(currentVersion); + } + } + } + function compareVersions(v1, v2) { + const [major1, minor1 = 0, patch1 = 0] = v1.split(".").map(Number); + const [major2, minor2 = 0, patch2 = 0] = v2.split(".").map(Number); + if (major1 !== major2) { + return major1 - major2; + } + if (minor1 !== minor2) { + return minor1 - minor2; + } + return patch1 - patch2; + } + function updateVersionInState(newVersion) { + const config = getConfig(); + config.version = newVersion; + setConfig(config); + } + + on("ready", () => { + registerHandlers(); + update(); + welcome(); + }); + + exports.registerObserver = registerObserver; + + return exports; + +})({}); diff --git a/ChatSetAttr/README.md b/ChatSetAttr/README.md index 44e317d0d4..bf57d3487e 100644 --- a/ChatSetAttr/README.md +++ b/ChatSetAttr/README.md @@ -1,94 +1,545 @@ # ChatSetAttr -This script is a utility that allows the user to create, modify, or delete character attributes via chat messages or macros. There are several options that determine which attributes are modified, and which characters the attributes are modified for. The script is called by the command **!setattr [--options]** for creating or modifying attributes, or **!delattr [--options]** for deleting attributes. +ChatSetAttr is a Roll20 API script that allows users to create, modify, or delete character sheet attributes through chat commands macros. Whether you need to update a single character attribute or make bulk changes across multiple characters, ChatSetAttr provides flexible options to streamline your game management. -## Selecting a target +## Table of Contents -One of the following options must be specified; they determine which characters are affected by the script. +1. [Basic Usage](#basic-usage) +2. [Available Commands](#available-commands) +3. [Target Selection](#target-selection) +4. [Attribute Syntax](#attribute-syntax) +5. [Modifier Options](#modifier-options) +6. [Output Control Options](#output-control-options) +7. [Inline Roll Integration](#inline-roll-integration) +8. [Repeating Section Support](#repeating-section-support) +9. [Special Value Expressions](#special-value-expressions) +10. [Global Configuration](#global-configuration) +11. [Complete Examples](#complete-examples) +12. [For Developers](#for-developers) -* **--all** will affect all characters in the game. USE WITH CAUTION. This option will only work for the GM. If you have a large number of characters in your campaign, this will take a while to process all attribute changes. -* **--allgm** will affect all characters which do not have a controlling player set, which typically will be every character that is not a player character. USE WITH CAUTION. This option will only work for the GM. -* **--charid charid1, charid2, ...** allows you to supply a list of character ids, and will affect characters whose ids come from this list. Non-GM Players can only affect characters that they control. -* **--name name1, name2, ...** allows you to supply a list of character names, and will look for a character with this name to affect. Non-GM Players can only affect characters that they control. -* **--sel** will affect all characters that are represented by tokens you have currently selected. +## Basic Usage -## Inline commands +The script provides several command formats: -It is possible to use some ChatSetAttr commands in the middle of a roll template, with some limitations. To do so, write the ChatSetAttr command between the properties of a roll template, and end it "!!!". If one of the attribute values is a whole roll template property, the first inline roll within that property will be used instead. It is easiest to illustrate how this works in an example: +- `!setattr [--options]` - Create or modify attributes +- `!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values) +- `!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds) +- `!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values) +- `!delattr [--options]` - Delete attributes -```null -&{template:default} {{name=Cthulhu}} !modattr --silent --charid @{target|character_id} --sanity|-{{Sanity damage=[[2d10+2]]}} --corruption|{{Corruption=Corruption increases by [[1]]}}!!! {{description=Text}} +Each command requires a target selection option and one or more attributes to modify. + +**Basic structure:** +``` +!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2 +``` + +## Available Commands + +### !setattr + +Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified). + +**Example:** +``` +!setattr --sel --hp|25|50 --xp|0|800 +``` + +This would set `hp` to 25, `hp_max` to 50, `xp` to 0 and `xp_max` to 800. + +### !modattr + +Adds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`. + +**Example:** +``` +!modattr --sel --hp|-5 --xp|100 +``` + +This subtracts 5 from `hp` and adds 100 to `xp`. + +### !modbattr + +Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`. + +**Example:** +``` +!modbattr --sel --hp|-25 --xp|2500 +``` + +This subtracts 5 from `hp` but won't reduce it below 0 and increase `xp` by 25, but won't increase it above `mp_xp`. + +### !resetattr + +Resets attributes to their maximum value. Shorthand for `!setattr --reset`. + +**Example:** +``` +!resetattr --sel --hp --xp +``` + +This resets `hp`, and `xp` to their respective maximum values. + +### !delattr + +Deletes the specified attributes. + +**Example:** +``` +!delattr --sel --hp --xp ``` -This will decrease sanity by 2d10+2 and increase corruption by 1 for the character selected with the --charid @{target|character\_id} command. **It is crucial** that the ChatSetAttr part of the command is ended by three exclamation marks like in the message above – this is how the script know when to stop interpreting the roll template as part of the ChatSetAttr command. +This removes the `hp` and `xp` attributes. -## Additional options +## Target Selection -These options will have no effect on **!delattr**, except for **--silent**. +One of these options must be specified to determine which characters will be affected: -* **--silent** will suppress normal output; error messages will still be displayed. -* **--mute** will suppress normal output as well as error messages (hence **--mute** implies **--silent**). -* **--replace** will replace the characters < , > , ~ , ; , and \` by the characters [,],-,?, and @ in attribute values. This is useful when you do not want roll20 to evaluate your expression in chat before it is parsed by the script. Alternatively, you can use \\lbrak, \\rbrak, \\n, \\at, and \\ques to create [, ], a newline, @, and ?. -* **--nocreate** will change the script's default behaviour of creating a new attribute when it cannot find one; instead, the script will display an error message when it cannot find an existing attribute with the given name. -* **--mod** will add the new value to the existing value instead of replacing it. If the existing value is a number (or empty, which will be treated as 0), the new value will be added to the existing value. If not, an error message will be displayed instead. Try not to apply this option to attributes whose values are not numbers. You can use **!modattr** as a shortcut for **!setattr --mod**. -* **--modb** works like **--mod**, except that the attribute's current value is kept between 0 and its maximum. You can use **!modbattr** as a shortcut for **!setattr --modb**. -* **--reset** will simply reset all entered attribute values to the maximum; the values you enter are ignored. You can use **!resetattr** as a shortcut for **!setattr --reset**. -* **--evaluate** is a GM-only (unless you allow it to be used by players via the configuration) option that will use JavaScript eval() to evaluate the attribute value expressions. This allows you to do math in expressions involving other attributes (see the example below). However, this option is inherently dangerous and prone to errors, so be careful. +### --all -## Feedback options +Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns. -The script accepts several options that modify the feedback messages sent by the script. +**Example:** +``` +!setattr --all --hp|15 +``` + +### --allgm + +Affects all characters without player controllers (typically NPCs). **GM only**. + +**Example:** +``` +!setattr --allgm --xp|150 +``` + +### --allplayers + +Affects all characters with player controllers (typically PCs). + +**Example:** +``` +!setattr --allplayers --hp|15 +``` + +### --charid + +Affects characters with the specified character IDs. Non-GM players can only affect characters they control. + +**Example:** +``` +!setattr --charid --xp|150 +``` + +### --name + +Affects characters with the specified names. Non-GM players can only affect characters they control. + +**Example:** +``` +!setattr --name Gandalf, Frodo Baggins --party|"Fellowship of the Ring" +``` + +### --sel + +Affects characters represented by currently selected tokens. -* **--fb-public** will send the output to chat publicly, instead of whispering it to the player who sent the command. Note that error messages will still be whispered. -* **--fb-from \** will modify the name that appears as the sender in chat messages sent by the script. If not specified, this defaults to "ChatSetAttr". -* **--fb-header \** will replace the title of the message sent by the script - normally, "Setting Attributes" or "Deleting Attributes" - with a custom string. -* **--fb-content \** will replace the feedback line for every character with a custom string. This will not work with **!delattr**. +**Example:** +``` +!setattr --sel --hp|25 --xp|30 +``` + +### --sel-party + +Affects only party characters represented by currently selected tokens (characters with `inParty` set to true). -You can use the following special character sequences in the values of both **--fb-header** and **--fb-content**. Here, **J** is an integer, starting from 0, and refers to the **J**-th attribute you are changing. They will be dynamically replaced as follows: +**Example:** +``` +!setattr --sel-party --inspiration|1 +``` -* \_NAME**J**\_: will insert the attribute name. -* \_TCUR**J**\_: will insert what you are changing the current value to (or changing by, if you're using **--mod** or **--modb**). -* \_TMAX**J**\_: will insert what you are changing the maximum value to (or changing by, if you're using **--mod** or **--modb**). +### --sel-noparty -In addition, there are extra insertion sequence that only make sense in the value of **--fb-content**: +Affects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set). -* \_CHARNAME\_: will insert the character name. -* \_CUR**J**\_: will insert the final current value of the attribute, for this character. -* \_MAX**J**\_: will insert the final maximum value of the attribute, for this character. +**Example:** +``` +!setattr --sel-noparty --npc_status|"Hostile" +``` + +### --party + +Affects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration. + +**Example:** +``` +!setattr --party --rest_complete|1 +``` ## Attribute Syntax -Attribute options will determine which attributes are set to which value (respectively deleted, in case of !delattr). The syntax for these options is **--name|value** or **--name|value|max**. Here, **name** is the name of the attribute (which is parsed case-insensitively), **value** is the value that the current value of the attribute should be set to, and **max** is the value that the maximum value of the attribute should be set to. Instead of the vertical line ('|'), you may also use '#' (for use inside roll queries, for example). +The syntax for specifying attributes is: +``` +--attributeName|currentValue|maxValue +``` + +* `attributeName` is the name of the attribute to modify +* `currentValue` is the value to set (optional for some commands) +* `maxValue` is the maximum value to set (optional) + +### Examples: + +1. Set current value only: + ``` + --strength|15 + ``` + +2. Set both current and maximum values: + ``` + --hp|27|35 + ``` + +3. Set only the maximum value (leave current unchanged): + ``` + --hp||50 + ``` + +4. Create empty attribute or set to empty: + ``` + --notes| + ``` + +5. Use `#` instead of `|` (useful in roll queries): + ``` + --strength#15 + ``` + +## Modifier Options + +These options change how attributes are processed: -* Single quotes (') surrounding **value** or **max** will be stripped, as will trailing spaces. If you need to include spaces at the end of a value, enclose the whole expression in single quotes. -* If you want to use the '|' or '#' characters inside an attribute value, you may escape them with a backslash: use '\|' or '\#' instead. -* If the option is of the form **--name|value**, then the maximum value will not be changed. -* If it is of the form **--name||max**, then the current value will not be changed. -* You can also just supply **--name|** or **--name** if you just want to create an empty attribute or set it to empty if it already exists, for whatever reason. -* **value** and **max** are ignored for **!delattr**. -* If you want to empty the current attribute and set some maximum, use **--name|''|max**. -* The script can deal with repeating attributes, both by id (e.g. **repeating\_prefix\_-ABC123\_attribute**) and by row index (e.g. **repeating\_prefix\_$0\_attribute**). If you want to create a new repeating row in a repeating section with name **prefix**, use the attribute name **repeating\_prefix\_-CREATE\_name**. If you want to delete a repeating row with **!delattr**, use the attribute name **repeating\_prefix\_ID** or **repeating\_prefix\_$rowNumber**. -* You can insert the values of _other_ attributes into the attributes values to be set via %attribute\_name%. For example, **--attr1|%attr2%|%attr2\_max%** will insert the current and maximum value of **attr2** into those of **attr1**. +### --mod + +See `!modattr` command. + +### --modb + +See `!modbattr` command. + +### --reset + +See `!resetattr` command. + +### --nocreate + +Prevents creation of new attributes, only updates existing ones. + +**Example:** +``` +!setattr --sel --nocreate --perception|20 --xp|15 +``` + +This will only update `perception` or `xp` if it already exists. + +### --evaluate + +Evaluates JavaScript expressions in attribute values. **GM only by default**. + +**Example:** +``` +!setattr --sel --evaluate --hp|2 * 3 +``` -## Examples +This will set the `hp` attribute to 6. -* **!setattr --sel --Strength|15** will set the Strength attribute for 15 for all selected characters. -* **!setattr --name John --HP|17|27 --Dex|10** will set HP to 17 out of 27 and Dex to 10 for the character John (only one of them, if more than one character by this name exists). -* **!delattr --all --gold** will delete the attribute called gold from all characters, if it exists. -* **!setattr --sel --mod --Strength|5** will increase the Strength attribute of all selected characters by 5, provided that Strength is either empty or has a numerical value - it will fail to have an effect if, for example, Strength has the value 'Very big'. -* **!setattr --sel --Ammo|%Ammo\_max%** will reset the Ammo attribute for the selected characters back to its maximum value. -* If the current value of attr1 is 3 and the current value of attr2 is 2, **!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%** will set the current value of attr3 to 15. +### --replace -## Global configuration +Replaces special characters to prevent Roll20 from evaluating them: +- < becomes [ +- > becomes ] +- ~ becomes - +- ; becomes ? +- \` becomes @ -There are three global configuration options, _playersCanModify_, _playersCanEvaluate_, and _useWorkers_, which can be toggled either on this page or by entering **!setattr-config** in chat. The former two will give players the possibility of modifying characters they don't control or using the **--evaluate** option. You should only activate either of these if you can trust your players not to vandalize your characters or your campaign. The last option will determine if the script triggers sheet workers on use, and should normally be toggled on. +Also supports \lbrak, \rbrak, \n, \at, and \ques for [, ], newline, @, and ?. + +**Example:** +``` +!setattr --sel --replace --notes|"Roll <<1d6>> to succeed" +``` + +This stores "Roll [[1d6]] to succeed" without evaluating the roll. + +## Output Control Options + +These options control the feedback messages generated by the script: + +### --silent + +Suppresses normal output messages (error messages will still appear). + +**Example:** +``` +!setattr --sel --silent --stealth|20 +``` + +### --mute + +Suppresses all output messages, including errors. + +**Example:** +``` +!setattr --sel --mute --nocreate --new_value|42 +``` + +### --fb-public + +Sends output publicly to the chat instead of whispering to the command sender. + +**Example:** +``` +!setattr --sel --fb-public --hp|25|25 --status|"Healed" +``` + +### --fb-from \ + +Changes the name of the sender for output messages (default is "ChatSetAttr"). + +**Example:** +``` +!setattr --sel --fb-from "Healing Potion" --hp|25 +``` + +### --fb-header \ + +Customizes the header of the output message. + +**Example:** +``` +!setattr --sel --evaluate --fb-header "Combat Effects Applied" --status|"Poisoned" --hp|%hp%-5 +``` -## Registering observers +### --fb-content \ -**Note:** this section is only intended to be read by script authors. If you are not writing API scripts, you can safely ignore this. +Customizes the content of the output message. + +**Example:** +``` +!setattr --sel --fb-content "Increasing Hitpoints" --hp|10 +``` + +### Special Placeholders + +For use in `--fb-header` and `--fb-content`: + +* `_NAMEJ_` - Name of the Jth attribute being changed +* `_TCURJ_` - Target current value of the Jth attribute +* `_TMAXJ_` - Target maximum value of the Jth attribute + +For use in `--fb-content` only: + +* `_CHARNAME_` - Name of the character +* `_CURJ_` - Final current value of the Jth attribute +* `_MAXJ_` - Final maximum value of the Jth attribute + +**Important:** The Jth index starts with 0 at the first item. + +**Example:** +``` +!setattr --sel --fb-header "Healing Effects" --fb-content "_CHARNAME_ healed by _CUR0_ hitpoints --hp|10 +``` + +## Inline Roll Integration + +ChatSetAttr can be used within roll templates or combined with inline rolls: + +### Within Roll Templates + +Place the command between roll template properties and end it with `!!!`: + +``` +&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}} +``` + +### Using Inline Rolls in Values + +Inline rolls can be used for attribute values: + +``` +!setattr --sel --hp|[[2d6+5]] +``` + +### Roll Queries + +Roll queries can determine attribute values: + +``` +!setattr --sel --hp|?{Set strength to what value?|100} +``` + +## Repeating Section Support + +ChatSetAttr supports working with repeating sections: + +### Creating New Repeating Items + +Use `-CREATE` to create a new row in a repeating section: + +``` +!setattr --sel --repeating_inventory_-CREATE_itemname|"Magic Sword" --repeating_inventory_-CREATE_itemweight|2 +``` + +### Modifying Existing Repeating Items + +Access by row ID: + +``` +!setattr --sel --repeating_inventory_-ID_itemname|"Enchanted Magic Sword" +``` + +Access by index (starts at 0): + +``` +!setattr --sel --repeating_inventory_$0_itemname|"First Item" +``` + +### Deleting Repeating Rows + +Delete by row ID: + +``` +!delattr --sel --repeating_inventory_-ID +``` + +Delete by index: + +``` +!delattr --sel --repeating_inventory_$0 +``` + +## Special Value Expressions + +### Attribute References + +Reference other attribute values using `%attribute_name%`: + +``` +!setattr --sel --evaluate --temp_hp|%hp% / 2 +``` + +### Resetting to Maximum + +Reset an attribute to its maximum value: + +``` +!setattr --sel --hp|%hp_max% +``` + +## Global Configuration + +The script has four global configuration options that can be toggled with `!setattr-config`: + +### --players-can-modify + +Allows players to modify attributes on characters they don't control. + +``` +!setattr-config --players-can-modify +``` + +### --players-can-evaluate + +Allows players to use the `--evaluate` option. + +``` +!setattr-config --players-can-evaluate +``` + +### --players-can-target-party + +Allows players to use the `--party` target option. **GM only by default**. + +``` +!setattr-config --players-can-target-party +``` + +### --use-workers + +Toggles whether the script triggers sheet workers when setting attributes. + +``` +!setattr-config --use-workers +``` + +## Complete Examples + +### Basic Combat Example + +Reduce a character's HP and status after taking damage: + +``` +!modattr --sel --evaluate --hp|-15 --fb-header "Combat Result" --fb-content "_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!" +``` + +### Leveling Up a Character + +Update multiple stats when a character gains a level: + +``` +!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from "Level Up" --fb-header "Character Advanced" --fb-public +``` + +### Create New Item in Inventory + +Add a new item to a character's inventory: + +``` +!setattr --sel --repeating_inventory_-CREATE_itemname|"Healing Potion" --repeating_inventory_-CREATE_itemcount|3 --repeating_inventory_-CREATE_itemweight|0.5 --repeating_inventory_-CREATE_itemcontent|"Restores 2d8+2 hit points when consumed" +``` + +### Apply Status Effects During Combat + +Apply a debuff to selected enemies in the middle of combat: + +``` +&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|"Restrained"!!! {{duration=1d4 rounds}} +``` + +### Party Management Examples + +Give inspiration to all party members after a great roleplay moment: + +``` +!setattr --party --inspiration|1 --fb-public --fb-header "Inspiration Awarded" --fb-content "All party members receive inspiration for excellent roleplay!" +``` + +Apply a long rest to only party characters among selected tokens: + +``` +!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header "Long Rest Complete" +``` + +Set hostile status for non-party characters among selected tokens: + +``` +!setattr --sel-noparty --attitude|"Hostile" --fb-from "DM" --fb-content "Enemies are now hostile!" +``` + +## For Developers + +### Registering Observers + +If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr: + +```javascript +ChatSetAttr.registerObserver(event, observer); +``` -Changes made by API scripts do not trigger the default Roll20 event handlers, by default. While perhaps a sensible choice in order to prevent infinite loops, it is unfortunate if you do want your script to ChatSetAttr-induced attribute changes. To this end, ChatSetAttr offers an observer pattern. You can register your script with ChatSetAttr like you would register Roll20 event handlers, and your handler functions will be called by ChatSetAttr. The syntax is +Where `event` is one of: +- `"add"` - Called when attributes are created +- `"change"` - Called when attributes are modified +- `"destroy"` - Called when attributes are deleted -`ChatSetAttr.registerObserver(event, observer);` +And `observer` is an event handler function similar to Roll20's built-in event handlers. -where `event` is one of `"add"`, `"change"`, or `"destroy"`, and `observer` is the event handler function (with identical structure like the one you would pass to e.g. a `"change:attribute"` event). \ No newline at end of file +This allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface. \ No newline at end of file diff --git a/ChatSetAttr/eslint.config.ts b/ChatSetAttr/eslint.config.ts new file mode 100644 index 0000000000..c82910f395 --- /dev/null +++ b/ChatSetAttr/eslint.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "eslint/config"; // While not strictly necessary, it's good practice. +import stylistic from "@stylistic/eslint-plugin"; +import jslint from "@eslint/js"; +import tslint from "typescript-eslint"; + +export default defineConfig( + { + ignores: ["[0-9]+.[0-9]+/", "*.d.ts", "dist/**", "build/**", "node_modules/**", "src/legacy/**"], + }, + jslint.configs.recommended, + ...tslint.configs.recommended, + { + plugins: { + "@stylistic": stylistic, + }, + rules: { + "@stylistic/quotes": ["error", "double"], + "@stylistic/semi": ["error", "always"], + "@stylistic/indent": ["error", 2], + }, + }, +); \ No newline at end of file diff --git a/ChatSetAttr/package-lock.json b/ChatSetAttr/package-lock.json new file mode 100644 index 0000000000..6a5fa7c49c --- /dev/null +++ b/ChatSetAttr/package-lock.json @@ -0,0 +1,3597 @@ +{ + "name": "chatsetattr", + "version": "2.0.0beta", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chatsetattr", + "version": "2.0.0beta", + "license": "ISC", + "devDependencies": { + "@eslint/js": "^9.36.0", + "@roll20/api-types": "../.types", + "@rollup/plugin-inject": "^5.0.5", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.4", + "@stylistic/eslint-plugin": "^5.4.0", + "@types/node": "^22.15.15", + "@types/underscore": "^1.13.0", + "eslint": "^9.36.0", + "lib-smart-attributes": "../libSmartAttributes", + "lib-uuid": "../libUUID", + "rollup": "^4.52.3", + "rollup-plugin-delete": "^3.0.1", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0", + "underscore": "^1.13.7", + "vitest": "^3.2.4" + } + }, + "../.types": { + "name": "@roll20/api-types", + "version": "1.0.0", + "dev": true + }, + "../APISmartAttributes": { + "name": "@roll20-api/smartattributes", + "version": "1.0.0", + "extraneous": true, + "license": "ISC", + "devDependencies": { + "@eslint/js": "^9.36.0", + "@roll20/api-types": "../.types", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.4", + "@stylistic/eslint-plugin": "^5.4.0", + "eslint": "^9.36.0", + "rollup": "^4.52.3", + "rollup-plugin-delete": "^3.0.1", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0", + "vitest": "^3.2.4" + } + }, + "../libSmartAttributes": { + "name": "lib-smart-attributes", + "version": "1.0.0", + "dev": true, + "license": "ISC", + "devDependencies": { + "@eslint/js": "^9.36.0", + "@roll20/api-types": "../.types", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.4", + "@stylistic/eslint-plugin": "^5.4.0", + "eslint": "^9.36.0", + "rollup": "^4.52.3", + "rollup-plugin-delete": "^3.0.1", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0", + "vitest": "^3.2.4" + } + }, + "../libUUID": { + "name": "lib-smart-attributes", + "version": "1.0.0", + "dev": true, + "license": "ISC", + "devDependencies": { + "@eslint/js": "^9.36.0", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.4", + "@stylistic/eslint-plugin": "^5.4.0", + "eslint": "^9.36.0", + "rollup": "^4.52.3", + "rollup-plugin-delete": "^3.0.1", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@roll20/api-types": { + "resolved": "../.types", + "link": true + }, + "node_modules/@rollup/plugin-inject": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz", + "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", + "integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "12.1.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.1.4.tgz", + "integrity": "sha512-s5Hx+EtN60LMlDBvl5f04bEiFZmAepk27Q+mr85L/00zPDn1jtzlTV6FWn81MaIwqfWzKxmOJrBWHU6vtQyedQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.4.0.tgz", + "integrity": "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.0", + "@typescript-eslint/types": "^8.44.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.18.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", + "integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, + "node_modules/@types/underscore": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", + "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", + "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/type-utils": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.45.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", + "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", + "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.45.0", + "@typescript-eslint/types": "^8.45.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", + "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", + "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", + "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", + "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", + "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.45.0", + "@typescript-eslint/tsconfig-utils": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", + "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", + "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.45.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/del": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/del/-/del-8.0.1.tgz", + "integrity": "sha512-gPqh0mKTPvaUZGAuHbrBUYKZWBNAeHG7TU3QH5EhVwPMyKvmfJaNXhcD2jTcXsJRRcffuho4vaYweu80dRrMGA==", + "dev": true, + "dependencies": { + "globby": "^14.0.2", + "is-glob": "^4.0.3", + "is-path-cwd": "^3.0.0", + "is-path-inside": "^4.0.0", + "p-map": "^7.0.2", + "presentable-error": "^0.0.1", + "slash": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-3.0.0.tgz", + "integrity": "sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lib-smart-attributes": { + "resolved": "../libSmartAttributes", + "link": true + }, + "node_modules/lib-uuid": { + "resolved": "../libUUID", + "link": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/presentable-error": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/presentable-error/-/presentable-error-0.0.1.tgz", + "integrity": "sha512-E6rsNU1QNJgB3sjj7OANinGncFKuK+164sLXw1/CqBjj/EkXSoSdHCtWQGBNlREIGLnL7IEUEGa08YFVUbrhVg==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-delete": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-delete/-/rollup-plugin-delete-3.0.1.tgz", + "integrity": "sha512-4tyijMQFwSDLA04DAHwbI2TrRwPiRwAqBQ17dxyr9CgHeHXLdgk8IDVWHFWPrL3UZJWrAmHohQ2MgmVghQDrlg==", + "dev": true, + "dependencies": { + "del": "^8.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rollup": "*" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.45.0.tgz", + "integrity": "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.45.0", + "@typescript-eslint/parser": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/vite": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.8.tgz", + "integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/ChatSetAttr/package.json b/ChatSetAttr/package.json new file mode 100644 index 0000000000..b11d50dd77 --- /dev/null +++ b/ChatSetAttr/package.json @@ -0,0 +1,41 @@ +{ + "name": "chatsetattr", + "version": "2.0.0beta", + "type": "module", + "main": "src/index.ts", + "scripts": { + "lint": "eslint", + "lint:fix": "eslint --fix", + "build": "rollup --config rollup.config.ts --configPlugin typescript --configImportAttributesKey with", + "start": "rollup --config rollup.config.ts --configPlugin typescript --configImportAttributesKey with --watch", + "test": "vitest", + "test:run": "vitest run", + "test:watch": "vitest --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@eslint/js": "^9.36.0", + "@roll20/api-types": "../.types", + "@rollup/plugin-inject": "^5.0.5", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.4", + "@stylistic/eslint-plugin": "^5.4.0", + "@types/node": "^22.15.15", + "@types/underscore": "^1.13.0", + "eslint": "^9.36.0", + "lib-smart-attributes": "../libSmartAttributes", + "lib-uuid": "../libUUID", + "rollup": "^4.52.3", + "rollup-plugin-delete": "^3.0.1", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0", + "underscore": "^1.13.7", + "vitest": "^3.2.4" + } +} diff --git a/ChatSetAttr/rollup.config.ts b/ChatSetAttr/rollup.config.ts new file mode 100644 index 0000000000..4b8d9e50c2 --- /dev/null +++ b/ChatSetAttr/rollup.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from "rollup"; // 💡 Import defineConfig and RollupOptions +import typescript from "@rollup/plugin-typescript"; +import del from "rollup-plugin-delete"; +import injectPlugin from "@rollup/plugin-inject"; +import jsonPlugin from "@rollup/plugin-json"; +import json from "./script.json" with { type: "json" }; +import path from "path/win32"; + +const authors = Array.isArray(json.authors) ? json.authors.join(", ") : json.authors; + +export default defineConfig({ + input: "src/index.ts", + + output: [ + { + file: `${json.version}/${json.name}.js`, + format: "iife", + name: "ChatSetAttr", + sourcemap: false, + banner: `// ${json.name} v${json.version} by ${authors}`, + }, + { + file: `${json.name}.js`, + sourcemap: false, + format: "iife", + name: "ChatSetAttr", + banner: `// ${json.name} v${json.version} by ${authors}`, + }, + ], + + plugins: [ + del({ targets: `${json.version}/*`, runOnce: true }), + jsonPlugin(), + injectPlugin({ + "h": [path.resolve("src/utils/chat.ts"), "h"], + }), + typescript(), + ] +}); \ No newline at end of file diff --git a/ChatSetAttr/script.json b/ChatSetAttr/script.json index 4ab4c6dbf7..59ed753c1c 100644 --- a/ChatSetAttr/script.json +++ b/ChatSetAttr/script.json @@ -1,11 +1,15 @@ { "name": "ChatSetAttr", "script": "ChatSetAttr.js", - "version": "1.10", + "version": "2.0", "description": "# ChatSetAttr\n\nThis script is a utility that allows the user to create, modify, or delete character attributes via chat messages or macros. There are several options that determine which attributes are modified, and which characters the attributes are modified for. The script is called by the command **!setattr [--options]** for creating or modifying attributes, or **!delattr [--options]** for deleting attributes.\n\n## Selecting a target\n\nOne of the following options must be specified; they determine which characters are affected by the script.\n\n* **--all** will affect all characters in the game. USE WITH CAUTION. This option will only work for the GM. If you have a large number of characters in your campaign, this will take a while to process all attribute changes.\n* **--allgm** will affect all characters which do not have a controlling player set, which typically will be every character that is not a player character. USE WITH CAUTION. This option will only work for the GM.\n* **--charid charid1, charid2, ...** allows you to supply a list of character ids, and will affect characters whose ids come from this list. Non-GM Players can only affect characters that they control.\n* **--name name1, name2, ...** allows you to supply a list of character names, and will look for a character with this name to affect. Non-GM Players can only affect characters that they control.\n* **--sel** will affect all characters that are represented by tokens you have currently selected.\n\n## Inline commands\n\nIt is possible to use some ChatSetAttr commands in the middle of a roll template, with some limitations. To do so, write the ChatSetAttr command between the properties of a roll template, and end it \"!!!\". If one of the attribute values is a whole roll template property, the first inline roll within that property will be used instead. It is easiest to illustrate how this works in an example:\n\n```\n&{template:default} {{name=Cthulhu}} !modattr --silent --charid @{target|character\\_id} --sanity|-{{Sanity damage=[[2d10+2]]}} --corruption|{{Corruption=Corruption increases by [[1]]}}!!! {{description=Text}}\n```\n\nThis will decrease sanity by 2d10+2 and increase corruption by 1 for the character selected with the --charid @{target|character\\_id} command. **It is crucial** that the ChatSetAttr part of the command is ended by three exclamation marks like in the message above – this is how the script know when to stop interpreting the roll template as part of the ChatSetAttr command.\n\n## Additional options\n\nThese options will have no effect on **!delattr**, except for **--silent**.\n\n* **--silent** will suppress normal output; error messages will still be displayed.\n* **--mute** will suppress normal output as well as error messages (hence **--mute** implies **--silent**).\n* **--replace** will replace the characters < , > , ~ , ; , and ` by the characters [,],-,?, and @ in attribute values. This is useful when you do not want roll20 to evaluate your expression in chat before it is parsed by the script. Alternatively, you can use \\lbrak, \\rbrak, \\n, \\at, and \\ques to create [, ], a newline, @, and ?.\n* **--nocreate** will change the script's default behaviour of creating a new attribute when it cannot find one; instead, the script will display an error message when it cannot find an existing attribute with the given name.\n* **--mod** will add the new value to the existing value instead of replacing it. If the existing value is a number (or empty, which will be treated as 0), the new value will be added to the existing value. If not, an error message will be displayed instead. Try not to apply this option to attributes whose values are not numbers. You can use **!modattr** as a shortcut for **!setattr --mod**.\n* **--modb** works like **--mod**, except that the attribute's current value is kept between 0 and its maximum. You can use **!modbattr** as a shortcut for **!setattr --modb**.\n* **--reset** will simply reset all entered attribute values to the maximum; the values you enter are ignored. You can use **!resetattr** as a shortcut for **!setattr --reset**.\n* **--evaluate** is a GM-only (unless you allow it to be used by players via the configuration) option that will use JavaScript eval() to evaluate the attribute value expressions. This allows you to do math in expressions involving other attributes (see the example below). However, this option is inherently dangerous and prone to errors, so be careful.\n\n## Feedback options\n\nThe script accepts several options that modify the feedback messages sent by the script.\n\n* **--fb-public** will send the output to chat publicly, instead of whispering it to the player who sent the command. Note that error messages will still be whispered.\n* **--fb-from ** will modify the name that appears as the sender in chat messages sent by the script. If not specified, this defaults to \"ChatSetAttr\".\n* **--fb-header ** will replace the title of the message sent by the script - normally, \"Setting Attributes\" or \"Deleting Attributes\" - with a custom string.\n* **--fb-content ** will replace the feedback line for every character with a custom string. This will not work with **!delattr**.\n\nYou can use the following special character sequences in the values of both **--fb-header** and **--fb-content**. Here, **J** is an integer, starting from 0, and refers to the **J**-th attribute you are changing. They will be dynamically replaced as follows:\n\n* \\_NAME**J**\\_: will insert the attribute name.\n* \\_TCUR**J**\\_: will insert what you are changing the current value to (or changing by, if you're using **--mod** or **--modb**).\n* \\_TMAX**J**\\_: will insert what you are changing the maximum value to (or changing by, if you're using **--mod** or **--modb**).\n\nIn addition, there are extra insertion sequence that only make sense in the value of **--fb-content**:\n\n* \\_CHARNAME\\_: will insert the character name.\n* \\_CUR**J**\\_: will insert the final current value of the attribute, for this character.\n* \\_MAX**J**\\_: will insert the final maximum value of the attribute, for this character.\n\n## Attribute Syntax\n\nAttribute options will determine which attributes are set to which value (respectively deleted, in case of !delattr). The syntax for these options is **--name|value** or **--name|value|max**. Here, **name** is the name of the attribute (which is parsed case-insensitively), **value** is the value that the current value of the attribute should be set to, and **max** is the value that the maximum value of the attribute should be set to. Instead of the vertical line ('|'), you may also use '#' (for use inside roll queries, for example).\n\n* Single quotes (') surrounding **value** or **max** will be stripped, as will trailing spaces. If you need to include spaces at the end of a value, enclose the whole expression in single quotes.\n* If you want to use the '|' or '#' characters inside an attribute value, you may escape them with a backslash: use '|' or '#' instead.\n* If the option is of the form **--name|value**, then the maximum value will not be changed.\n* If it is of the form **--name||max**, then the current value will not be changed.\n* You can also just supply **--name|** or **--name** if you just want to create an empty attribute or set it to empty if it already exists, for whatever reason.\n* **value** and **max** are ignored for **!delattr**.\n* If you want to empty the current attribute and set some maximum, use **--name|''|max**.\n* The script can deal with repeating attributes, both by id (e.g. **repeating\\_prefix\\_-ABC123\\_attribute**) and by row index (e.g. **repeating\\_prefix\\_$0\\_attribute**). If you want to create a new repeating row in a repeating section with name **prefix**, use the attribute name **repeating\\_prefix\\_-CREATE\\_name**. If you want to delete a repeating row with **!delattr**, use the attribute name **repeating\\_prefix\\_ID** or **repeating\\_prefix\\_$rowNumber**.\n* You can insert the values of \\_other\\_ attributes into the attributes values to be set via %attribute\\_name%. For example, **--attr1|%attr2%|%attr2_max%** will insert the current and maximum value of **attr2** into those of **attr1**.\n\n## Examples\n\n* **!setattr --sel --Strength|15** will set the Strength attribute for 15 for all selected characters.\n* **!setattr --name John --HP|17|27 --Dex|10** will set HP to 17 out of 27 and Dex to 10 for the character John (only one of them, if more than one character by this name exists).\n* **!delattr --all --gold** will delete the attribute called gold from all characters, if it exists.\n* **!setattr --sel --mod --Strength|5** will increase the Strength attribute of all selected characters by 5, provided that Strength is either empty or has a numerical value - it will fail to have an effect if, for example, Strength has the value 'Very big'.\n* **!setattr --sel --Ammo|%Ammo_max%** will reset the Ammo attribute for the selected characters back to its maximum value.\n* If the current value of attr1 is 3 and the current value of attr2 is 2, **!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%** will set the current value of attr3 to 15.\n\n## Global configuration\n\nThere are three global configuration options, _playersCanModify_, _playersCanEvaluate_, and _useWorkers_, which can be toggled either on this page or by entering **!setattr-config** in chat. The former two will give players the possibility of modifying characters they don't control or using the **--evaluate** option. You should only activate either of these if you can trust your players not to vandalize your characters or your campaign. The last option will determine if the script triggers sheet workers on use, and should normally be toggled on.\n## Registering observers\n\n**Note:** this section is only intended to be read by script authors. If you are not writing API scripts, you can safely ignore this.\n\nChanges made by API scripts do not trigger the default Roll20 event handlers, by default. While perhaps a sensible choice in order to prevent infinite loops, it is unfortunate if you do want your script to ChatSetAttr-induced attribute changes. To this end, ChatSetAttr offers an observer pattern. You can register your script with ChatSetAttr like you would register Roll20 event handlers, and your handler functions will be called by ChatSetAttr. The syntax is\n\n`ChatSetAttr.registerObserver(event, observer);`\n\nwhere `event` is one of `\"add\"`, `\"change\"`, or `\"destroy\"`, and `observer` is the event handler function (with identical structure like the one you would pass to e.g. a `\"change:attribute\"` event).", - "authors": "Jakob", + "authors": [ + "Jakob", + "GUD Team" + ], "roll20userid": "726129", - "useroptions": [{ + "useroptions": [ + { "name": "Players can modify all characters", "type": "checkbox", "description": "Select this option to allow all players to use the `--charid` or `--name` parameter to specify characters they don't control to be modified.", @@ -25,7 +29,9 @@ "checked": "checked" } ], - "dependencies": {}, + "dependencies": [ + "APISmartAttributes" + ], "modifies": { "state.ChatSetAttr": "read,write", "attribute.characterid": "read", @@ -36,5 +42,31 @@ "graphic.represents": "read" }, "conflicts": [], - "previousversions": ["1.9", "1.8", "1.7.1", "1.7", "1.6.2", "1.6.1", "1.6", "1.5", "1.4", "1.3", "1.2.2", "1.2.1", "1.2", "1.1.5", "1.1.4", "1.1.3", "1.1.2", "1.1.1", "1.1", "1.0.2", "1.0.1", "1.0", "0.9.1", "0.9"] -} + "previousversions": [ + "1.10", + "1.9", + "1.8", + "1.7.1", + "1.7", + "1.6.2", + "1.6.1", + "1.6", + "1.5", + "1.4", + "1.3", + "1.2.2", + "1.2.1", + "1.2", + "1.1.5", + "1.1.4", + "1.1.3", + "1.1.2", + "1.1.1", + "1.1", + "1.0.2", + "1.0.1", + "1.0", + "0.9.1", + "0.9" + ] +} \ No newline at end of file diff --git a/ChatSetAttr/src/__mocks__/apiObjects.mock.ts b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts new file mode 100644 index 0000000000..349ee17539 --- /dev/null +++ b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts @@ -0,0 +1,148 @@ +import { vi } from "vitest"; +import { debugLog, debugWarn } from "./utility.mock"; + +const allObjects: AnyRoll20Object[] = []; + +export function resetAllObjects(): void { + allObjects.length = 0; +} + +function createRandomId(): string { + return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10); +}; + +type ObjProps = Roll20ObjectTypeToInstance[T]["properties"]; + +export class MockObject implements Roll20Object> { + id = createRandomId(); + properties: ObjProps; + + constructor(type: Roll20ObjectType, initialProperties: Record) { + if (initialProperties.id) { + this.id = String(initialProperties.id); + } + if (initialProperties._id) { + this.id = String(initialProperties._id); + } + const allProperties: Record = { _id: this.id, _type: type }; + for (const key in initialProperties) { + const fixedKey = key.startsWith("_") ? key : `_${key}`; + allProperties[fixedKey] = initialProperties[key]; + } + this.properties = allProperties as ObjProps; + } + + get = vi.fn(>(key: K) => { + if (typeof key !== "string") { + throw new Error("Key must be a string"); + } + const fixedKey = key.startsWith("_") ? key : `_${key}` as K; + return this.properties[fixedKey] as ObjProps[K]; + }); + + set = vi.fn((properties: Partial>) => { + const updatedProperties: Record = {}; + for (const key in properties) { + const fixedKey = key.startsWith("_") ? key : `_${key}`; + updatedProperties[fixedKey] = properties[key]; + } + this.properties = { ...this.properties, ...updatedProperties }; + return this; + }); + + setWithWorker = vi.fn(this.set); + + remove = vi.fn(() => { + const index = allObjects.findIndex(obj => obj.id === this.id); + if (index !== -1) { + allObjects.splice(index, 1); + } + }); +}; + +export type AnyRoll20Properties = Roll20ObjectTypeToInstance[Roll20ObjectType]["properties"]; +export type AnyRoll20Object = Roll20Object; +export type SpecificRoll20Object = Roll20Object; + +export function mockGetObj( + type: T, + id: string +): Roll20ObjectTypeToInstance[T] | undefined { + debugLog("================================="); + debugLog(`mockGetObj called with type: ${type}, id: ${id}`); + debugLog("Current allObjects:", allObjects.map(obj => obj.id)); + const found = allObjects.find(obj => { + debugLog(`Checking object: ${obj.id} of type ${obj.properties._type}`); + return obj.properties._type === type && obj.id === id; + }) as Roll20ObjectTypeToInstance[T] | undefined; + if (found) { + debugLog(`Found object: ${found.id} of type ${found.properties._type}`); + } else { + debugLog(`No matching object found: ${type}, ${id}`); + } + return found; +}; + +export function mockFindObjs( + attrs: Partial & { _type: T }, +): Roll20ObjectTypeToInstance[T][] { + debugLog("================================="); + debugLog("mockFindObjs called with attrs:", attrs); + debugLog("Current allObjects:", allObjects.map(obj => obj.id)); + const filteredObjects = allObjects.filter(obj => { + if (obj.properties._type !== attrs._type) { + return false; + } + for (const [key, value] of Object.entries(attrs)) { + const fixedKey = key.startsWith("_") ? key : `_${key}`; + if (key === "type" || key === "_type") continue; + if (!Object.hasOwn(obj.properties, fixedKey)) { + debugWarn(`Property ${fixedKey} not found on object ${obj.id}`); + return false; + } + if ((obj.properties as Record)[fixedKey] !== value) { + debugWarn(`Property ${fixedKey} on object ${obj.id} has value ${(obj.properties as Record)[fixedKey]}, expected ${value}`); + return false; + } + } + return true; + }) as unknown as Roll20ObjectTypeToInstance[T][]; + if (filteredObjects.length > 0) { + debugLog(`Found ${filteredObjects.length} matching objects:`, filteredObjects.map(o => o.id)); + } else { + debugWarn("No matching objects found"); + } + return filteredObjects; +}; + +export function mockCreateObj( + type: T, + properties: Roll20ObjectTypeToInstance[T]["properties"] +): Roll20ObjectTypeToInstance[T] { + debugLog("================================="); + debugLog("***"); + debugLog(`mockCreateObj called with type: ${type}, properties:`, properties); + const newObj = new MockObject(type, properties) as unknown as Roll20ObjectTypeToInstance[T]; + allObjects.push(newObj); + return newObj; +}; + +export function mockGetAllObjs(): Roll20Object>[] { + debugLog("================================="); + debugLog("mockGetAllObjs called"); + debugLog("Current allObjects:", allObjects.map(obj => obj.id)); + return [...allObjects]; +}; + +export function mockGetAttrByName(characterId: string, attrName: string, type: "current" | "max") { + const attrs = mockFindObjs({ _type: "attribute", _characterid: characterId, name: attrName }); + const attr = attrs.length > 0 ? attrs[0] : undefined; + if (!attr) { + return undefined; + } + if (type === "current") { + return attr.get("current"); + } else { + return attr.get("max"); + } +}; diff --git a/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts new file mode 100644 index 0000000000..cd8f31ba5c --- /dev/null +++ b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts @@ -0,0 +1,55 @@ +type MockBeaconAttribute = { + current: string; + max: string; +}; + +type MockCharacterList = { + [characterId: string]: { + [attributeName: string]: MockBeaconAttribute; + }; +}; + +export const beaconAttributes: MockCharacterList = { +}; + +export async function getSheetItem( + characterId: string, + attributeName: string, + type: "current" | "max" = "current" +) { + const character = beaconAttributes[characterId]; + if (!character) { + return undefined; + } + const attribute = character[attributeName]; + if (!attribute) { + return undefined; + } + return attribute[type]; +}; + +export async function setSheetItem( + characterId: string, + attributeName: string, + value: string, + type: "current" | "max" = "current", +): Promise { + const character = beaconAttributes[characterId]; + if (!character) { + beaconAttributes[characterId] = {}; + } + const attribute = beaconAttributes[characterId][attributeName]; + if (!attribute) { + beaconAttributes[characterId][attributeName] = { current: "", max: "" }; + } + beaconAttributes[characterId][attributeName][type] = value; + return true; +}; + +export function getBeaconAttributeNames(characterId: string): string[] { + const character = beaconAttributes[characterId]; + if (!character) { + return []; + } + return Object.keys(character); +}; diff --git a/ChatSetAttr/src/__mocks__/eventHandling.mock.ts b/ChatSetAttr/src/__mocks__/eventHandling.mock.ts new file mode 100644 index 0000000000..a4cc03bebf --- /dev/null +++ b/ChatSetAttr/src/__mocks__/eventHandling.mock.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { vi } from "vitest"; + +export type CallbackStore = { + [eventType in Roll20EventType]?: ((...args: any[]) => void)[]; +}; + +const callbacks: CallbackStore = {}; +export type ListOfEvents = `${Roll20EventType}` | `${Roll20EventType} ${Roll20EventType}`; + +export function resetAllCallbacks(): void { + for (const key in callbacks) { + delete callbacks[key as Roll20EventType]; + } +}; + +export function mockedOn( + eventName: E, + callback: (...args: any[]) => void +) { + const eventNames = eventName.split(" ") as Roll20EventType[]; + for (const name of eventNames) { + if (!callbacks[name]) { + callbacks[name] = []; + } + callbacks[name].push(callback); + } +}; + +export function mockTriggerEvent(eventName: string, response: unknown[]) { + const eventCallbacks = callbacks[eventName as Roll20EventType]; + if (eventCallbacks) { + for (const callback of eventCallbacks) { + callback(...response); + } + } + + vi.runAllTimers(); +}; + +export type SimulationMessageOptions = Partial & { inputs?: string[] }; + +export function simulateChatMessage(message: string, options?: SimulationMessageOptions) { + const { + who = "GM", + playerid = "example-player-id", + inlinerolls, + type = "api", + content = message, + origRoll = undefined, + rolltemplate = undefined, + target = undefined, + target_name = undefined, + selected = [], + inputs = [], + } = options || {}; + + // match all occurances of @{attribute_name} and replace with 10 for testing + let contentWithReplacements = content; + const attrMatches = content.match(/@{([^}]+)}/g); + attrMatches?.forEach((match) => { + const attributeName = match.slice(2, -1).replace(/(selected|target)\|/, ""); + const attributes = findObjs({ _type: "attribute", name: attributeName }); + const attribute = attributes[0]; + const value = attribute ? attribute.get("current") : "10"; + contentWithReplacements = contentWithReplacements.replace(match, value); + }); + + // match all occurrences of XdX inside [[...]] and replace with a fixed number for testing + const rollMatches = contentWithReplacements.match(/\[\[\d+d(\d+)\]\]/g); + rollMatches?.forEach((match) => { + // replace with half the die size rounded up multiplied by the number of dice + // e.g. 3d6 becomes 12 (3 * 3 + 1) + const parts = match.replace(/[[\]]/g, "").split("d"); + const numDice = parseInt(parts[0], 10); + const dieSize = parseInt(parts[1], 10); + const replacement = Math.ceil(dieSize / 2) * numDice; + contentWithReplacements = contentWithReplacements.replace(match, replacement.toString()); + }); + + // match all occurrences of ?{...} with the inputs in order + const regex = /\?\{([^}]+)\}/g; + const matches = contentWithReplacements.match(regex); + matches?.forEach((match) => { + const input = inputs.shift(); + if (!input) { + throw new Error(`No input provided for prompt: ${match}`); + } + contentWithReplacements = contentWithReplacements.replace(match, input); + }); + + // replace all occurrences of [[...]] with the evaluated result + const inlineRegex = /\[\[([^\]]+)\]\]/g; + const inlineMatches = contentWithReplacements.match(inlineRegex); + inlineMatches?.forEach((match) => { + const noBrackets = match.replace(/[[\]]/g, ""); + try { + const result = eval(noBrackets); + contentWithReplacements = contentWithReplacements.replace(match, result.toString()); + } catch { + throw new Error(`Error evaluating inline roll: ${match}`); + } + }); + + const defaultMessage: Roll20ChatMessage = { + who, + playerid, + inlinerolls, + type, + content: contentWithReplacements, + origRoll, + rolltemplate, + target, + target_name, + selected, + }; + + triggerEvent("chat:message", [defaultMessage]); +} diff --git a/ChatSetAttr/src/__mocks__/utility.mock.ts b/ChatSetAttr/src/__mocks__/utility.mock.ts new file mode 100644 index 0000000000..c07e598952 --- /dev/null +++ b/ChatSetAttr/src/__mocks__/utility.mock.ts @@ -0,0 +1,31 @@ +import { vi } from "vitest"; + +let debugMode = false; + +export function debugLog(...args: unknown[]): void { + if (debugMode) { + console.log(...args); + } +} + +export function debugWarn(...args: unknown[]): void { + if (debugMode) { + console.warn(...args); + } +} + +export function startDebugMode(): void { + debugMode = true; +} + +export function endDebugMode(): void { + debugMode = false; +} + +export function isDebugMode(): boolean { + return debugMode; +} + +export const log = vi.fn((...args: unknown[]): void => { + debugLog(...args); +}); diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts new file mode 100644 index 0000000000..40e6cf6392 --- /dev/null +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -0,0 +1,1153 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as ChatSetAttr from "../../modules/main"; +import { resetAllObjects } from "../../__mocks__/apiObjects.mock"; +import { resetAllCallbacks } from "../../__mocks__/eventHandling.mock"; +import { getBeaconAttributeNames } from "../../__mocks__/beaconAttributes.mock"; + + +describe("ChatSetAttr Integration Tests", () => { + type StateConfig = { + version: string; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; + }; + + const originalConfig: StateConfig = { + version: "1.10", + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true + }; + + // Set up the test environment before each test + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + ChatSetAttr.registerHandlers(); + global.state.ChatSetAttr = { ...originalConfig }; + }); + + // Cleanup after each test if needed + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + resetAllObjects(); + resetAllCallbacks(); + }); + + describe("Attribute Setting Commands", () => { + it("should set Strength to 15 for selected characters", async () => { + // arrange + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const charOne = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const charTwo = createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("attribute", { _id: "strengthchar1", _characterid: charOne.id, name: "Strength", current: "10" }); + createObj("attribute", { _id: "strengthchar2", _characterid: charTwo.id, name: "Strength", current: "12" }); + const tokenOne = createObj("graphic", { _id: "token1", represents: charOne.id, _subtype: "token" }); + const tokenTwo = createObj("graphic", { _id: "token2", represents: charTwo.id, _subtype: "token" }); + const selectedTokens = [tokenOne.properties, tokenTwo.properties]; + + // act + executeCommand( + "!setattr --sel --Strength|15", + { selected: selectedTokens }, + ); + + // assert + await vi.waitFor(async () => { + const charOneStrength = await libSmartAttributes.getAttribute("char1", "Strength"); + const charTwoStrength = await libSmartAttributes.getAttribute("char2", "Strength"); + + expect(charOneStrength).toBeDefined(); + expect(charOneStrength).toBe("15"); + expect(charTwoStrength).toBeDefined(); + expect(charTwoStrength).toBe("15"); + }); + }); + + it("should set HP and Dex for character named John", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "john1", name: "John", controlledby: player.id }); + createObj("character", { _id: "john2", name: "john", controlledby: player.id }); + createObj("character", { _id: "char3", name: "NotJohn", controlledby: player.id }); + + executeCommand("!setattr --name John --HP|17|27 --Dex|10"); + + await vi.waitFor(async () => { + const johnHP = await libSmartAttributes.getAttribute("john1", "HP", "current"); + const johnMaxHP = await libSmartAttributes.getAttribute("john1", "HP", "max"); + const johnDex = await libSmartAttributes.getAttribute("john1", "Dex"); + + expect(johnHP).toBeDefined(); + expect(johnHP).toBe("17"); + expect(johnMaxHP).toBeDefined(); + expect(johnMaxHP).toBe("27"); + expect(johnDex).toBeDefined(); + expect(johnDex).toBe("10"); + + const anotherJohnHP = findObjs({ _type: "attribute", _characterid: "john2", name: "HP" })[0]; + const notJohnHP = findObjs({ _type: "attribute", _characterid: "char3", name: "HP" })[0]; + expect(anotherJohnHP).toBeUndefined(); + expect(notJohnHP).toBeUndefined(); + }); + }); + + it("should set td attribute to d8 for all characters", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("character", { _id: "char3", name: "Character 3", controlledby: player.id }); + + executeCommand("!setattr --all --td|d8"); + + await vi.waitFor(async () => { + const char1TensionDie = await libSmartAttributes.getAttribute("char1", "td"); + const char2TensionDie = await libSmartAttributes.getAttribute("char2", "td"); + const char3TensionDie = await libSmartAttributes.getAttribute("char3", "td"); + + expect(char1TensionDie).toBeDefined(); + expect(char1TensionDie).toBe("d8"); + expect(char2TensionDie).toBeDefined(); + expect(char2TensionDie).toBe("d8"); + expect(char3TensionDie).toBeDefined(); + expect(char3TensionDie).toBe("d8"); + }); + }); + + it("should add a new item to a repeating inventory section", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("graphic", { _id: "token1", represents: "char1", _subtype: "token" }); + + const commandParts = [ + "!setattr", + "--sel", + "--fb-public", + "--fb-header Aquiring Magic Item", + "--fb-content The Cloak of Excellence from the chest by a character.", + "--repeating_inventory_-CREATE_itemname|Cloak of Excellence", + "--repeating_inventory_-CREATE_itemcount|1", + "--repeating_inventory_-CREATE_itemweight|3", + "--repeating_inventory_-CREATE_equipped|1", + "--repeating_inventory_-CREATE_itemmodifiers|Item Type: Wondrous item, AC +2, Saving Throws +1", + "--repeating_inventory_-CREATE_itemcontent|(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar." + ]; + const command = commandParts.join(" "); + const selected = [{ _id: "token1" } as unknown as Roll20Graphic["properties"]]; + + executeCommand(command, { selected }); + + await vi.waitFor(async () => { + expect(sendChat).toHaveBeenCalled(); + + const repeatingRowId = "-unique-rowid-1234"; + const itemName = await libSmartAttributes.getAttribute("char1", `user.repeating_inventory_${repeatingRowId}_itemname`); + const itemCount = await libSmartAttributes.getAttribute("char1", `user.repeating_inventory_${repeatingRowId}_itemcount`); + const itemWeight = await libSmartAttributes.getAttribute("char1", `user.repeating_inventory_${repeatingRowId}_itemweight`); + const itemEquipped = await libSmartAttributes.getAttribute("char1", `user.repeating_inventory_${repeatingRowId}_equipped`); + const itemModifiers = await libSmartAttributes.getAttribute("char1", `user.repeating_inventory_${repeatingRowId}_itemmodifiers`); + const itemContent = await libSmartAttributes.getAttribute("char1", "user.repeating_inventory_-unique-rowid-1234_itemcontent"); + + expect(itemName).toBe("Cloak of Excellence"); + expect(itemCount).toBe("1"); + expect(itemWeight).toBe("3"); + expect(itemEquipped).toBe("1"); + expect(itemModifiers).toBe("Item Type: Wondrous item, AC +2, Saving Throws +1"); + expect(itemContent).toBe("(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar."); + }); + }); + + it("should process inline roll queries", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --Strength|15 --Dexterity|20"); + + await vi.waitFor(async () => { + const strAttr = await libSmartAttributes.getAttribute("char1", "Strength"); + const dexAttr = await libSmartAttributes.getAttribute("char1", "Dexterity"); + + expect(strAttr).toBeDefined(); + expect(strAttr).toBe("15"); + expect(dexAttr).toBeDefined(); + expect(dexAttr).toBe("20"); + }); + }); + + it("should process an inline command within a chat message", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("I cast a spell and !setattr --charid char1 --Mana|10!!!", { type: "general" }); + + await vi.waitFor(async () => { + const manaAttr = await libSmartAttributes.getAttribute("char1", "Mana"); + + expect(manaAttr).toBeDefined(); + expect(manaAttr).toBe("10"); + }); + }); + + it("should use character IDs directly to set attributes", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + + executeCommand("!setattr --charid char1,char2 --Level|5"); + + await vi.waitFor(async () => { + const char1Level = await libSmartAttributes.getAttribute("char1", "Level"); + const char2Level = await libSmartAttributes.getAttribute("char2", "Level"); + + expect(char1Level).toBeDefined(); + expect(char1Level).toBe("5"); + expect(char2Level).toBeDefined(); + expect(char2Level).toBe("5"); + }); + }); + + it("should set multiple attributes on multiple characters", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + + executeCommand("!setattr --charid char1,char2 --Class|Fighter --Level|5 --HP|30|30"); + + await vi.waitFor(async () => { + const char1Class = await libSmartAttributes.getAttribute("char1", "Class"); + const char1Level = await libSmartAttributes.getAttribute("char1", "Level"); + const char1HP = await libSmartAttributes.getAttribute("char1", "HP"); + const char1HPMax = await libSmartAttributes.getAttribute("char1", "HP", "max"); + + expect(char1Class).toBeDefined(); + expect(char1Class).toBe("Fighter"); + expect(char1Level).toBeDefined(); + expect(char1Level).toBe("5"); + expect(char1HP).toBeDefined(); + expect(char1HP).toBe("30"); + expect(char1HPMax).toBeDefined(); + expect(char1HPMax).toBe("30"); + + const char2Class = await libSmartAttributes.getAttribute("char2", "Class"); + const char2Level = await libSmartAttributes.getAttribute("char2", "Level"); + const char2HP = await libSmartAttributes.getAttribute("char2", "HP"); + const char2HPMax = await libSmartAttributes.getAttribute("char2", "HP", "max"); + + expect(char2Class).toBeDefined(); + expect(char2Class).toBe("Fighter"); + expect(char2Level).toBeDefined(); + expect(char2Level).toBe("5"); + expect(char2HP).toBeDefined(); + expect(char2HP).toBe("30"); + expect(char2HPMax).toBeDefined(); + expect(char2HPMax).toBe("30"); + }); + }); + }); + + describe("Attribute Modification Commands", () => { + it("should increase Strength by 5 for selected characters", async () => { + // This is failing because we're not currently outputting to chat + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("character", { _id: "char3", name: "Character 3", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "Strength", current: "10" }); + createObj("attribute", { _characterid: "char2", name: "Strength", current: "15" }); + createObj("attribute", { _characterid: "char3", name: "Strength", current: "Very big" }); + const token1 = createObj("graphic", { _id: "token1", represents: "char1", _subtype: "token" }); + const token2 = createObj("graphic", { _id: "token2", represents: "char2", _subtype: "token" }); + const token3 = createObj("graphic", { _id: "token3", represents: "char3", _subtype: "token" }); + + executeCommand("!setattr --sel --mod --Strength|5", { selected: [token1.properties, token2.properties, token3.properties] }); + + await vi.waitFor(async () => { + const char1Strength = await libSmartAttributes.getAttribute("char1", "Strength"); + const char2Strength = await libSmartAttributes.getAttribute("char2", "Strength"); + const char3Strength = await libSmartAttributes.getAttribute("char3", "Strength"); + + expect(char1Strength).toBeDefined(); + expect(char1Strength).toBe(15); + expect(char2Strength).toBeDefined(); + expect(char2Strength).toBe(20); + expect(char3Strength).toBeDefined(); + expect(char3Strength).toBe("Very big"); + + expect(sendChat).toHaveBeenCalled(); + const mockCalls = vi.mocked(sendChat).mock.calls; + const errorCall = mockCalls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("is not number-valued") + ); + expect(errorCall).toBeDefined(); + }); + }); + + it("should handle --mod option for modifying attributes", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "Counter", current: "5" }); + createObj("attribute", { _characterid: "char1", name: "CounterMax", current: "3", max: "10" }); + + executeCommand("!modattr --charid char1 --Counter|2 --CounterMax|1|2"); + + await vi.waitFor(async () => { + const counter = await libSmartAttributes.getAttribute("char1", "Counter"); + const counterMax = await libSmartAttributes.getAttribute("char1", "CounterMax"); + const counterMaxMax = await libSmartAttributes.getAttribute("char1", "CounterMax", "max"); + + expect(counter).toBeDefined(); + expect(counter).toBe(7); + expect(counterMax).toBeDefined(); + expect(counterMax).toBe(4); + expect(counterMaxMax).toBeDefined(); + expect(counterMaxMax).toBe(12); + }); + }); + + it("should modify attributes using the !mod command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "30" }); + + executeCommand("!modattr --charid char1 --HP|5 --MP|-3"); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "HP"); + const mp = await libSmartAttributes.getAttribute("char1", "MP"); + + expect(hp).toBeDefined(); + expect(hp).toBe(15); + expect(mp).toBeDefined(); + expect(mp).toBe(12); + }); + }); + + it("should modify attributes with bounds using modbattr", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "Stamina", current: "1", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|5 --Stamina|-5"); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "HP"); + const mp = await libSmartAttributes.getAttribute("char1", "MP"); + const stamina = await libSmartAttributes.getAttribute("char1", "Stamina"); + + expect(hp).toBeDefined(); + expect(hp).toBe(15); + expect(mp).toBeDefined(); + expect(mp).toBe(15); + expect(stamina).toBeDefined(); + expect(stamina).toBe(0); + }); + }); + + it("should modify attributes with bounds using the !modb command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "10" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "8", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|-10"); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "HP"); + const mp = await libSmartAttributes.getAttribute("char1", "MP"); + + expect(hp).toBeDefined(); + expect(hp).toBe(10); + expect(mp).toBeDefined(); + expect(mp).toBe(0); + }); + }); + }); + + describe("Attribute Deletion and Reset Commands", () => { + it("should delete the gold attribute from all characters", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "gold", current: "100" }); + createObj("attribute", { _characterid: "char2", name: "gold", current: "200" }); + createObj("attribute", { _characterid: "char1", name: "silver", current: "50" }); + + executeCommand("!delattr --all --gold"); + + await vi.waitFor(async () => { + const char1Gold = await libSmartAttributes.getAttribute("char1", "gold"); + const char2Gold = await libSmartAttributes.getAttribute("char2", "gold"); + const char1Silver = await libSmartAttributes.getAttribute("char1", "silver"); + + expect(char1Gold).toBeUndefined(); + expect(char2Gold).toBeUndefined(); + expect(char1Silver).toBeDefined(); + expect(char1Silver).toBe("50"); + }); + }); + + it("should reset Ammo to its maximum value", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "Ammo", current: "3", max: "20" }); + const token1 = createObj("graphic", { _id: "token1", represents: "char1", _subtype: "token" }); + + executeCommand("!setattr --sel --Ammo|%Ammo_max%", { selected: [token1.properties] }); + + await vi.waitFor(async () => { + const ammo = await libSmartAttributes.getAttribute("char1", "Ammo"); + expect(ammo).toBeDefined(); + expect(ammo).toBe("20"); + }); + }); + + it("should reset attributes to their maximum values with resetattr", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "5", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100", max: "" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "HP"); + const mp = await libSmartAttributes.getAttribute("char1", "MP"); + const xp = await libSmartAttributes.getAttribute("char1", "XP"); + + expect(hp).toBeDefined(); + expect(hp).toBe(20); + expect(mp).toBeDefined(); + expect(mp).toBe(15); + expect(xp).toBeDefined(); + expect(xp).toBe("100"); + }); + }); + + it("should reset attributes using the !reset command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "10", max: "30" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "HP"); + const mp = await libSmartAttributes.getAttribute("char1", "MP"); + const xp = await libSmartAttributes.getAttribute("char1", "XP"); + + expect(hp).toBeDefined(); + expect(hp).toBe(20); + expect(mp).toBeDefined(); + expect(mp).toBe(30); + expect(xp).toBeDefined(); + expect(xp).toBe("100"); + }); + }); + + it("should delete attributes using the !del command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "ToDelete1", current: "10" }); + createObj("attribute", { _characterid: "char1", name: "ToDelete2", current: "20" }); + createObj("attribute", { _characterid: "char1", name: "ToKeep", current: "30" }); + + executeCommand("!delattr --charid char1 --ToDelete1 --ToDelete2"); + + await vi.waitFor(async () => { + const toDelete1 = await libSmartAttributes.getAttribute("char1", "ToDelete1"); + const toDelete2 = await libSmartAttributes.getAttribute("char1", "ToDelete2"); + const toKeep = await libSmartAttributes.getAttribute("char1", "ToKeep"); + + expect(toDelete1).toBeUndefined(); + expect(toDelete2).toBeUndefined(); + expect(toKeep).toBeDefined(); + expect(toKeep).toBe("30"); + }); + }); + }); + + describe("Targeting Options", () => { + it("should set attributes for GM-only characters with allgm targeting mode", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + const gmCharOne = createObj("character", { _id: "gmchar1", name: "GM Character 1", controlledby: "" }); + const gmCharTwo = createObj("character", { _id: "gmchar2", name: "GM Character 2", controlledby: "" }); + const playerChar = createObj("character", { _id: "playerchar", name: "Player Character", controlledby: player.id }); + + executeCommand("!setattr --allgm --Status|NPC"); + + await vi.waitFor(async () => { + const gmChar1Status = await libSmartAttributes.getAttribute(gmCharOne.id, "Status"); + const gmChar2Status = await libSmartAttributes.getAttribute(gmCharTwo.id, "Status"); + const playerCharStatus = await libSmartAttributes.getAttribute(playerChar.id, "Status"); + + expect(gmChar1Status).toBeDefined(); + expect(gmChar1Status).toBe("NPC"); + + expect(gmChar2Status).toBeDefined(); + expect(gmChar2Status).toBe("NPC"); + + expect(playerCharStatus).toBeUndefined(); + }); + }); + + it("should set attributes for player-controlled characters with allplayers targeting mode", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + createObj("character", { _id: "playerchar1", name: "Player Character 1", controlledby: player.id }); + createObj("character", { _id: "playerchar2", name: "Player Character 2", controlledby: player.id }); + createObj("character", { _id: "gmchar", name: "GM Character", controlledby: "" }); + + executeCommand("!setattr --allplayers --CharType|PC"); + + await vi.waitFor(async () => { + const playerChar1Type = await libSmartAttributes.getAttribute("playerchar1", "CharType"); + const playerChar2Type = await libSmartAttributes.getAttribute("playerchar2", "CharType"); + const gmCharType = await libSmartAttributes.getAttribute("gmchar", "CharType"); + + expect(playerChar1Type).toBeDefined(); + expect(playerChar1Type).toBe("PC"); + + expect(playerChar2Type).toBeDefined(); + expect(playerChar2Type).toBe("PC"); + + expect(gmCharType).toBeUndefined(); + }); + }); + }); + + describe("Attribute Value Processing", () => { + it("should evaluate expressions using attribute references", async () => { + vi.mocked(playerIsGM).mockReturnValue(true); + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const char = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: char.id, name: "attr1", current: "3" }); + createObj("attribute", { _characterid: char.id, name: "attr2", current: "2" }); + const token1 = createObj("graphic", { _id: "token1", represents: char.id, _subtype: "token" }); + + executeCommand("!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%", { selected: [token1.properties] }); + + await vi.waitFor(async () => { + const attr3 = await libSmartAttributes.getAttribute("char1", "attr3"); + expect(attr3).toBeDefined(); + expect(attr3).toBe(11); + }); + }); + + it("should handle --replace option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --replace --charid char1 --Description|This text has characters; and should be `replaced`"); + + await vi.waitFor(async () => { + const desc = await libSmartAttributes.getAttribute("char1", "Description"); + expect(desc).toBeDefined(); + expect(desc).toBe("This text has [special] characters? and should be @replaced@"); + }); + }); + + it("should honor multiple modifier flags used together", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "ExistingAttr", current: "10" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --evaluate --ExistingAttr|20*2"); + + await vi.waitFor(async () => { + const existingAttr = await libSmartAttributes.getAttribute("char1", "ExistingAttr"); + expect(existingAttr).toBeDefined(); + expect(existingAttr).toBe(40); + + expect(sendChat).not.toHaveBeenCalled(); + }); + }); + }); + + describe("Configuration Options", () => { + it("should handle configuration commands", async () => { + global.state.ChatSetAttr.config = { + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true + }; + vi.mocked(global.playerIsGM).mockReturnValue(true); + global.createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + + executeCommand("!setattr-config --players-can-modify", { playerid: "example-player-id" }); + expect(global.state.ChatSetAttr.playersCanModify).toBeFalsy(); + expect(sendChat).toHaveBeenCalledTimes(1); + + executeCommand("!setattr-config --players-can-evaluate", { playerid: "example-player-id" }); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBeFalsy(); + expect(sendChat).toHaveBeenCalledTimes(2); + + executeCommand("!setattr-config --use-workers", { playerid: "example-player-id" }); + expect(global.state.ChatSetAttr.useWorkers).toBeFalsy(); + expect(sendChat).toHaveBeenCalledTimes(3); + }); + + it("should respect player permissions", async () => { + createObj("player", { _id: "player123", _displayname: "Regular Player" }); + createObj("player", { _id: "differentPlayer456", _displayname: "Another Player" }); + createObj("character", { _id: "char1", name: "Player Character", controlledby: "player123" }); + + const state = global.state as { ChatSetAttr: StateConfig }; + const originalConfig = state.ChatSetAttr.playersCanModify; + state.ChatSetAttr.playersCanModify = false; + + const originalPlayerIsGM = global.playerIsGM; + global.playerIsGM = vi.fn(() => false); + + executeCommand("!setattr --charid char1 --Strength|18", { playerid: "differentPlayer456" }); + + await vi.waitFor(() => { + const strength = findObjs({ _type: "attribute", _characterid: "char1", name: "Strength" })[0]; + expect(strength).toBeUndefined(); + + expect(sendChat).toHaveBeenCalled(); + const errorCalls = vi.mocked(sendChat).mock.calls; + const errorCall = errorCalls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Permission error") + ); + expect(errorCall).toBeDefined(); + }); + + state.ChatSetAttr.playersCanModify = originalConfig; + global.playerIsGM = originalPlayerIsGM; + }); + }); + + describe("Feedback Options", () => { + it("should send public feedback with --fb-public option", async () => { + vi.mocked(global.playerIsGM).mockReturnValue(true); + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-public --Attribute|42"); + + await vi.waitFor(async () => { + const attr = await libSmartAttributes.getAttribute("char1", "Attribute"); + expect(attr).toBeDefined(); + expect(attr).toBe("42"); + + const mockCalls = vi.mocked(sendChat).mock.calls; + const feedbackCalls = mockCalls.filter(call => { + const message = call[1]; + const messageIsString = typeof message === "string"; + const messageIsWhisper = message.startsWith("/w "); + const messageIncludesFeedback = message.includes("Setting Attribute"); + + return messageIsString && messageIsWhisper && messageIncludesFeedback; + }); + + expect(feedbackCalls.length).toBeGreaterThan(0); + }); + }); + + it("should use custom sender with --fb-from option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-from Wizard --Spell|Fireball"); + + await vi.waitFor(async () => { + const attr = await libSmartAttributes.getAttribute("char1", "Spell"); + expect(attr).toBeDefined(); + expect(attr).toBe("Fireball"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => { + const senderIsWizard = call[0] === "Wizard"; + const message = call[1]; + const messageIsString = typeof message === "string"; + const messageIncludesFeedback = message.includes("Set attribute 'Spell'"); + return senderIsWizard && messageIsString && messageIncludesFeedback; + }); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should use custom header with --fb-header option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-header Magic Item Acquired --Item|Staff of Power"); + + await vi.waitFor(async () => { + const attr = await libSmartAttributes.getAttribute("char1", "Item"); + expect(attr).toBeDefined(); + expect(attr).toBe("Staff of Power"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Magic Item Acquired") && + !call[1].includes("Setting Attributes") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should use custom content with --fb-content option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-header \"Level Up\" --fb-content \"_CHARNAME_ is now level _CUR0_!\" --Level|5"); + + await vi.waitFor(async () => { + const attr = await libSmartAttributes.getAttribute("char1", "Level"); + expect(attr).toBeDefined(); + expect(attr).toBe("5"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => { + const isString = call[1] && typeof call[1] === "string"; + const includesFeedback = call[1].includes("Character 1 is now level 5!"); + return isString && includesFeedback; + }); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should combine all feedback options together", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char-unique-feedback", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "hp-feedback-attr", _characterid: character.id, name: "HP", current: "10" }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + const selectedTokens = [token.properties]; + + const callParts = [ + "!setattr", + "--sel", + "--fb-public", + "--fb-from Dungeon_Master", + "--fb-header \"Combat Stats Updated\"", + "--fb-content \"_CHARNAME_'s health increased to _CUR0_!\"", + "--HP|25" + ]; + + executeCommand(callParts.join(" "), { selected: selectedTokens }); + + await vi.waitFor(async () => { + const attr = await libSmartAttributes.getAttribute("char-unique-feedback", "HP"); + expect(attr).toBeDefined(); + expect(attr).toBe("25"); + + // Verify that sendChat was called (feedback message sent) + expect(sendChat).toHaveBeenCalled(); + }); + }); + }); + + describe("Message Suppression Options", () => { + it("should suppress feedback messages when using the --silent option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --TestAttr|42"); + + await vi.waitFor(async () => { + const testAttr = await libSmartAttributes.getAttribute("char1", "TestAttr"); + expect(testAttr).toBeDefined(); + expect(testAttr).toBe("42"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Setting TestAttr") + ); + expect(feedbackCall).toBeUndefined(); + }); + }); + + it("should suppress error messages when using the --mute option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --mute --mod --NonNumeric|abc --Value|5"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Error") + ); + expect(errorCall).toBeUndefined(); + }); + }); + + it("should not create attributes when using the --nocreate option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --nocreate --NewAttribute|50"); + + await vi.waitFor(async () => { + const newAttr = await libSmartAttributes.getAttribute("char1", "NewAttribute"); + expect(newAttr).toBeUndefined(); + + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Missing attribute") && + call[1].includes("not created") + ); + expect(errorCall).toBeDefined(); + }); + }); + }); + + describe("Observer Events", () => { + it("should observe attribute additions with registered observers", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("add", mockObserver); + + executeCommand("!setattr --charid char1 --NewAttribute|42"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + const calls = mockObserver.mock.calls; + const firstCall = calls[0]; + expect(firstCall).toStrictEqual([ + "add", + "char1", + "NewAttribute", + "42", + undefined + ]); + }); + }); + + it("should observe attribute changes with registered observers", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ExistingAttr", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("change", mockObserver); + + executeCommand("!setattr --charid char1 --ExistingAttr|20"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + const calls = mockObserver.mock.calls; + const firstCall = calls[0]; + expect(firstCall).toStrictEqual([ + "change", + "char1", + "ExistingAttr", + "20", + "10", + ]); + }); + }); + + it("should observe attribute deletions with registered observers", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "DeleteMe", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("destroy", mockObserver); + + executeCommand("!delattr --charid char1 --DeleteMe"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + const calls = mockObserver.mock.calls; + const firstCall = calls[0]; + expect(firstCall).toStrictEqual([ + "destroy", + "char1", + "DeleteMe", + undefined, + "10", + ]); + }); + }); + }); + + describe("Repeating Sections", () => { + it("should create repeating section attributes", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --repeating_weapons_-CREATE_weaponname|Longsword --repeating_weapons_-CREATE_damage|1d8"); + + await vi.waitFor(async () => { + const repeatingAttrs = getBeaconAttributeNames("char1"); + const weaponNameAttrs = repeatingAttrs.filter(name => name.endsWith("_weaponname")); + const firstRow = weaponNameAttrs[0]; + const [ , , rowID ] = firstRow.split("_"); + + const weaponName = await libSmartAttributes.getAttribute("char1", `repeating_weapons_${rowID}_weaponname`); + const weaponDamage = await libSmartAttributes.getAttribute("char1", `repeating_weapons_${rowID}_damage`); + + expect(weaponName).toBeDefined(); + expect(weaponName).toBe("Longsword"); + expect(weaponDamage).toBeDefined(); + expect(weaponDamage).toBe("1d8"); + }); + }); + + it("should adjust number of uses remaining for an ability", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "repeating_ability_-exampleid_used", current: "3" }); + const token = createObj("graphic", { _id: "token1", represents: character.id }); + const selected = [token.properties]; + + const commandParts = [ + "!setattr", + "--charid char1", + "--repeating_ability_-exampleid_used|[[?{How many are left?|0}]]" + ]; + executeCommand(commandParts.join(" "), { + selected, + inputs: ["2"], + }); + + await vi.waitFor(async () => { + const usedAttr = await libSmartAttributes.getAttribute("char1", "repeating_ability_-exampleid_used"); + expect(usedAttr).toBeDefined(); + expect(usedAttr).toBe("2"); + }); + }); + + it("should toggle a buff on or off", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "attr1", _characterid: character.id, name: "repeating_buff2_-example_enable_toggle", current: "0" }); + const token = createObj("graphic", { _id: "token1", represents: "char1", _subtype: "token" }); + const selected = [token.properties]; + + executeCommand("!setattr --sel --repeating_buff2_-example_enable_toggle|[[1-@{selected|repeating_buff2_-example_enable_toggle}]]", { + selected, + }); + + await vi.waitFor(async () => { + const toggleAttr = await libSmartAttributes.getAttribute("char1", "repeating_buff2_-example_enable_toggle"); + expect(toggleAttr).toBeDefined(); + expect(toggleAttr).toBe("1"); + }); + + executeCommand("!setattr --sel --repeating_buff2_-example_enable_toggle|[[1-@{selected|repeating_buff2_-example_enable_toggle}]]", { + selected, + }); + + await vi.waitFor(async () => { + const toggleAttr = await libSmartAttributes.getAttribute("char1", "repeating_buff2_-example_enable_toggle"); + expect(toggleAttr).toBeDefined(); + expect(toggleAttr).toBe("0"); + }); + }); + + const createRepeatingObjects = () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + + const firstWeaponNameAttr = createObj("attribute", { + _id: "attr1", + _characterid: character.id, + name: "repeating_weapons_-abc123_weaponname", + current: "Longsword" + }); + const firstWeaponDamageAttr = createObj("attribute", { + _id: "attr2", + _characterid: character.id, + name: "repeating_weapons_-abc123_damage", + current: "1d8" + }); + + const secondWeaponNameAttr = createObj("attribute", { + _id: "attr3", + _characterid: character.id, + name: "repeating_weapons_-def456_weaponname", + current: "Dagger" + }); + const secondWeaponDamageAttr = createObj("attribute", { + _id: "attr4", + _characterid: character.id, + name: "repeating_weapons_-def456_damage", + current: "1d4" + }); + + const thirdWeaponNameAttr = createObj("attribute", { + _id: "attr5", + _characterid: character.id, + name: "repeating_weapons_-ghi789_weaponname", + current: "Bow" + }); + const thirdWeaponDamageAttr = createObj("attribute", { + _id: "attr6", + _characterid: character.id, + name: "repeating_weapons_-ghi789_damage", + current: "1d6" + }); + + const reporder = createObj("attribute", { + _id: "attr7", + _characterid: character.id, + name: "_reporder_" + "repeating_weapons", + current: "-abc123,-def456,-ghi789" + }); + + return { + player, + character, + firstWeaponNameAttr, + firstWeaponDamageAttr, + secondWeaponNameAttr, + secondWeaponDamageAttr, + thirdWeaponNameAttr, + thirdWeaponDamageAttr, + reporder, + token + }; + }; + + + it("should handle deleting repeating section attributes referenced by index", async () => { + // arrange + const { token, firstWeaponNameAttr, secondWeaponNameAttr, thirdWeaponNameAttr } = createRepeatingObjects(); + const selected = [token.properties]; + + // act + executeCommand("!delattr --sel --repeating_weapons_$1_weaponname", { selected }); + + // assert + await vi.waitFor(() => { + expect(firstWeaponNameAttr.remove).not.toHaveBeenCalled(); + + // Second weapon (Dagger) should be deleted + expect(secondWeaponNameAttr.remove).toHaveBeenCalled(); + + // Third weapon should still exist + expect(thirdWeaponNameAttr.remove).not.toHaveBeenCalled(); + }); + }); + + it("should handle modifying repeating section attributes referenced by index", async () => { + // arrange + const { token } = createRepeatingObjects(); + const selected = [token.properties]; + + // act - Modify the damage of the first weapon ($0 index) + executeCommand( + "!setattr --sel --nocreate --repeating_weapons_$0_damage|2d8", + { selected } + ); + + // Wait for the operation to complete + await vi.waitFor(async () => { + // assert - First weapon damage should be updated + const firstWeaponDamage = await libSmartAttributes.getAttribute("char1", "repeating_weapons_-abc123_damage"); + const secondWeaponDamage = await libSmartAttributes.getAttribute("char1", "repeating_weapons_-def456_damage"); + + expect(firstWeaponDamage).toBeDefined(); + expect(firstWeaponDamage).toBe("2d8"); + expect(secondWeaponDamage).toBeDefined(); + expect(secondWeaponDamage).toBe("1d4"); + }); + }); + + it("should handle creating new repeating section attributes after deletion", async () => { + // arrange - Create initial repeating section attributes + const { token } = createRepeatingObjects(); + + // act - Create a new attribute in the last weapon ($1 index after deletion) + const selected = [token.properties]; + executeCommand( + "!setattr --sel --repeating_weapons_$1_newlycreated|5", + { selected } + ); + + // Wait for the operation to complete + await vi.waitFor(async () => { + const newlyCreated = await libSmartAttributes.getAttribute("char1", "repeating_weapons_-def456_newlycreated"); + expect(newlyCreated).toBeDefined(); + expect(newlyCreated).toBe("5"); + }); + }); + }); + + describe("Delayed Processing", () => { + it.skip("should process characters sequentially with delays", async () => { + // Don't want this to happen in the new script + + vi.useFakeTimers(); + + // Create multiple characters + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("character", { _id: "char3", name: "Character 3", controlledby: player.id }); + + // Set up spy on setTimeout to track when it's called + const setTimeoutSpy = vi.spyOn(global, "setTimeout"); + + // Execute a command that sets attributes on all three characters + executeCommand("!setattr --charid char1,char2,char3 --TestAttr|42"); + vi.runAllTimers(); + + // all three characters should eventually get their attributes + await vi.waitFor(async () => { + const char1Attr = await libSmartAttributes.getAttribute("char1", "TestAttr"); + const char2Attr = await libSmartAttributes.getAttribute("char2", "TestAttr"); + const char3Attr = await libSmartAttributes.getAttribute("char3", "TestAttr"); + + expect(char1Attr).toBeDefined(); + expect(char1Attr).toBe("42"); + expect(char2Attr).toBeDefined(); + expect(char2Attr).toBe("42"); + expect(char3Attr).toBeDefined(); + expect(char3Attr).toBe("42"); + }); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(3); + + // Verify the specific parameters of setTimeout calls + const timeoutCalls = setTimeoutSpy.mock.calls.filter( + call => typeof call[0] === "function" && call[1] === 50 + ); + expect(timeoutCalls.length).toBe(2); + }); + + it("should notify about delays when processing characters", async () => { + vi.useFakeTimers(); + vi.mocked(global.playerIsGM).mockReturnValue(true); + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + for (let i = 1; i <= 50; i++) { + createObj("character", { _id: `char${i}`, name: `Character ${i}`, controlledby: player.id }); + } + // Execute a command that sets attributes on multiple characters + executeCommand("!setattr --all --TestAttr|42"); + + // Wait for the notification to be called + vi.runAllTimers(); + await vi.waitFor(() => { + expect(sendChat).toBeCalledTimes(2); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/long time to execute/g), + undefined, + expect.objectContaining({ + noarchive: true, + }) + ); + }); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/legacy/ChatSetAttr.d.ts b/ChatSetAttr/src/__tests__/legacy/ChatSetAttr.d.ts new file mode 100644 index 0000000000..07d921eaf3 --- /dev/null +++ b/ChatSetAttr/src/__tests__/legacy/ChatSetAttr.d.ts @@ -0,0 +1,125 @@ +declare interface StateConfig { + version: number; + globalconfigCache: { + lastsaved: number; + }; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; +} + +declare interface MockAttribute { + id: string; + _type: string; + _characterid: string; + name: string; + current: string; + max?: string; + get(property: string): string; + set(values: Record): void; + setWithWorker(values: Record): void; + remove(): void; +} + +declare interface ObserverFunction { + (attribute: MockAttribute, previousValues?: Record): void; +} + +declare interface RepeatingData { + regExp: RegExp[]; + toCreate: string[]; + sections: string[]; +} + +declare interface AttributeValue { + current?: string; + max?: string; + fillin?: boolean; + repeating: any | false; +} + +declare interface CommandOptions { + all?: boolean; + allgm?: boolean; + charid?: string; + name?: string; + allplayers?: boolean; + sel?: boolean; + deletemode?: boolean; + replace?: boolean; + nocreate?: boolean; + mod?: boolean; + modb?: boolean; + evaluate?: boolean; + silent?: boolean; + reset?: boolean; + mute?: boolean; + "fb-header"?: string; + "fb-content"?: string; + "fb-from"?: string; + "fb-public"?: boolean; + [key: string]: any; +} + +declare const ChatSetAttr: { + /** + * Checks if the script is properly installed and updates state if needed + */ + checkInstall(): void; + + /** + * Registers an observer function for attribute events + * @param event Event type: "add", "change", or "destroy" + * @param observer Function to call when event occurs + */ + registerObserver(event: "add" | "change" | "destroy", observer: ObserverFunction): void; + + /** + * Registers event handlers for the module + */ + registerEventHandlers(): void; + + /** + * Testing methods - only available for internal use + */ + testing: { + isDef(value: any): boolean; + getWhisperPrefix(playerid: string): string; + sendChatMessage(msg: string, from?: string): void; + setAttribute(attr: MockAttribute, value: Record): void; + handleErrors(whisper: string, errors: string[]): void; + showConfig(whisper: string): void; + getConfigOptionText(o: { name: string; command: string; desc: string }): string; + getCharNameById(id: string): string; + escapeRegExp(str: string): string; + htmlReplace(str: string): string; + processInlinerolls(msg: { content: string; inlinerolls?: any[] }): string; + notifyAboutDelay(whisper: string): number; + getCIKey(obj: Record, name: string): string | false; + generatelibUUID(): string; + generateRowID(): string; + delayedGetAndSetAttributes(whisper: string, list: string[], setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): void; + setCharAttributes(charid: string, setting: Record, errors: string[], feedback: string[], attrs: Record, opts: CommandOptions): void; + fillInAttrValues(charid: string, expression: string): string; + getCharAttributes(charid: string, setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): Record; + getCharStandardAttributes(charid: string, attrNames: string[], errors: string[], opts: CommandOptions): Record; + getCharRepeatingAttributes(charid: string, setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): Record; + delayedDeleteAttributes(whisper: string, list: string[], setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): void; + deleteCharAttributes(charid: string, attrs: Record, feedback: Record): void; + parseOpts(content: string, hasValue: string[]): CommandOptions; + parseAttributes(args: string[], opts: CommandOptions, errors: string[]): [Record, RepeatingData]; + getRepeatingData(name: string, globalData: any, opts: CommandOptions, errors: string[]): any | null; + checkPermissions(list: string[], errors: string[], playerid: string, isGM: boolean): string[]; + getIDsFromTokens(selected: any[] | undefined): string[]; + getIDsFromNames(charNames: string, errors: string[]): string[]; + sendFeedback(whisper: string, feedback: string[], opts: CommandOptions): void; + sendDeleteFeedback(whisper: string, feedback: Record, opts: CommandOptions): void; + handleCommand(content: string, playerid: string, selected: any[] | undefined, pre: string): void; + handleInlineCommand(msg: { content: string; playerid: string; selected?: any[]; inlinerolls?: any[] }): void; + handleInput(msg: { type: string; content: string; playerid: string; selected?: any[]; inlinerolls?: any[] }): void; + notifyObservers(event: "add" | "change" | "destroy", obj: MockAttribute, prev?: Record): void; + checkGlobalConfig(): void; + }; +}; + +export default ChatSetAttr; diff --git a/ChatSetAttr/src/__tests__/legacy/ChatSetAttr.js b/ChatSetAttr/src/__tests__/legacy/ChatSetAttr.js new file mode 100644 index 0000000000..705f529ed0 --- /dev/null +++ b/ChatSetAttr/src/__tests__/legacy/ChatSetAttr.js @@ -0,0 +1,824 @@ +// ChatSetAttr version 1.10 +// Last Updated: 2020-09-03 +// A script to create, modify, or delete character attributes from the chat area or macros. +// If you don't like my choices for --replace, you can edit the replacers variable at your own peril to change them. + +/* global log, state, globalconfig, getObj, sendChat, _, getAttrByName, findObjs, createObj, playerIsGM, on */ +var ChatSetAttr = (function () { + "use strict"; + const version = "1.10", + observers = { + "add": [], + "change": [], + "destroy": [] + }, + schemaVersion = 3, + replacers = [ + [//g, "]"], + [/\\rbrak/g, "]"], + [/;/g, "?"], + [/\\ques/g, "?"], + [/`/g, "@"], + [/\\at/g, "@"], + [/~/g, "-"], + [/\\n/g, "\n"], + ], + // Basic Setup + checkInstall = function () { + log(`-=> ChatSetAttr v${version} <=-`); + if (!state.ChatSetAttr || state.ChatSetAttr.version !== schemaVersion) { + log(` > Updating ChatSetAttr Schema to v${schemaVersion} <`); + state.ChatSetAttr = { + version: schemaVersion, + globalconfigCache: { + lastsaved: 0 + }, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true + }; + } + checkGlobalConfig(); + }, + checkGlobalConfig = function () { + const s = state.ChatSetAttr, + g = globalconfig && globalconfig.chatsetattr; + if (g && g.lastsaved && g.lastsaved > s.globalconfigCache.lastsaved) { + log(" > Updating ChatSetAttr from Global Config < [" + + (new Date(g.lastsaved * 1000)) + "]"); + s.playersCanModify = "playersCanModify" === g["Players can modify all characters"]; + s.playersCanEvaluate = "playersCanEvaluate" === g["Players can use --evaluate"]; + s.useWorkers = "useWorkers" === g["Trigger sheet workers when setting attributes"]; + s.globalconfigCache = globalconfig.chatsetattr; + } + }, + // Utility functions + isDef = function (value) { + return value !== undefined; + }, + getWhisperPrefix = function (playerid) { + const player = getObj("player", playerid); + if (player && player.get("_displayname")) { + return "/w \"" + player.get("_displayname") + "\" "; + } else { + return "/w GM "; + } + }, + sendChatMessage = function (msg, from) { + if (from === undefined) from = "ChatSetAttr"; + sendChat(from, msg, null, { + noarchive: true + }); + }, + setAttribute = function (attr, value) { + if (state.ChatSetAttr.useWorkers) attr.setWithWorker(value); + else attr.set(value); + }, + handleErrors = function (whisper, errors) { + if (errors.length) { + const output = whisper + + "
" + + "

Errors

" + + `

${errors.join("
")}

` + + "
"; + sendChatMessage(output); + errors.splice(0, errors.length); + } + }, + showConfig = function (whisper) { + const optionsText = [{ + name: "playersCanModify", + command: "players-can-modify", + desc: "Determines if players can use --name and --charid to " + + "change attributes of characters they do not control." + }, { + name: "playersCanEvaluate", + command: "players-can-evaluate", + desc: "Determines if players can use the --evaluate option. " + + "Be careful in giving players access to this option, because " + + "it potentially gives players access to your full API sandbox." + }, { + name: "useWorkers", + command: "use-workers", + desc: "Determines if setting attributes should trigger sheet worker operations." + }].map(getConfigOptionText).join(""), + output = whisper + "
ChatSetAttr Configuration
" + + "

!setattr-config can be invoked in the following format:

!setattr-config --option
" + + "

Specifying an option toggles the current setting. There are currently two" + + " configuration options:

" + optionsText + "
"; + sendChatMessage(output); + }, + getConfigOptionText = function (o) { + const button = state.ChatSetAttr[o.name] ? + "ON" : + "OFF"; + return "
    " + + "
  • " + + "
    ${button}
    ` + + `${o.command}${htmlReplace("-")}` + + `${o.desc}
${o.name} is currently ${button}` + + `Toggle
`; + }, + getCharNameById = function (id) { + const character = getObj("character", id); + return (character) ? character.get("name") : ""; + }, + escapeRegExp = function (str) { + return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); + }, + htmlReplace = function (str) { + const entities = { + "<": "lt", + ">": "gt", + "'": "#39", + "*": "#42", + "@": "#64", + "{": "#123", + "|": "#124", + "}": "#125", + "[": "#91", + "]": "#93", + "_": "#95", + "\"": "quot" + }; + return String(str).split("").map(c => (entities[c]) ? ("&" + entities[c] + ";") : c).join(""); + }, + processInlinerolls = function (msg) { + if (msg.inlinerolls && msg.inlinerolls.length) { + return msg.inlinerolls.map(v => { + const ti = v.results.rolls.filter(v2 => v2.table) + .map(v2 => v2.results.map(v3 => v3.tableItem.name).join(", ")) + .join(", "); + return (ti.length && ti) || v.results.total || 0; + }) + .reduce((m, v, k) => m.replace(`$[[${k}]]`, v), msg.content); + } else { + return msg.content; + } + }, + notifyAboutDelay = function (whisper) { + const chatFunction = () => sendChatMessage(whisper + "Your command is taking a " + + "long time to execute. Please be patient, the process will finish eventually."); + return setTimeout(chatFunction, 8000); + }, + getCIKey = function (obj, name) { + const nameLower = name.toLowerCase(); + let result = false; + Object.entries(obj).forEach(([k, ]) => { + if (k.toLowerCase() === nameLower) { + result = k; + } + }); + return result; + }, + generateUUID = function () { + var a = 0, + b = []; + return function () { + var c = (new Date()).getTime() + 0, + d = c === a; + a = c; + for (var e = new Array(8), f = 7; 0 <= f; f--) { + e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(c % 64); + c = Math.floor(c / 64); + } + c = e.join(""); + if (d) { + for (f = 11; 0 <= f && 63 === b[f]; f--) { + b[f] = 0; + } + b[f]++; + } else { + for (f = 0; 12 > f; f++) { + b[f] = Math.floor(64 * Math.random()); + } + } + for (f = 0; 12 > f; f++) { + c += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(b[f]); + } + return c; + }; + }(), + generateRowID = function () { + return generateUUID().replace(/_/g, "Z"); + }, + // Setting attributes happens in a delayed recursive way to prevent the sandbox + // from overheating. + delayedGetAndSetAttributes = function (whisper, list, setting, errors, rData, opts) { + const timeNotification = notifyAboutDelay(whisper), + cList = [].concat(list), + feedback = [], + dWork = function (charid) { + const attrs = getCharAttributes(charid, setting, errors, rData, opts); + setCharAttributes(charid, setting, errors, feedback, attrs, opts); + if (cList.length) { + setTimeout(dWork, 50, cList.shift()); + } else { + clearTimeout(timeNotification); + if (!opts.mute) handleErrors(whisper, errors); + if (!opts.silent) sendFeedback(whisper, feedback, opts); + } + }; + dWork(cList.shift()); + }, + setCharAttributes = function (charid, setting, errors, feedback, attrs, opts) { + const charFeedback = {}; + Object.entries(attrs).forEach(([attrName, attr]) => { + let newValue; + charFeedback[attrName] = {}; + const fillInAttrs = setting[attrName].fillin, + settingValue = _.pick(setting[attrName], ["current", "max"]); + if (opts.reset) { + newValue = { + current: attr.get("max") + }; + } else { + newValue = (fillInAttrs) ? + _.mapObject(settingValue, v => fillInAttrValues(charid, v)) : Object.assign({}, settingValue); + } + if (opts.evaluate) { + try { + newValue = _.mapObject(newValue, function (v) { + const parsed = eval(v); + if (_.isString(parsed) || Number.isFinite(parsed) || _.isBoolean(parsed)) { + return parsed.toString(); + } else return v; + }); + } catch (err) { + errors.push("Something went wrong with --evaluate" + + ` for the character ${getCharNameById(charid)}.` + + ` You were warned. The error message was: ${err}.` + + ` Attribute ${attrName} left unchanged.`); + return; + } + } + if (opts.mod || opts.modb) { + Object.entries(newValue).forEach(([k, v]) => { + let moddedValue = parseFloat(v) + parseFloat(attr.get(k) || "0"); + if (!_.isNaN(moddedValue)) { + if (opts.modb && k === "current") { + const parsedMax = parseFloat(attr.get("max")); + moddedValue = Math.min(Math.max(moddedValue, 0), _.isNaN(parsedMax) ? Infinity : parsedMax); + } + newValue[k] = moddedValue; + } else { + delete newValue[k]; + const type = (k === "max") ? "maximum " : ""; + errors.push(`Attribute ${type}${attrName} is not number-valued for ` + + `character ${getCharNameById(charid)}. Attribute ${type}left unchanged.`); + } + }); + } + newValue = _.mapObject(newValue, v => String(v)); + charFeedback[attrName] = newValue; + const oldAttr = JSON.parse(JSON.stringify(attr)); + setAttribute(attr, newValue); + notifyObservers("change", attr, oldAttr); + }); + // Feedback + if (!opts.silent) { + if ("fb-content" in opts) { + const finalFeedback = Object.entries(setting).reduce((m, [attrName, value], k) => { + if (!charFeedback[attrName]) return m; + else return m.replace(`_NAME${k}_`, attrName) + .replace(`_TCUR${k}_`, () => htmlReplace(value.current || "")) + .replace(`_TMAX${k}_`, () => htmlReplace(value.max || "")) + .replace(`_CUR${k}_`, () => htmlReplace(charFeedback[attrName].current || attrs[attrName].get("current") || "")) + .replace(`_MAX${k}_`, () => htmlReplace(charFeedback[attrName].max || attrs[attrName].get("max") || "")); + }, String(opts["fb-content"]).replace("_CHARNAME_", getCharNameById(charid))) + .replace(/_(?:TCUR|TMAX|CUR|MAX|NAME)\d*_/g, ""); + feedback.push(finalFeedback); + } else { + const finalFeedback = Object.entries(charFeedback).map(([k, o]) => { + if ("max" in o && "current" in o) + return `${k} to ${htmlReplace(o.current) || "(empty)"} / ${htmlReplace(o.max) || "(empty)"}`; + else if ("current" in o) return `${k} to ${htmlReplace(o.current) || "(empty)"}`; + else if ("max" in o) return `${k} to ${htmlReplace(o.max) || "(empty)"} (max)`; + else return null; + }).filter(x => !!x).join(", ").replace(/\n/g, "
"); + if (finalFeedback.length) { + feedback.push(`Setting ${finalFeedback} for character ${getCharNameById(charid)}.`); + } else { + feedback.push(`Nothing to do for character ${getCharNameById(charid)}.`); + } + } + } + return; + }, + fillInAttrValues = function (charid, expression) { + let match = expression.match(/%(\S.*?)(?:_(max))?%/), + replacer; + while (match) { + replacer = getAttrByName(charid, match[1], match[2] || "current") || ""; + expression = expression.replace(/%(\S.*?)(?:_(max))?%/, replacer); + match = expression.match(/%(\S.*?)(?:_(max))?%/); + } + return expression; + }, + // Getting attributes for a specific character + getCharAttributes = function (charid, setting, errors, rData, opts) { + const standardAttrNames = Object.keys(setting).filter(x => !setting[x].repeating), + rSetting = _.omit(setting, standardAttrNames); + return Object.assign({}, + getCharStandardAttributes(charid, standardAttrNames, errors, opts), + getCharRepeatingAttributes(charid, rSetting, errors, rData, opts) + ); + }, + getCharStandardAttributes = function (charid, attrNames, errors, opts) { + const attrs = {}, + attrNamesUpper = attrNames.map(x => x.toUpperCase()); + if (attrNames.length === 0) return {}; + findObjs({ + _type: "attribute", + _characterid: charid + }).forEach(attr => { + const nameIndex = attrNamesUpper.indexOf(attr.get("name").toUpperCase()); + if (nameIndex !== -1) attrs[attrNames[nameIndex]] = attr; + }); + _.difference(attrNames, Object.keys(attrs)).forEach(attrName => { + if (!opts.nocreate && !opts.deletemode) { + attrs[attrName] = createObj("attribute", { + characterid: charid, + name: attrName + }); + notifyObservers("add", attrs[attrName]); + } else if (!opts.deletemode) { + errors.push(`Missing attribute ${attrName} not created for` + + ` character ${getCharNameById(charid)}.`); + } + }); + return attrs; + }, + getCharRepeatingAttributes = function (charid, setting, errors, rData, opts) { + const allRepAttrs = {}, + attrs = {}, + repRowIds = {}, + repOrders = {}; + if (rData.sections.size === 0) return {}; + rData.sections.forEach(prefix => allRepAttrs[prefix] = {}); + // Get attributes + findObjs({ + _type: "attribute", + _characterid: charid + }).forEach(o => { + const attrName = o.get("name"); + rData.sections.forEach((prefix, k) => { + if (attrName.search(rData.regExp[k]) === 0) { + allRepAttrs[prefix][attrName] = o; + } else if (attrName === "_reporder_" + prefix) { + repOrders[prefix] = o.get("current").split(","); + } + }); + }); + // Get list of repeating row ids by prefix from allRepAttrs + rData.sections.forEach((prefix, k) => { + repRowIds[prefix] = [...new Set(Object.keys(allRepAttrs[prefix]) + .map(n => n.match(rData.regExp[k])) + .filter(x => !!x) + .map(a => a[1]))]; + if (repOrders[prefix]) { + repRowIds[prefix] = _.chain(repOrders[prefix]) + .intersection(repRowIds[prefix]) + .union(repRowIds[prefix]) + .value(); + } + }); + const repRowIdsLo = _.mapObject(repRowIds, l => l.map(n => n.toLowerCase())); + rData.toCreate.forEach(prefix => repRowIds[prefix].push(generateRowID())); + Object.entries(setting).forEach(([attrName, value]) => { + const p = value.repeating; + let finalId; + if (isDef(p.rowNum) && isDef(repRowIds[p.splitName[0]][p.rowNum])) { + finalId = repRowIds[p.splitName[0]][p.rowNum]; + } else if (p.rowIdLo === "-create" && !opts.deletemode) { + finalId = repRowIds[p.splitName[0]][repRowIds[p.splitName[0]].length - 1]; + } else if (isDef(p.rowIdLo) && repRowIdsLo[p.splitName[0]].includes(p.rowIdLo)) { + finalId = repRowIds[p.splitName[0]][repRowIdsLo[p.splitName[0]].indexOf(p.rowIdLo)]; + } else if (isDef(p.rowNum)) { + errors.push(`Repeating row number ${p.rowNum} invalid for` + + ` character ${getCharNameById(charid)}` + + ` and repeating section ${p.splitName[0]}.`); + } else { + errors.push(`Repeating row id ${p.rowIdLo} invalid for` + + ` character ${getCharNameById(charid)}` + + ` and repeating section ${p.splitName[0]}.`); + } + if (finalId && p.rowMatch) { + const repRowUpper = (p.splitName[0] + "_" + finalId).toUpperCase(); + Object.entries(allRepAttrs[p.splitName[0]]).forEach(([name, attr]) => { + if (name.toUpperCase().indexOf(repRowUpper) === 0) { + attrs[name] = attr; + } + }); + } else if (finalId) { + const finalName = p.splitName[0] + "_" + finalId + "_" + p.splitName[1], + attrNameCased = getCIKey(allRepAttrs[p.splitName[0]], finalName); + if (attrNameCased) { + attrs[attrName] = allRepAttrs[p.splitName[0]][attrNameCased]; + } else if (!opts.nocreate && !opts.deletemode) { + attrs[attrName] = createObj("attribute", { + characterid: charid, + name: finalName + }); + notifyObservers("add", attrs[attrName]); + } else if (!opts.deletemode) { + errors.push(`Missing attribute ${finalName} not created` + + ` for character ${getCharNameById(charid)}.`); + } + } + }); + return attrs; + }, + // Deleting attributes + delayedDeleteAttributes = function (whisper, list, setting, errors, rData, opts) { + const timeNotification = notifyAboutDelay(whisper), + cList = [].concat(list), + feedback = {}, + dWork = function (charid) { + const attrs = getCharAttributes(charid, setting, errors, rData, opts); + feedback[charid] = []; + deleteCharAttributes(charid, attrs, feedback); + if (cList.length) { + setTimeout(dWork, 50, cList.shift()); + } else { + clearTimeout(timeNotification); + if (!opts.silent) sendDeleteFeedback(whisper, feedback, opts); + } + }; + dWork(cList.shift()); + }, + deleteCharAttributes = function (charid, attrs, feedback) { + Object.keys(attrs).forEach(name => { + attrs[name].remove(); + notifyObservers("destroy", attrs[name]); + feedback[charid].push(name); + }); + }, + // These functions parse the chat input. + parseOpts = function (content, hasValue) { + // Input: content - string of the form command --opts1 --opts2 value --opts3. + // values come separated by whitespace. + // hasValue - array of all options which come with a value + // Output: object containing key:true if key is not in hasValue. and containing + // key:value otherwise + return content.replace(//g, "") // delete added HTML line breaks + .replace(/\s+$/g, "") // delete trailing whitespace + .replace(/\s*{{((?:.|\n)*)\s+}}$/, " $1") // replace content wrapped in curly brackets + .replace(/\\([{}])/g, "$1") // add escaped brackets + .split(/\s+--/) + .slice(1) + .reduce((m, arg) => { + const kv = arg.split(/\s(.+)/); + if (hasValue.includes(kv[0])) { + m[kv[0]] = kv[1] || ""; + } else { + m[arg] = true; + } + return m; + }, {}); + }, + parseAttributes = function (args, opts, errors) { + // Input: args - array containing comma-separated list of strings, every one of which contains + // an expression of the form key|value or key|value|maxvalue + // replace - true if characters from the replacers array should be replaced + // Output: Object containing key|value for all expressions. + const globalRepeatingData = { + regExp: new Set(), + toCreate: new Set(), + sections: new Set(), + }, + setting = args.map(str => { + return str.split(/(\\?(?:#|\|))/g) + .reduce((m, s) => { + if ((s === "#" || s === "|")) m[m.length] = ""; + else if ((s === "\\#" || s === "\\|")) m[m.length - 1] += s.slice(-1); + else m[m.length - 1] += s; + return m; + }, [""]); + }) + .filter(v => !!v) + // Replace for --replace + .map(arr => { + return arr.map((str, k) => { + if (opts.replace && k > 0) return replacers.reduce((m, rep) => m.replace(rep[0], rep[1]), str); + else return str; + }); + }) + // parse out current/max value + .map(arr => { + const value = {}; + if (arr.length < 3 || arr[1] !== "") { + value.current = (arr[1] || "").replace(/^'((?:.|\n)*)'$/, "$1"); + } + if (arr.length > 2) { + value.max = arr[2].replace(/^'((?:.|\n)*)'$/, "$1"); + } + return [arr[0].trim(), value]; + }) + // Find out if we need to run %_% replacement + .map(([name, value]) => { + if ((value.current && value.current.search(/%(\S.*?)(?:_(max))?%/) !== -1) || + (value.max && value.max.search(/%(\S.*?)(?:_(max))?%/) !== -1)) value.fillin = true; + else value.fillin = false; + return [name, value]; + }) + // Do repeating section stuff + .map(([name, value]) => { + if (name.search(/^repeating_/) === 0) { + value.repeating = getRepeatingData(name, globalRepeatingData, opts, errors); + } else value.repeating = false; + return [name, value]; + }) + .filter(([, value]) => value.repeating !== null) + .reduce((p, c) => { + p[c[0]] = Object.assign(p[c[0]] || {}, c[1]); + return p; + }, {}); + globalRepeatingData.sections.forEach(s => { + globalRepeatingData.regExp.add(new RegExp(`^${escapeRegExp(s)}_(-[-A-Za-z0-9]+?|\\d+)_`, "i")); + }); + globalRepeatingData.regExp = [...globalRepeatingData.regExp]; + globalRepeatingData.toCreate = [...globalRepeatingData.toCreate]; + globalRepeatingData.sections = [...globalRepeatingData.sections]; + return [setting, globalRepeatingData]; + }, + getRepeatingData = function (name, globalData, opts, errors) { + const match = name.match(/_(\$\d+|-[-A-Za-z0-9]+|\d+)(_)?/); + let output = {}; + if (match && match[1][0] === "$" && match[2] === "_") { + output.rowNum = parseInt(match[1].slice(1)); + } else if (match && match[2] === "_") { + output.rowId = match[1]; + output.rowIdLo = match[1].toLowerCase(); + } else if (match && match[1][0] === "$" && opts.deletemode) { + output.rowNum = parseInt(match[1].slice(1)); + output.rowMatch = true; + } else if (match && opts.deletemode) { + output.rowId = match[1]; + output.rowIdLo = match[1].toLowerCase(); + output.rowMatch = true; + } else { + errors.push(`Could not understand repeating attribute name ${name}.`); + output = null; + } + if (output) { + output.splitName = name.split(match[0]); + globalData.sections.add(output.splitName[0]); + if (output.rowIdLo === "-create" && !opts.deletemode) { + globalData.toCreate.add(output.splitName[0]); + } + } + return output; + }, + // These functions are used to get a list of character ids from the input, + // and check for permissions. + checkPermissions = function (list, errors, playerid, isGM) { + return list.filter(id => { + const character = getObj("character", id); + if (character) { + const control = character.get("controlledby").split(/,/); + if (!(isGM || control.includes("all") || control.includes(playerid) || state.ChatSetAttr.playersCanModify)) { + errors.push(`Permission error for character ${character.get("name")}.`); + return false; + } else return true; + } else { + errors.push(`Invalid character id ${id}.`); + return false; + } + }); + }, + getIDsFromTokens = function (selected) { + return (selected || []).map(obj => getObj("graphic", obj._id)) + .filter(x => !!x) + .map(token => token.get("represents")) + .filter(id => getObj("character", id || "")); + }, + getIDsFromNames = function (charNames, errors) { + return charNames.split(/\s*,\s*/) + .map(name => { + const character = findObjs({ + _type: "character", + name: name + }, { + caseInsensitive: true + })[0]; + if (character) { + return character.id; + } else { + errors.push(`No character named ${name} found.`); + return null; + } + }) + .filter(x => !!x); + }, + sendFeedback = function (whisper, feedback, opts) { + const output = (opts["fb-public"] ? "" : whisper) + + "
" + + "

" + (("fb-header" in opts) ? opts["fb-header"] : "Setting attributes") + "

" + + "

" + (feedback.join("
") || "Nothing to do.") + "

"; + sendChatMessage(output, opts["fb-from"]); + }, + sendDeleteFeedback = function (whisper, feedback, opts) { + let output = (opts["fb-public"] ? "" : whisper) + + "
" + + "

" + (("fb-header" in opts) ? opts["fb-header"] : "Deleting attributes") + "

"; + output += Object.entries(feedback) + .filter(([, arr]) => arr.length) + .map(([charid, arr]) => `Deleting attribute(s) ${arr.join(", ")} for character ${getCharNameById(charid)}.`) + .join("
") || "Nothing to do."; + output += "

"; + sendChatMessage(output, opts["fb-from"]); + }, + handleCommand = (content, playerid, selected, pre) => { + // Parsing input + let charIDList = [], + errors = []; + const hasValue = ["charid", "name", "fb-header", "fb-content", "fb-from"], + optsArray = ["all", "allgm", "charid", "name", "allplayers", "sel", "deletemode", + "replace", "nocreate", "mod", "modb", "evaluate", "silent", "reset", "mute", + "fb-header", "fb-content", "fb-from", "fb-public" + ], + opts = parseOpts(content, hasValue), + isGM = playerid === "API" || playerIsGM(playerid), + whisper = getWhisperPrefix(playerid); + opts.mod = opts.mod || (pre === "mod"); + opts.modb = opts.modb || (pre === "modb"); + opts.reset = opts.reset || (pre === "reset"); + opts.silent = opts.silent || opts.mute; + opts.deletemode = (pre === "del"); + // Sanitise feedback + if ("fb-from" in opts) opts["fb-from"] = String(opts["fb-from"]); + // Parse desired attribute values + const [setting, rData] = parseAttributes(Object.keys(_.omit(opts, optsArray)), opts, errors); + // Fill in header info + if ("fb-header" in opts) { + opts["fb-header"] = Object.entries(setting).reduce((m, [n, v], k) => { + return m.replace(`_NAME${k}_`, n) + .replace(`_TCUR${k}_`, htmlReplace(v.current || "")) + .replace(`_TMAX${k}_`, htmlReplace(v.max || "")); + }, String(opts["fb-header"])).replace(/_(?:TCUR|TMAX|NAME)\d*_/g, ""); + } + if (opts.evaluate && !isGM && !state.ChatSetAttr.playersCanEvaluate) { + if (!opts.mute) handleErrors(whisper, ["The --evaluate option is only available to the GM."]); + return; + } + // Get list of character IDs + if (opts.all && isGM) { + charIDList = findObjs({ + _type: "character" + }).map(c => c.id); + } else if (opts.allgm && isGM) { + charIDList = findObjs({ + _type: "character" + }).filter(c => c.get("controlledby") === "") + .map(c => c.id); + } else if (opts.allplayers && isGM) { + charIDList = findObjs({ + _type: "character" + }).filter(c => c.get("controlledby") !== "") + .map(c => c.id); + } else { + if (opts.charid) charIDList.push(...opts.charid.split(/\s*,\s*/)); + if (opts.name) charIDList.push(...getIDsFromNames(opts.name, errors)); + if (opts.sel) charIDList.push(...getIDsFromTokens(selected)); + charIDList = checkPermissions([...new Set(charIDList)], errors, playerid, isGM); + } + if (charIDList.length === 0) { + errors.push("No target characters. You need to supply one of --all, --allgm, --sel," + + " --allplayers, --charid, or --name."); + } + if (Object.keys(setting).length === 0) { + errors.push("No attributes supplied."); + } + // Get attributes + if (!opts.mute) handleErrors(whisper, errors); + // Set or delete attributes + if (charIDList.length > 0 && Object.keys(setting).length > 0) { + if (opts.deletemode) { + delayedDeleteAttributes(whisper, charIDList, setting, errors, rData, opts); + } else { + delayedGetAndSetAttributes(whisper, charIDList, setting, errors, rData, opts); + } + } + }, + handleInlineCommand = (msg) => { + const command = msg.content.match(/!(set|mod|modb)attr .*?!!!/); + if (command) { + const mode = command[1], + newMsgContent = command[0].slice(0, -3).replace(/{{[^}[\]]+\$\[\[(\d+)\]\].*?}}/g, (_, number) => { + return `$[[${number}]]`; + }); + const newMsg = { + content: newMsgContent, + inlinerolls: msg.inlinerolls, + }; + handleCommand( + processInlinerolls(newMsg), + msg.playerid, + msg.selected, + mode + ); + } + }, + // Main function, called after chat message input + handleInput = function (msg) { + if (msg.type !== "api") handleInlineCommand(msg); + else { + const mode = msg.content.match(/^!(reset|set|del|mod|modb)attr\b(?:-|\s|$)(config)?/); + + if (mode && mode[2]) { + if (playerIsGM(msg.playerid)) { + const whisper = getWhisperPrefix(msg.playerid), + opts = parseOpts(msg.content, []); + if (opts["players-can-modify"]) { + state.ChatSetAttr.playersCanModify = !state.ChatSetAttr.playersCanModify; + } + if (opts["players-can-evaluate"]) { + state.ChatSetAttr.playersCanEvaluate = !state.ChatSetAttr.playersCanEvaluate; + } + if (opts["use-workers"]) { + state.ChatSetAttr.useWorkers = !state.ChatSetAttr.useWorkers; + } + showConfig(whisper); + } + } else if (mode) { + handleCommand( + processInlinerolls(msg), + msg.playerid, + msg.selected, + mode[1] + ); + } + } + return; + }, + notifyObservers = function(event, obj, prev) { + observers[event].forEach(observer => observer(obj, prev)); + }, + registerObserver = function (event, observer) { + if(observer && _.isFunction(observer) && observers.hasOwnProperty(event)) { + observers[event].push(observer); + } else { + log("ChatSetAttr event registration unsuccessful. Please check the documentation."); + } + }, + registerEventHandlers = function () { + on("chat:message", handleInput); + }; + return { + checkInstall, + registerObserver, + registerEventHandlers, + testing: { + isDef, + getWhisperPrefix, + sendChatMessage, + setAttribute, + handleErrors, + showConfig, + getConfigOptionText, + getCharNameById, + escapeRegExp, + htmlReplace, + processInlinerolls, + notifyAboutDelay, + getCIKey, + generateUUID, + generateRowID, + delayedGetAndSetAttributes, + setCharAttributes, + fillInAttrValues, + getCharAttributes, + getCharStandardAttributes, + getCharRepeatingAttributes, + delayedDeleteAttributes, + deleteCharAttributes, + parseOpts, + parseAttributes, + getRepeatingData, + checkPermissions, + getIDsFromTokens, + getIDsFromNames, + sendFeedback, + sendDeleteFeedback, + handleCommand, + handleInlineCommand, + handleInput, + notifyObservers, + checkGlobalConfig, + } + }; +}()); + +on("ready", function () { + "use strict"; + ChatSetAttr.checkInstall(); + ChatSetAttr.registerEventHandlers(); +}); + +export default ChatSetAttr; \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts b/ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts new file mode 100644 index 0000000000..4c4ea6b75c --- /dev/null +++ b/ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts @@ -0,0 +1,1136 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import ChatSetAttr from "./ChatSetAttr.js"; +import { resetAllObjects } from "../../__mocks__/apiObjects.mock.js"; +import { resetAllCallbacks } from "../../__mocks__/eventHandling.mock.js"; + +// startDebugMode(); + +describe("ChatSetAttr Integration Tests", () => { + type StateConfig = { + version: string; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; + }; + + const originalConfig: StateConfig = { + version: "1.10", + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true + }; + + // Set up the test environment before each test + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + ChatSetAttr.registerEventHandlers(); + global.state.ChatSetAttr = { ...originalConfig }; + }); + + // Cleanup after each test if needed + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + resetAllObjects(); + resetAllCallbacks(); + }); + + describe("Attribute Setting Commands", () => { + it("should set Strength to 15 for selected characters", async () => { + // arrange + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const charOne = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const charTwo = createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + const strengthAttrOne = createObj("attribute", { _id: "strengthchar1", _characterid: charOne.id, name: "Strength", current: "10" }); + const strengthAttrTwo = createObj("attribute", { _id: "strengthchar2", _characterid: charTwo.id, name: "Strength", current: "12" }); + const tokenOne = createObj("graphic", { _id: "token1", represents: charOne.id }); + const tokenTwo = createObj("graphic", { _id: "token2", represents: charTwo.id }); + const selectedTokens = [tokenOne.properties, tokenTwo.properties]; + + // act + executeCommand( + "!setattr --sel --Strength|15", + { selected: selectedTokens }, + ); + + // assert + await vi.waitFor(() => { + expect(strengthAttrOne.set).toHaveBeenCalledWith({ current: "15" }); + expect(strengthAttrTwo.set).toHaveBeenCalledWith({ current: "15" }); + }); + }); + + it("should set HP and Dex for character named John", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "john1", name: "John", controlledby: player.id }); + createObj("character", { _id: "john2", name: "john", controlledby: player.id }); + createObj("character", { _id: "char3", name: "NotJohn", controlledby: player.id }); + + executeCommand("!setattr --name John --HP|17|27 --Dex|10"); + + await vi.waitFor(() => { + const johnHP = findObjs({ _type: "attribute", _characterid: "john1", name: "HP" })[0]; + const johnDex = findObjs({ _type: "attribute", _characterid: "john1", name: "Dex" })[0]; + + expect(johnHP).toBeDefined(); + expect(johnHP.set).toHaveBeenCalledWith({ current: "17", max: "27" }); + expect(johnDex).toBeDefined(); + expect(johnDex.set).toHaveBeenCalledWith({ current: "10" }); + + const anotherJohnHP = findObjs({ _type: "attribute", _characterid: "john2", name: "HP" })[0]; + const notJohnHP = findObjs({ _type: "attribute", _characterid: "char3", name: "HP" })[0]; + expect(anotherJohnHP).toBeUndefined(); + expect(notJohnHP).toBeUndefined(); + }); + }); + + it("should set td attribute to d8 for all characters", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("character", { _id: "char3", name: "Character 3", controlledby: player.id }); + + executeCommand("!setattr --all --td|d8"); + + await vi.waitFor(() => { + const char1TensionDie = findObjs({ _type: "attribute", _characterid: "char1", name: "td" })[0]; + const char2TensionDie = findObjs({ _type: "attribute", _characterid: "char2", name: "td" })[0]; + const char3TensionDie = findObjs({ _type: "attribute", _characterid: "char3", name: "td" })[0]; + + expect(char1TensionDie).toBeDefined(); + expect(char1TensionDie.set).toHaveBeenCalledWith({ current: "d8" }); + expect(char2TensionDie).toBeDefined(); + expect(char2TensionDie.set).toHaveBeenCalledWith({ current: "d8" }); + expect(char3TensionDie).toBeDefined(); + expect(char3TensionDie.set).toHaveBeenCalledWith({ current: "d8" }); + }); + }); + + it("should add a new item to a repeating inventory section", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("graphic", { _id: "token1", represents: "char1" }); + + const commandParts = [ + "!setattr", + "--sel", + "--fb-public", + "--fb-header Aquiring Magic Item", + "--fb-content The Cloak of Excellence from the chest by a character.", + "--repeating_inventory_-CREATE_itemname|Cloak of Excellence", + "--repeating_inventory_-CREATE_itemcount|1", + "--repeating_inventory_-CREATE_itemweight|3", + "--repeating_inventory_-CREATE_equipped|1", + "--repeating_inventory_-CREATE_itemmodifiers|Item Type: Wondrous item, AC +2, Saving Throws +1", + "--repeating_inventory_-CREATE_itemcontent|(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar." + ]; + const command = commandParts.join(" "); + const selected = [{ _id: "token1" } as unknown as Roll20Graphic["properties"]]; + + executeCommand(command, { selected }); + + await vi.waitFor(() => { + expect(sendChat).toHaveBeenCalled(); + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Aquiring Magic Item") + ); + expect(feedbackCall).toBeDefined(); + + const nameAttrs = findObjs({ _type: "attribute", _characterid: "char1" }).filter(a => a.get("name").includes("itemname")); + expect(nameAttrs.length).toBeGreaterThan(0); + const nameAttr = nameAttrs[0]; + + const repeatingRowId = nameAttr.get("name").match(/repeating_inventory_([^_]+)_itemname/)?.[1]; + expect(repeatingRowId).toBeDefined(); + + const itemName = findObjs({ _type: "attribute", name: `repeating_inventory_${repeatingRowId}_itemname` })[0]; + const itemCount = findObjs({ _type: "attribute", name: `repeating_inventory_${repeatingRowId}_itemcount` })[0]; + const itemWeight = findObjs({ _type: "attribute", name: `repeating_inventory_${repeatingRowId}_itemweight` })[0]; + const itemEquipped = findObjs({ _type: "attribute", name: `repeating_inventory_${repeatingRowId}_equipped` })[0]; + const itemModifiers = findObjs({ _type: "attribute", name: `repeating_inventory_${repeatingRowId}_itemmodifiers` })[0]; + const itemContent = findObjs({ _type: "attribute", name: `repeating_inventory_${repeatingRowId}_itemcontent` })[0]; + + expect(itemName).toBeDefined(); + expect(itemName.set).toHaveBeenCalledWith({ current: "Cloak of Excellence" }); + expect(itemCount.set).toHaveBeenCalledWith({ current: "1" }); + expect(itemWeight.set).toHaveBeenCalledWith({ current: "3" }); + expect(itemEquipped.set).toHaveBeenCalledWith({ current: "1" }); + expect(itemModifiers.set).toHaveBeenCalledWith({ current: "Item Type: Wondrous item, AC +2, Saving Throws +1" }); + expect(itemContent.set).toHaveBeenCalledWith({ current: "(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar." }); + }); + }); + + it("should process inline roll queries", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --Strength|15 --Dexterity|20"); + + await vi.waitFor(() => { + const strAttr = findObjs({ _type: "attribute", _characterid: "char1", name: "Strength" })[0]; + const dexAttr = findObjs({ _type: "attribute", _characterid: "char1", name: "Dexterity" })[0]; + + expect(strAttr).toBeDefined(); + expect(strAttr.set).toHaveBeenCalledWith({ current: "15" }); + expect(dexAttr).toBeDefined(); + expect(dexAttr.set).toHaveBeenCalledWith({ current: "20" }); + }); + }); + + it("should process an inline command within a chat message", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("I cast a spell and !setattr --charid char1 --Mana|10!!!", { type: "general" }); + + await vi.waitFor(() => { + const manaAttr = findObjs({ _type: "attribute", _characterid: "char1", name: "Mana" })[0]; + + expect(manaAttr).toBeDefined(); + expect(manaAttr.set).toHaveBeenCalledWith({ current: "10" }); + }); + }); + + it("should use character IDs directly to set attributes", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + + executeCommand("!setattr --charid char1,char2 --Level|5"); + + await vi.waitFor(() => { + const char1Level = findObjs({ _type: "attribute", _characterid: "char1", name: "Level" })[0]; + const char2Level = findObjs({ _type: "attribute", _characterid: "char2", name: "Level" })[0]; + + expect(char1Level).toBeDefined(); + expect(char1Level.set).toHaveBeenCalledWith({ current: "5" }); + expect(char2Level).toBeDefined(); + expect(char2Level.set).toHaveBeenCalledWith({ current: "5" }); + }); + }); + + it("should set multiple attributes on multiple characters", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + + executeCommand("!setattr --charid char1,char2 --Class|Fighter --Level|5 --HP|30|30"); + + await vi.waitFor(() => { + const char1Class = findObjs({ _type: "attribute", _characterid: "char1", name: "Class" })[0]; + const char1Level = findObjs({ _type: "attribute", _characterid: "char1", name: "Level" })[0]; + const char1HP = findObjs({ _type: "attribute", _characterid: "char1", name: "HP" })[0]; + + expect(char1Class).toBeDefined(); + expect(char1Class.set).toHaveBeenCalledWith({ current: "Fighter" }); + expect(char1Level).toBeDefined(); + expect(char1Level.set).toHaveBeenCalledWith({ current: "5" }); + expect(char1HP).toBeDefined(); + expect(char1HP.set).toHaveBeenCalledWith({ current: "30", max: "30" }); + + const char2Class = findObjs({ _type: "attribute", _characterid: "char2", name: "Class" })[0]; + const char2Level = findObjs({ _type: "attribute", _characterid: "char2", name: "Level" })[0]; + const char2HP = findObjs({ _type: "attribute", _characterid: "char2", name: "HP" })[0]; + + expect(char2Class).toBeDefined(); + expect(char2Class.set).toHaveBeenCalledWith({ current: "Fighter" }); + expect(char2Level).toBeDefined(); + expect(char2Level.set).toHaveBeenCalledWith({ current: "5" }); + expect(char2HP).toBeDefined(); + expect(char2HP.set).toHaveBeenCalledWith({ current: "30", max: "30" }); + }); + }); + }); + + describe("Attribute Modification Commands", () => { + it("should increase Strength by 5 for selected characters", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("character", { _id: "char3", name: "Character 3", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "Strength", current: "10" }); + createObj("attribute", { _characterid: "char2", name: "Strength", current: "15" }); + createObj("attribute", { _characterid: "char3", name: "Strength", current: "Very big" }); + const token1 = createObj("graphic", { _id: "token1", represents: "char1" }); + const token2 = createObj("graphic", { _id: "token2", represents: "char2" }); + const token3 = createObj("graphic", { _id: "token3", represents: "char3" }); + + executeCommand("!setattr --sel --mod --Strength|5", { selected: [token1.properties, token2.properties, token3.properties] }); + + await vi.waitFor(() => { + const char1Strength = findObjs({ _type: "attribute", _characterid: "char1", name: "Strength" })[0]; + const char2Strength = findObjs({ _type: "attribute", _characterid: "char2", name: "Strength" })[0]; + const char3Strength = findObjs({ _type: "attribute", _characterid: "char3", name: "Strength" })[0]; + + expect(char1Strength).toBeDefined(); + expect(char1Strength.set).toHaveBeenCalledWith({ current: "15" }); + expect(char2Strength).toBeDefined(); + expect(char2Strength.set).toHaveBeenCalledWith({ current: "20" }); + expect(char3Strength).toBeDefined(); + expect(char3Strength.get("current")).toBe("Very big"); + + expect(sendChat).toHaveBeenCalled(); + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("is not number-valued") + ); + expect(errorCall).toBeDefined(); + }); + }); + + it("should handle --mod option for modifying attributes", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "Counter", current: "5" }); + createObj("attribute", { _characterid: "char1", name: "CounterMax", current: "3", max: "10" }); + + executeCommand("!modattr --charid char1 --Counter|2 --CounterMax|1|2"); + + await vi.waitFor(() => { + const counter = findObjs({ _type: "attribute", _characterid: "char1", name: "Counter" })[0]; + const counterMax = findObjs({ _type: "attribute", _characterid: "char1", name: "CounterMax" })[0]; + + expect(counter).toBeDefined(); + expect(counter.set).toHaveBeenCalledWith({ current: "7" }); + expect(counterMax).toBeDefined(); + expect(counterMax.set).toHaveBeenCalledWith({ current: "4", max: "12" }); + }); + }); + + it("should modify attributes using the !mod command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "30" }); + + executeCommand("!modattr --charid char1 --HP|5 --MP|-3"); + + await vi.waitFor(() => { + const hp = findObjs({ _type: "attribute", _characterid: "char1", name: "HP" })[0]; + const mp = findObjs({ _type: "attribute", _characterid: "char1", name: "MP" })[0]; + + expect(hp).toBeDefined(); + expect(hp.set).toHaveBeenCalledWith({ current: "15" }); + expect(mp).toBeDefined(); + expect(mp.set).toHaveBeenCalledWith({ current: "12" }); + }); + }); + + it("should modify attributes with bounds using modbattr", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "Stamina", current: "1", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|5 --Stamina|-5"); + + await vi.waitFor(() => { + const hp = findObjs({ _type: "attribute", _characterid: "char1", name: "HP" })[0]; + const mp = findObjs({ _type: "attribute", _characterid: "char1", name: "MP" })[0]; + const stamina = findObjs({ _type: "attribute", _characterid: "char1", name: "Stamina" })[0]; + + expect(hp).toBeDefined(); + expect(hp.set).toHaveBeenCalledWith({ current: "15" }); + expect(mp).toBeDefined(); + expect(mp.set).toHaveBeenCalledWith({ current: "15" }); + expect(stamina).toBeDefined(); + expect(stamina.set).toHaveBeenCalledWith({ current: "0" }); + }); + }); + + it("should modify attributes with bounds using the !modb command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "10" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "8", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|-10"); + + await vi.waitFor(() => { + const hp = findObjs({ _type: "attribute", _characterid: "char1", name: "HP" })[0]; + const mp = findObjs({ _type: "attribute", _characterid: "char1", name: "MP" })[0]; + + expect(hp).toBeDefined(); + expect(hp.set).toHaveBeenCalledWith({ current: "10" }); + expect(mp).toBeDefined(); + expect(mp.set).toHaveBeenCalledWith({ current: "0" }); + }); + }); + }); + + describe("Attribute Deletion and Reset Commands", () => { + it("should delete the gold attribute from all characters", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "gold", current: "100" }); + createObj("attribute", { _characterid: "char2", name: "gold", current: "200" }); + createObj("attribute", { _characterid: "char1", name: "silver", current: "50" }); + + executeCommand("!delattr --all --gold"); + + await vi.waitFor(() => { + expect(findObjs({ _type: "attribute", _characterid: "char1", name: "gold" })[0]).toBeUndefined(); + expect(findObjs({ _type: "attribute", _characterid: "char2", name: "gold" })[0]).toBeUndefined(); + expect(findObjs({ _type: "attribute", _characterid: "char1", name: "silver" })[0]).toBeDefined(); + }); + }); + + it("should reset Ammo to its maximum value", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "Ammo", current: "3", max: "20" }); + const token1 = createObj("graphic", { _id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --Ammo|%Ammo_max%", { selected: [token1.properties] }); + + await vi.waitFor(() => { + const ammo = findObjs({ _type: "attribute", _characterid: "char1", name: "Ammo" })[0]; + expect(ammo).toBeDefined(); + expect(ammo.set).toHaveBeenCalledWith({ current: "20" }); + }); + }); + + it("should reset attributes to their maximum values with resetattr", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "5", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100", max: "" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(() => { + const hp = findObjs({ _type: "attribute", _characterid: "char1", name: "HP" })[0]; + const mp = findObjs({ _type: "attribute", _characterid: "char1", name: "MP" })[0]; + const xp = findObjs({ _type: "attribute", _characterid: "char1", name: "XP" })[0]; + + expect(hp).toBeDefined(); + expect(hp.set).toHaveBeenCalledWith({ current: "20" }); + expect(mp).toBeDefined(); + expect(mp.set).toHaveBeenCalledWith({ current: "15" }); + expect(xp).toBeDefined(); + expect(xp.get("current")).toBe("100"); + }); + }); + + it("should reset attributes using the !reset command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "10", max: "30" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(() => { + const hp = findObjs({ _type: "attribute", _characterid: "char1", name: "HP" })[0]; + const mp = findObjs({ _type: "attribute", _characterid: "char1", name: "MP" })[0]; + const xp = findObjs({ _type: "attribute", _characterid: "char1", name: "XP" })[0]; + + expect(hp).toBeDefined(); + expect(hp.set).toHaveBeenCalledWith({ current: "20" }); + expect(mp).toBeDefined(); + expect(mp.set).toHaveBeenCalledWith({ current: "30" }); + expect(xp).toBeDefined(); + expect(xp.get("current")).toBe("100"); + }); + }); + + it("should delete attributes using the !del command syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "ToDelete1", current: "10" }); + createObj("attribute", { _characterid: "char1", name: "ToDelete2", current: "20" }); + createObj("attribute", { _characterid: "char1", name: "ToKeep", current: "30" }); + + executeCommand("!delattr --charid char1 --ToDelete1 --ToDelete2"); + + await vi.waitFor(() => { + const toDelete1 = findObjs({ _type: "attribute", _characterid: "char1", name: "ToDelete1" })[0]; + const toDelete2 = findObjs({ _type: "attribute", _characterid: "char1", name: "ToDelete2" })[0]; + const toKeep = findObjs({ _type: "attribute", _characterid: "char1", name: "ToKeep" })[0]; + + expect(toDelete1).toBeUndefined(); + expect(toDelete2).toBeUndefined(); + expect(toKeep).toBeDefined(); + expect(toKeep.get("current")).toBe("30"); + }); + }); + }); + + describe("Targeting Options", () => { + it("should set attributes for GM-only characters with allgm targeting mode", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + const gmCharOne = createObj("character", { _id: "gmchar1", name: "GM Character 1", controlledby: "" }); + const gmCharTwo = createObj("character", { _id: "gmchar2", name: "GM Character 2", controlledby: "" }); + const playerChar = createObj("character", { _id: "playerchar", name: "Player Character", controlledby: player.id }); + + executeCommand("!setattr --allgm --Status|NPC"); + + await vi.waitFor(() => { + const gmChar1Status = findObjs({ _type: "attribute", _characterid: gmCharOne.id, name: "Status" })[0]; + const gmChar2Status = findObjs({ _type: "attribute", _characterid: gmCharTwo.id, name: "Status" })[0]; + const playerCharStatus = findObjs({ _type: "attribute", _characterid: playerChar.id, name: "Status" })[0]; + + expect(gmChar1Status).toBeDefined(); + expect(gmChar1Status.set).toHaveBeenCalledWith({ current: "NPC" }); + + expect(gmChar2Status).toBeDefined(); + expect(gmChar2Status.set).toHaveBeenCalledWith({ current: "NPC" }); + + expect(playerCharStatus).toBeUndefined(); + }); + }); + + it("should set attributes for player-controlled characters with allplayers targeting mode", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + createObj("character", { _id: "playerchar1", name: "Player Character 1", controlledby: player.id }); + createObj("character", { _id: "playerchar2", name: "Player Character 2", controlledby: player.id }); + createObj("character", { _id: "gmchar", name: "GM Character", controlledby: "" }); + + executeCommand("!setattr --allplayers --CharType|PC"); + + await vi.waitFor(() => { + const playerChar1Type = findObjs({ _type: "attribute", _characterid: "playerchar1", name: "CharType" })[0]; + const playerChar2Type = findObjs({ _type: "attribute", _characterid: "playerchar2", name: "CharType" })[0]; + const gmCharType = findObjs({ _type: "attribute", _characterid: "gmchar", name: "CharType" })[0]; + + expect(playerChar1Type).toBeDefined(); + expect(playerChar1Type.set).toHaveBeenCalledWith({ current: "PC" }); + + expect(playerChar2Type).toBeDefined(); + expect(playerChar2Type.set).toHaveBeenCalledWith({ current: "PC" }); + + expect(gmCharType).toBeUndefined(); + }); + }); + }); + + describe("Attribute Value Processing", () => { + it("should evaluate expressions using attribute references", async () => { + vi.mocked(playerIsGM).mockReturnValue(true); + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const char = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: char.id, name: "attr1", current: "3" }); + createObj("attribute", { _characterid: char.id, name: "attr2", current: "2" }); + const token1 = createObj("graphic", { _id: "token1", represents: char.id }); + + executeCommand("!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%", { selected: [token1.properties] }); + + await vi.waitFor(() => { + const attr3 = findObjs({ _type: "attribute", _characterid: "char1", name: "attr3" })[0]; + expect(attr3).toBeDefined(); + expect(attr3.set).toHaveBeenCalledWith({ current: "11" }); + }); + }); + + it("should handle --replace option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --replace --charid char1 --Description|This text has characters; and should be `replaced`"); + + await vi.waitFor(() => { + const desc = findObjs({ _type: "attribute", _characterid: "char1", name: "Description" })[0]; + expect(desc).toBeDefined(); + expect(desc.set).toHaveBeenCalledWith({ current: "This text has [special] characters? and should be @replaced@" }); + }); + }); + + it("should honor multiple modifier flags used together", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "ExistingAttr", current: "10" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --evaluate --ExistingAttr|20*2"); + + await vi.waitFor(() => { + const existingAttr = findObjs({ _type: "attribute", _characterid: "char1", name: "ExistingAttr" })[0]; + expect(existingAttr).toBeDefined(); + expect(existingAttr.set).toHaveBeenCalledWith({ current: "40" }); + + expect(sendChat).not.toHaveBeenCalled(); + }); + }); + }); + + describe("Configuration Options", () => { + it("should handle configuration commands", async () => { + vi.mocked(global.playerIsGM).mockReturnValue(true); + global.createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + + executeCommand("!setattr-config --players-can-modify", { playerid: "example-player-id" }); + expect(global.state.ChatSetAttr.playersCanModify).toBeFalsy(); + expect(sendChat).toHaveBeenCalledTimes(1); + + executeCommand("!setattr-config --players-can-evaluate", { playerid: "example-player-id" }); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBeFalsy(); + expect(sendChat).toHaveBeenCalledTimes(2); + + executeCommand("!setattr-config --use-workers", { playerid: "example-player-id" }); + expect(global.state.ChatSetAttr.useWorkers).toBeFalsy(); + expect(sendChat).toHaveBeenCalledTimes(3); + }); + + it("should respect player permissions", async () => { + createObj("character", { _id: "char1", name: "Player Character", controlledby: "player123" }); + + const state = global.state as { ChatSetAttr: StateConfig }; + const originalConfig = state.ChatSetAttr.playersCanModify; + state.ChatSetAttr.playersCanModify = false; + + const originalPlayerIsGM = global.playerIsGM; + global.playerIsGM = vi.fn(() => false); + + executeCommand("!setattr --charid char1 --Strength|18", { playerid: "differentPlayer456" }); + + await vi.waitFor(() => { + const strength = findObjs({ _type: "attribute", _characterid: "char1", name: "Strength" })[0]; + expect(strength).toBeUndefined(); + + expect(sendChat).toHaveBeenCalled(); + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Permission error") + ); + expect(errorCall).toBeDefined(); + }); + + state.ChatSetAttr.playersCanModify = originalConfig; + global.playerIsGM = originalPlayerIsGM; + }); + }); + + describe("Feedback Options", () => { + it("should send public feedback with --fb-public option", async () => { + vi.mocked(global.playerIsGM).mockReturnValue(true); + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-public --Attribute|42"); + + await vi.waitFor(() => { + const attr = findObjs({ _type: "attribute", _characterid: "char1", name: "Attribute" })[0]; + expect(attr).toBeDefined(); + expect(attr.set).toHaveBeenCalledWith({ current: "42" }); + + const feedbackCalls = vi.mocked(sendChat).mock.calls.filter(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Setting Attribute") && + !call[1].startsWith("/w ") + ); + + expect(feedbackCalls.length).toBeGreaterThan(0); + }); + }); + + it("should use custom sender with --fb-from option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-from Wizard --Spell|Fireball"); + + await vi.waitFor(() => { + const attr = findObjs({ _type: "attribute", _characterid: "char1", name: "Spell" })[0]; + expect(attr).toBeDefined(); + expect(attr.set).toHaveBeenCalledWith({ current: "Fireball" }); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[0] === "Wizard" && + call[1] && typeof call[1] === "string" && + call[1].includes("Setting Spell") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should use custom header with --fb-header option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-header Magic Item Acquired --Item|Staff of Power"); + + await vi.waitFor(() => { + const attr = findObjs({ _type: "attribute", _characterid: "char1", name: "Item" })[0]; + expect(attr).toBeDefined(); + expect(attr.set).toHaveBeenCalledWith({ current: "Staff of Power" }); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Magic Item Acquired") && + !call[1].includes("Setting attributes") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should use custom content with --fb-content option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --fb-header \"Level Up\" --fb-content \"_CHARNAME_ is now level _CUR0_!\" --Level|5"); + + await vi.waitFor(() => { + const attr = findObjs({ _type: "attribute", _characterid: "char1", name: "Level" })[0]; + expect(attr).toBeDefined(); + expect(attr.set).toHaveBeenCalledWith({ current: "5" }); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Character 1 is now level 5!") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should combine all feedback options together", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: character.id }); + + const callParts = [ + "!setattr", + "--sel", + "--fb-public", + "--fb-from Dungeon_Master", + "--fb-header \"Combat Stats Updated\"", + "--fb-content \"_CHARNAME_'s health increased to _CUR0_!\"", + "--HP|25" + ]; + + const selected = [token.properties]; + + vi.mocked(sendChat).mockRestore(); + executeCommand(callParts.join(" "), { selected }); + + await vi.waitFor(() => { + const attr = findObjs({ _type: "attribute", _characterid: "char1", name: "HP" })[0]; + expect(attr).toBeDefined(); + expect(attr.set).toHaveBeenCalledWith({ current: "25" }); + + const feedbackCalls = vi.mocked(global.sendChat).mock.calls.find(call => + call[0] === "Dungeon_Master" && + !call[1].startsWith("/w ") && + call[1].includes("Combat Stats Updated") && + call[1].includes("Character 1's health increased to 25!") + ); + + expect(feedbackCalls).toBeDefined(); + }); + }); + }); + + describe("Message Suppression Options", () => { + it("should suppress feedback messages when using the --silent option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --TestAttr|42"); + + await vi.waitFor(() => { + const testAttr = findObjs({ _type: "attribute", _characterid: "char1", name: "TestAttr" })[0]; + expect(testAttr).toBeDefined(); + expect(testAttr.set).toHaveBeenCalledWith({ current: "42" }); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Setting TestAttr") + ); + expect(feedbackCall).toBeUndefined(); + }); + }); + + it("should suppress error messages when using the --mute option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --mute --mod --NonNumeric|abc --Value|5"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Error") + ); + expect(errorCall).toBeUndefined(); + }); + }); + + it("should not create attributes when using the --nocreate option", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --nocreate --NewAttribute|50"); + + await vi.waitFor(() => { + const newAttr = findObjs({ _type: "attribute", _characterid: "char1", name: "NewAttribute" })[0]; + expect(newAttr).toBeUndefined(); + + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Missing attribute") && + call[1].includes("not created") + ); + expect(errorCall).toBeDefined(); + }); + }); + }); + + describe("Observer Events", () => { + it("should observe attribute additions with registered observers", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("add", mockObserver); + + executeCommand("!setattr --charid char1 --NewAttribute|42"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + const calls = mockObserver.mock.calls; + const hasAddCall = calls.some(call => { + const attr = call[0]; + return attr && attr.get("name") === "NewAttribute" && attr.get("current") === "42"; + }); + expect(hasAddCall).toBe(true); + }); + }); + + it("should observe attribute changes with registered observers", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ExistingAttr", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("change", mockObserver); + + executeCommand("!setattr --charid char1 --ExistingAttr|20"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + const calls = mockObserver.mock.calls; + const firstCall = calls[0]; + + expect(firstCall[0]).toBeDefined(); + expect(firstCall[0].get("name")).toBe("ExistingAttr"); + expect(firstCall[0].get("current")).toBe("20"); + }); + }); + + it("should observe attribute deletions with registered observers", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "DeleteMe", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("destroy", mockObserver); + + executeCommand("!delattr --charid char1 --DeleteMe"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + + const calls = mockObserver.mock.calls; + const firstCall = calls[0]; + + expect(firstCall[0]).toBeDefined(); + expect(firstCall[0].get("name")).toBe("DeleteMe"); + expect(firstCall[0].get("current")).toBe("10"); + }); + }); + }); + + describe("Repeating Sections", () => { + it("should create repeating section attributes", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --repeating_weapons_-CREATE_weaponname|Longsword --repeating_weapons_-CREATE_damage|1d8"); + + await vi.waitFor(() => { + const nameAttr = findObjs({ _type: "attribute", _characterid: "char1" }).find(a => a.get("name")?.includes("weaponname")); + expect(nameAttr).toBeDefined(); + + if (!nameAttr) return expect.fail("nameAttr is undefined"); + + const name = nameAttr.get("name"); + const current = nameAttr.get("current"); + const rowID = name.match(/repeating_weapons_([^_]+)_weaponname/)?.[1]; + + expect(name).toBe(`repeating_weapons_${rowID}_weaponname`); + expect(current).toBe("Longsword"); + + const damageAttr = findObjs({ _type: "attribute", _characterid: "char1", name: `repeating_weapons_${rowID}_damage` })[0]; + expect(damageAttr).toBeDefined(); + expect(damageAttr.get("current")).toBe("1d8"); + }); + }); + + it("should adjust number of uses remaining for an ability", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const attr = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "repeating_ability_-exampleid_used", current: "3" }); + const token = createObj("graphic", { _id: "token1", represents: character.id }); + const selected = [token.properties]; + + const commandParts = [ + "!setattr", + "--charid char1", + "--repeating_ability_-exampleid_used|[[?{How many are left?|0}]]" + ]; + executeCommand(commandParts.join(" "), { + selected, + inputs: ["2"], + }); + + await vi.waitFor(() => { + expect(attr.set).toHaveBeenCalled(); + expect(attr.set).toHaveBeenCalledWith({ current: "2" }); + }); + }); + + it("should toggle a buff on or off", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const attribute = createObj("attribute", { _id: "attr1", _characterid: character.id, name: "repeating_buff2_-example_enable_toggle", current: "0" }); + const token = createObj("graphic", { _id: "token1", represents: "char1" }); + const selected = [token.properties]; + + executeCommand("!setattr --sel --repeating_buff2_-example_enable_toggle|[[1-@{selected|repeating_buff2_-example_enable_toggle}]]", { + selected, + }); + + await vi.waitFor(() => { + expect(attribute).toBeDefined(); + expect(attribute.get("current")).toBe("1"); + expect(attribute.set).toHaveBeenCalled(); + expect(attribute.set).toHaveBeenCalledWith({ current: "1" }); + }); + + executeCommand("!setattr --sel --repeating_buff2_-example_enable_toggle|[[1-@{selected|repeating_buff2_-example_enable_toggle}]]", { + selected, + }); + + await vi.waitFor(() => { + expect(attribute).toBeDefined(); + expect(attribute.get("current")).toBe("0"); + expect(attribute.set).toHaveBeenCalled(); + expect(attribute.set).toHaveBeenCalledWith({ current: "0" }); + }); + }); + + const createRepeatingObjects = () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const firstWeaponNameAttr = createObj("attribute", { + _id: "attr1", + _characterid: character.id, + name: "repeating_weapons_-abc123_weaponname", + current: "Longsword" + }); + const firstWeaponDamageAttr = createObj("attribute", { + _id: "attr2", + _characterid: character.id, + name: "repeating_weapons_-abc123_damage", + current: "1d8" + }); + + const secondWeaponNameAttr = createObj("attribute", { + _id: "attr3", + _characterid: character.id, + name: "repeating_weapons_-def456_weaponname", + current: "Dagger" + }); + const secondWeaponDamageAttr = createObj("attribute", { + _id: "attr4", + _characterid: character.id, + name: "repeating_weapons_-def456_damage", + current: "1d4" + }); + + const thirdWeaponNameAttr = createObj("attribute", { + _id: "attr5", + _characterid: character.id, + name: "repeating_weapons_-ghi789_weaponname", + current: "Bow" + }); + const thirdWeaponDamageAttr = createObj("attribute", { + _id: "attr6", + _characterid: character.id, + name: "repeating_weapons_-ghi789_damage", + current: "1d6" + }); + + const reporder = createObj("attribute", { + _id: "attr7", + _characterid: character.id, + name: "_reporder_" + "repeating_weapons", + current: "abc123,def456,ghi789" + }); + + const token = createObj("graphic", { _id: "token1", represents: character.id }); + + return { + player, + character, + firstWeaponNameAttr, + firstWeaponDamageAttr, + secondWeaponNameAttr, + secondWeaponDamageAttr, + thirdWeaponNameAttr, + thirdWeaponDamageAttr, + reporder, + token + }; + }; + + + it("should handle deleting repeating section attributes referenced by index", async () => { + // arrange + const { token, firstWeaponNameAttr, secondWeaponNameAttr, thirdWeaponNameAttr } = createRepeatingObjects(); + const selected = [token.properties]; + + // act + executeCommand("!delattr --sel --repeating_weapons_$1_weaponname", { selected }); + + // assert + await vi.waitFor(() => { + expect(firstWeaponNameAttr.remove).not.toHaveBeenCalled(); + + // Second weapon (Dagger) should be deleted + expect(secondWeaponNameAttr.remove).toHaveBeenCalled(); + + // Third weapon should still exist + expect(thirdWeaponNameAttr.remove).not.toHaveBeenCalled(); + }); + }); + + it("should handle modifying repeating section attributes referenced by index", async () => { + // arrange + const { firstWeaponDamageAttr, secondWeaponDamageAttr, token } = createRepeatingObjects(); + const selected = [token.properties]; + + // act - Modify the damage of the first weapon ($0 index) + executeCommand( + "!setattr --sel --nocreate --repeating_weapons_$0_damage|2d8", + { selected } + ); + + // Wait for the operation to complete + await vi.waitFor(() => { + // assert - First weapon damage should be updated + expect(firstWeaponDamageAttr.get("current")).toBe("2d8"); + expect(firstWeaponDamageAttr.set).toHaveBeenCalledWith({ current: "2d8" }); + + expect(secondWeaponDamageAttr.get("current")).toBe("1d4"); + expect(secondWeaponDamageAttr.set).not.toHaveBeenCalled(); + }); + }); + + it("should handle creating new repeating section attributes after deletion", async () => { + // arrange - Create initial repeating section attributes + const { token } = createRepeatingObjects(); + + // act - Create a new attribute in the last weapon ($1 index after deletion) + const selected = [token.properties]; + executeCommand( + "!setattr --sel --repeating_weapons_$1_newlycreated|5", + { selected } + ); + + // Wait for the operation to complete + await vi.waitFor(() => { + const attackBonus = findObjs({ + _type: "attribute", + _characterid: "char1", + name: "repeating_weapons_-def456_newlycreated" + })[0]; + expect(attackBonus).toBeDefined(); + expect(attackBonus.get("current")).toBe("5"); + }); + }); + }); + + describe("Delayed Processing", () => { + it("should process characters sequentially with delays", async () => { + vi.useFakeTimers(); + + // Create multiple characters + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("character", { _id: "char2", name: "Character 2", controlledby: player.id }); + createObj("character", { _id: "char3", name: "Character 3", controlledby: player.id }); + + // Set up spy on setTimeout to track when it's called + const setTimeoutSpy = vi.spyOn(global, "setTimeout"); + + // Execute a command that sets attributes on all three characters + executeCommand("!setattr --charid char1,char2,char3 --TestAttr|42"); + vi.runAllTimers(); + + // all three characters should eventually get their attributes + await vi.waitFor(() => { + const char1Attr = findObjs({ _type: "attribute", _characterid: "char1", name: "TestAttr" })[0]; + const char2Attr = findObjs({ _type: "attribute", _characterid: "char2", name: "TestAttr" })[0]; + const char3Attr = findObjs({ _type: "attribute", _characterid: "char3", name: "TestAttr" })[0]; + + expect(char1Attr).toBeDefined(); + expect(char2Attr).toBeDefined(); + expect(char3Attr).toBeDefined(); + + expect(char1Attr.set).toHaveBeenCalledWith({ current: "42" }); + expect(char2Attr.set).toHaveBeenCalledWith({ current: "42" }); + expect(char3Attr.set).toHaveBeenCalledWith({ current: "42" }); + }); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(3); + + // Verify the specific parameters of setTimeout calls + const timeoutCalls = setTimeoutSpy.mock.calls.filter( + call => typeof call[0] === "function" && call[1] === 50 + ); + expect(timeoutCalls.length).toBe(2); + }); + + it("should notify about delays when processing characters", async () => { + vi.useFakeTimers(); + const actualCommand = setTimeout; + vi.spyOn(global, "setTimeout").mockImplementation((callback, delay, ...args) => { + if (delay === 8000) { + // Simulate the delay notification + callback(); + } + return actualCommand(callback, delay, ...args); + }); + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + for (let i = 1; i <= 50; i++) { + createObj("character", { _id: `char${i}`, name: `Character ${i}`, controlledby: player.id }); + } + // Execute a command that sets attributes on multiple characters + executeCommand("!setattr --all --TestAttr|42"); + + // Wait for the notification to be called + vi.runAllTimers(); + await vi.waitFor(() => { + expect(sendChat).toBeCalledTimes(2); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/long time to execute/g), + null, + expect.objectContaining({ + noarchive: true, + }) + ); + }); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/templates/messages.test.ts b/ChatSetAttr/src/__tests__/templates/messages.test.ts new file mode 100644 index 0000000000..f73d092544 --- /dev/null +++ b/ChatSetAttr/src/__tests__/templates/messages.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect } from "vitest"; +import { createChatMessage, createErrorMessage } from "../../templates/messages"; + +describe("messages", () => { + describe("createChatMessage", () => { + it("should create a basic chat message with header and single message", () => { + const header = "Test Header"; + const messages = ["Test message"]; + + const result = createChatMessage(header, messages); + + expect(result).toContain("Test Header"); + expect(result).toContain("Test message"); + expect(result).toContain(""); + expect(result).toContain(" { + const header = "Multiple Messages"; + const messages = ["First message", "Second message", "Third message"]; + + const result = createChatMessage(header, messages); + + expect(result).toContain("Multiple Messages"); + expect(result).toContain("First message"); + expect(result).toContain("Second message"); + expect(result).toContain("Third message"); + + // Should have three paragraph tags + const paragraphCount = (result.match(/

/g) || []).length; + expect(paragraphCount).toBe(3); + }); + + it("should handle empty messages array", () => { + const header = "Empty Messages"; + const messages: string[] = []; + + const result = createChatMessage(header, messages); + + expect(result).toContain("Empty Messages"); + expect(result).toContain(""); + }); + + it("should handle empty header", () => { + const header = ""; + const messages = ["Test message"]; + + const result = createChatMessage(header, messages); + + expect(result).toContain("Test message"); + expect(result).toContain(""); + }); + + it("should apply correct CSS styles for chat messages", () => { + const header = "Styled Header"; + const messages = ["Styled message"]; + + const result = createChatMessage(header, messages); + + // Check for wrapper styles (chat-specific) + expect(result).toContain("border: 1px solid #4dffc7"); + expect(result).toContain("border-radius: 4px"); + expect(result).toContain("padding: 8px"); + expect(result).toContain("background-color: #e6fff5"); + + // Check for header styles + expect(result).toContain("font-size: 1.125rem"); + expect(result).toContain("font-weight: bold"); + expect(result).toContain("margin-bottom: 4px"); + + // Check for body styles + expect(result).toContain("font-size: 0.875rem"); + + // Should NOT contain error-specific styles + expect(result).not.toContain("color: #ff2020"); + expect(result).not.toContain("border: 1px solid #ff7474"); + }); + + it("should maintain proper HTML structure", () => { + const header = "Structure Test"; + const messages = ["Message 1", "Message 2"]; + + const result = createChatMessage(header, messages); + + // Check for proper nesting - outer div contains h3 header and body div + expect(result).toMatch(/]*>]*>Structure Test<\/h3>]*>

Message 1<\/p>

Message 2<\/p><\/div><\/div>/); + }); + + it("should handle special characters in header and messages", () => { + const header = "Special Characters: & < > \" '"; + const messages = ["Message with & < > \" ' characters", "Another message with åäö"]; + + const result = createChatMessage(header, messages); + + expect(result).toContain("Special Characters: & < > \" '"); + expect(result).toContain("Message with & < > \" ' characters"); + expect(result).toContain("Another message with åäö"); + }); + + it("should handle very long header and messages", () => { + const longHeader = "A".repeat(1000); + const longMessage = "B".repeat(2000); + const messages = [longMessage]; + + const result = createChatMessage(longHeader, messages); + + expect(result).toContain(longHeader); + expect(result).toContain(longMessage); + expect(result.length).toBeGreaterThan(3000); + }); + + it("should handle messages with newlines and whitespace", () => { + const header = "Whitespace Test"; + const messages = [ + "Message with\nnewlines", + " Message with spaces ", + "\tMessage with tabs\t" + ]; + + const result = createChatMessage(header, messages); + + expect(result).toContain("Message with\nnewlines"); + expect(result).toContain(" Message with spaces "); + expect(result).toContain("\tMessage with tabs\t"); + }); + + it("should generate consistent output for same inputs", () => { + const header = "Consistency Test"; + const messages = ["Message 1", "Message 2"]; + + const result1 = createChatMessage(header, messages); + const result2 = createChatMessage(header, messages); + + expect(result1).toBe(result2); + }); + }); + + describe("createErrorMessage", () => { + it("should create a basic error message with header and single error", () => { + const header = "Error Header"; + const errors = ["Test error"]; + + const result = createErrorMessage(header, errors); + + expect(result).toContain("Error Header"); + expect(result).toContain("Test error"); + expect(result).toContain(""); + expect(result).toContain(" { + const header = "Multiple Errors"; + const errors = ["First error", "Second error", "Third error"]; + + const result = createErrorMessage(header, errors); + + expect(result).toContain("Multiple Errors"); + expect(result).toContain("First error"); + expect(result).toContain("Second error"); + expect(result).toContain("Third error"); + + // Should have three paragraph tags + const paragraphCount = (result.match(/

/g) || []).length; + expect(paragraphCount).toBe(3); + }); + + it("should handle empty errors array", () => { + const header = "Empty Errors"; + const errors: string[] = []; + + const result = createErrorMessage(header, errors); + + expect(result).toContain("Empty Errors"); + expect(result).toContain(""); + }); + + it("should handle empty header", () => { + const header = ""; + const errors = ["Test error"]; + + const result = createErrorMessage(header, errors); + + expect(result).toContain("Test error"); + expect(result).toContain(""); + }); + + it("should apply correct CSS styles for error messages", () => { + const header = "Error Header"; + const errors = ["Error message"]; + + const result = createErrorMessage(header, errors); + + // Check for wrapper styles (error-specific) + expect(result).toContain("border: 1px solid #ff7474"); + expect(result).toContain("border-radius: 4px"); + expect(result).toContain("padding: 8px"); + expect(result).toContain("background-color: #ffebeb"); + + // Check for header styles (error-specific) + expect(result).toContain("color: #ff2020"); + expect(result).toContain("font-weight: bold"); + expect(result).toContain("font-size: 1.125rem"); + + // Check for body styles + expect(result).toContain("font-size: 0.875rem"); + + // Should NOT contain chat-specific styles + expect(result).not.toContain("border: 1px solid #ccc"); + expect(result).not.toContain("margin-bottom: 5px"); + }); + + it("should maintain proper HTML structure", () => { + const header = "Error Structure Test"; + const errors = ["Error 1", "Error 2"]; + + const result = createErrorMessage(header, errors); + + // Check for proper nesting - outer div contains h3 header and body div + expect(result).toMatch(/]*>]*>Error Structure Test<\/h3>]*>

Error 1<\/p>

Error 2<\/p><\/div><\/div>/); + }); + + it("should handle special characters in header and errors", () => { + const header = "Special Error Characters: & < > \" '"; + const errors = ["Error with & < > \" ' characters", "Another error with åäö"]; + + const result = createErrorMessage(header, errors); + + expect(result).toContain("Special Error Characters: & < > \" '"); + expect(result).toContain("Error with & < > \" ' characters"); + expect(result).toContain("Another error with åäö"); + }); + + it("should handle very long header and errors", () => { + const longHeader = "E".repeat(1000); + const longError = "R".repeat(2000); + const errors = [longError]; + + const result = createErrorMessage(longHeader, errors); + + expect(result).toContain(longHeader); + expect(result).toContain(longError); + expect(result.length).toBeGreaterThan(3000); + }); + + it("should handle errors with newlines and whitespace", () => { + const header = "Error Whitespace Test"; + const errors = [ + "Error with\nnewlines", + " Error with spaces ", + "\tError with tabs\t" + ]; + + const result = createErrorMessage(header, errors); + + expect(result).toContain("Error with\nnewlines"); + expect(result).toContain(" Error with spaces "); + expect(result).toContain("\tError with tabs\t"); + }); + + it("should generate consistent output for same inputs", () => { + const header = "Error Consistency Test"; + const errors = ["Error 1", "Error 2"]; + + const result1 = createErrorMessage(header, errors); + const result2 = createErrorMessage(header, errors); + + expect(result1).toBe(result2); + }); + }); + + describe("message comparison", () => { + it("should produce different styling for chat vs error messages", () => { + const header = "Test Message"; + const content = ["Content"]; + + const chatResult = createChatMessage(header, content); + const errorResult = createErrorMessage(header, content); + + // Should have different wrapper styles + expect(chatResult).toContain("border: 1px solid #4dffc7"); + expect(errorResult).toContain("border: 1px solid #ff7474"); + + // Should have different header styles + expect(chatResult).not.toContain("color: #ff2020"); + expect(errorResult).toContain("color: #ff2020"); + + // Should have different header margin (chat has margin-bottom, error doesn't) + expect(chatResult).toContain("margin-bottom: 4px"); + expect(errorResult).not.toContain("margin-bottom: 4px"); + }); + + it("should have the same basic structure but different styles", () => { + const header = "Test"; + const content = ["Message"]; + + const chatResult = createChatMessage(header, content); + const errorResult = createErrorMessage(header, content); + + // Both should have the same basic HTML structure + expect(chatResult).toMatch(/]*>]*>Test<\/h3>]*>

Message<\/p><\/div><\/div>/); + expect(errorResult).toMatch(/]*>]*>Test<\/h3>]*>

Message<\/p><\/div><\/div>/); + + // But should have different style attributes + expect(chatResult).not.toBe(errorResult); + }); + }); + + describe("edge cases", () => { + it("should handle both functions with identical inputs consistently", () => { + const testCases = [ + { header: "Test", messages: ["msg"] }, + { header: "", messages: [] }, + { header: "Only header", messages: [] }, + { header: "", messages: ["Only message"] } + ]; + + testCases.forEach(({ header, messages }) => { + const chatResult = createChatMessage(header, messages); + const errorResult = createErrorMessage(header, messages); + + // Both should return non-empty strings + expect(chatResult).toBeTruthy(); + expect(errorResult).toBeTruthy(); + expect(typeof chatResult).toBe("string"); + expect(typeof errorResult).toBe("string"); + expect(chatResult.length).toBeGreaterThan(0); + expect(errorResult.length).toBeGreaterThan(0); + + // Both should contain the header and messages + expect(chatResult).toContain(header); + expect(errorResult).toContain(header); + messages.forEach(message => { + expect(chatResult).toContain(message); + expect(errorResult).toContain(message); + }); + }); + }); + + it("should handle null-like values gracefully", () => { + // Test with falsy but valid string values + const chatResult = createChatMessage("0", ["0", "false"]); + const errorResult = createErrorMessage("0", ["0", "false"]); + + expect(chatResult).toContain("0"); + expect(chatResult).toContain("false"); + expect(errorResult).toContain("0"); + expect(errorResult).toContain("false"); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/attributes.test.ts b/ChatSetAttr/src/__tests__/unit/attributes.test.ts new file mode 100644 index 0000000000..ddd136b151 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/attributes.test.ts @@ -0,0 +1,467 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getAttributes, + setAttributes, + setSingleAttribute, + deleteAttributes, + deleteSingleAttribute +} from "../../modules/attributes"; +import type { Attribute, AttributeRecord } from "../../types"; + +// Mock libSmartAttributes +const mockGetAttribute = vi.fn(); +const mockSetAttribute = vi.fn(); +const mockDeleteAttribute = vi.fn(); + +const mocklibSmartAttributes = { + getAttribute: mockGetAttribute, + setAttribute: mockSetAttribute, + deleteAttribute: mockDeleteAttribute +}; + +// Setup global libSmartAttributes mock +global.libSmartAttributes = mocklibSmartAttributes; + +describe("attributes module", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getAttributes", () => { + const target = "character-123"; + + it("should get single attribute from array", async () => { + mockGetAttribute.mockResolvedValue("Test Value"); + + const result = await getAttributes(target, ["strength"]); + + expect(result).toEqual({ strength: "Test Value" }); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "strength", "current"); + }); + + it("should get multiple attributes from array", async () => { + mockGetAttribute + .mockResolvedValueOnce("18") + .mockResolvedValueOnce("16") + .mockResolvedValueOnce("14"); + + const result = await getAttributes(target, ["strength", "dexterity", "constitution"]); + + expect(result).toEqual({ + strength: "18", + dexterity: "16", + constitution: "14" + }); + expect(mockGetAttribute).toHaveBeenCalledTimes(3); + expect(mockGetAttribute).toHaveBeenNthCalledWith(1, target, "strength", "current"); + expect(mockGetAttribute).toHaveBeenNthCalledWith(2, target, "dexterity", "current"); + expect(mockGetAttribute).toHaveBeenNthCalledWith(3, target, "constitution", "current"); + }); + + it("should handle _max suffix for max attributes", async () => { + mockGetAttribute.mockResolvedValue("100"); + + const result = await getAttributes(target, ["hp_max"]); + + expect(result).toEqual({ hp_max: "100" }); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "hp", "max"); + }); + + it("should get attributes from object record", async () => { + mockGetAttribute + .mockResolvedValueOnce("Test") + .mockResolvedValueOnce("Value"); + + const record: AttributeRecord = { name: undefined, description: undefined }; + const result = await getAttributes(target, record); + + expect(result).toEqual({ + name: "Test", + description: "Value" + }); + expect(mockGetAttribute).toHaveBeenCalledTimes(2); + }); + + it("should clean attribute names by removing special characters", async () => { + mockGetAttribute.mockResolvedValue("cleaned"); + + const result = await getAttributes(target, ["test-attr!", "another@attr#"]); + + expect(result).toEqual({ + testattr: "cleaned", + anotherattr: "cleaned" + }); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "testattr", "current"); + expect(mockGetAttribute).toHaveBeenCalledWith(target, "anotherattr", "current"); + }); + + it("should handle libSmartAttributes errors by returning undefined", async () => { + mockGetAttribute.mockRejectedValue(new Error("Attribute not found")); + + const result = await getAttributes(target, ["nonexistent"]); + + expect(result).toEqual({ nonexistent: undefined }); + }); + + it("should handle mixed success and failure", async () => { + mockGetAttribute + .mockResolvedValueOnce("success") + .mockRejectedValueOnce(new Error("failed")); + + const result = await getAttributes(target, ["existing", "missing"]); + + expect(result).toEqual({ + existing: "success", + missing: undefined + }); + }); + + it("should handle empty array", async () => { + const result = await getAttributes(target, []); + + expect(result).toEqual({}); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + + it("should handle empty object", async () => { + const result = await getAttributes(target, {}); + + expect(result).toEqual({}); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + }); + + describe("setSingleAttribute", () => { + const target = "character-123"; + const options = { replace: true }; + + it("should set current attribute", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + await setSingleAttribute(target, "strength", 18, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", options); + }); + + it("should set max attribute when isMax is true", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + await setSingleAttribute(target, "hp", 100, options, true); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", options); + }); + + it("should handle string values", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + await setSingleAttribute(target, "name", "Test Character", options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "name", "Test Character", "current", options); + }); + + it("should handle boolean values", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + await setSingleAttribute(target, "isDead", false, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "isDead", false, "current", options); + }); + + it("should handle numeric values", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + await setSingleAttribute(target, "level", 5, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "level", 5, "current", options); + }); + }); + + describe("setAttributes", () => { + const target = "character-123"; + const options = { replace: true, silent: false }; + + it("should set single attribute with current value", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", options); + }); + + it("should set single attribute with max value", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + const attributes: Attribute[] = [ + { name: "hp", max: 100 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", options); + }); + + it("should set both current and max values", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + const attributes: Attribute[] = [ + { name: "hp", current: 75, max: 100 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(2); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "hp", 75, "current", options); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "hp", 100, "max", options); + }); + + it("should set multiple attributes", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 }, + { name: "dexterity", current: 16 }, + { name: "hp", current: 75, max: 100 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(4); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "strength", 18, "current", options); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "dexterity", 16, "current", options); + expect(mockSetAttribute).toHaveBeenNthCalledWith(3, target, "hp", 75, "current", options); + expect(mockSetAttribute).toHaveBeenNthCalledWith(4, target, "hp", 100, "max", options); + }); + + it("should handle different value types", async () => { + mockSetAttribute.mockResolvedValue(undefined); + + const attributes: Attribute[] = [ + { name: "name", current: "Test Character" }, + { name: "level", current: 5 }, + { name: "isDead", current: false } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(3); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "name", "Test Character", "current", options); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "level", 5, "current", options); + expect(mockSetAttribute).toHaveBeenNthCalledWith(3, target, "isDead", false, "current", options); + }); + + it("should throw error if attribute has no name", async () => { + const attributes: Attribute[] = [ + { current: 18 } // Missing name + ]; + + await expect(setAttributes(target, attributes, options)) + .rejects.toThrow("Attribute must have a name defined."); + }); + + it("should throw error if attribute has neither current nor max value", async () => { + const attributes: Attribute[] = [ + { name: "strength" } // Missing both current and max + ]; + + await expect(setAttributes(target, attributes, options)) + .rejects.toThrow("Attribute must have at least a current or max value defined."); + }); + + it("should handle empty attributes array", async () => { + await setAttributes(target, [], options); + + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it("should handle Promise.all rejections properly", async () => { + mockSetAttribute.mockRejectedValue(new Error("Set failed")); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 } + ]; + + await expect(setAttributes(target, attributes, options)) + .rejects.toThrow("Set failed"); + }); + + it("should execute all operations in parallel", async () => { + const callOrder: number[] = []; + let callCount = 0; + + mockSetAttribute.mockImplementation(async () => { + const currentCall = ++callCount; + callOrder.push(currentCall); + // Simulate async delay + await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); + return undefined; + }); + + const attributes: Attribute[] = [ + { name: "attr1", current: 1 }, + { name: "attr2", current: 2 }, + { name: "attr3", current: 3 } + ]; + + await setAttributes(target, attributes, options); + + expect(mockSetAttribute).toHaveBeenCalledTimes(3); + // All calls should have been initiated quickly (in parallel) + expect(callOrder).toEqual([1, 2, 3]); + }); + }); + + describe("deleteSingleAttribute", () => { + const target = "character-123"; + + it("should delete single attribute", async () => { + mockDeleteAttribute.mockResolvedValue(true); + + await deleteSingleAttribute(target, "oldAttribute"); + + expect(mockDeleteAttribute).toHaveBeenCalledWith(target, "oldAttribute"); + }); + + it("should handle delete failures", async () => { + mockDeleteAttribute.mockRejectedValue(new Error("Delete failed")); + + await expect(deleteSingleAttribute(target, "nonexistent")) + .rejects.toThrow("Delete failed"); + }); + }); + + describe("deleteAttributes", () => { + const target = "character-123"; + + it("should delete single attribute", async () => { + mockDeleteAttribute.mockResolvedValue(true); + + await deleteAttributes(target, ["oldAttribute"]); + + expect(mockDeleteAttribute).toHaveBeenCalledWith(target, "oldAttribute"); + }); + + it("should delete multiple attributes", async () => { + mockDeleteAttribute.mockResolvedValue(true); + + const attributeNames = ["attr1", "attr2", "attr3"]; + await deleteAttributes(target, attributeNames); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + expect(mockDeleteAttribute).toHaveBeenNthCalledWith(1, target, "attr1"); + expect(mockDeleteAttribute).toHaveBeenNthCalledWith(2, target, "attr2"); + expect(mockDeleteAttribute).toHaveBeenNthCalledWith(3, target, "attr3"); + }); + + it("should handle empty array", async () => { + await deleteAttributes(target, []); + + expect(mockDeleteAttribute).not.toHaveBeenCalled(); + }); + + it("should handle mixed success and failure", async () => { + mockDeleteAttribute + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error("Delete failed")) + .mockResolvedValueOnce(true); + + await expect(deleteAttributes(target, ["attr1", "attr2", "attr3"])) + .rejects.toThrow("Delete failed"); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should execute all deletions in parallel", async () => { + const callOrder: number[] = []; + let callCount = 0; + + mockDeleteAttribute.mockImplementation(async () => { + const currentCall = ++callCount; + callOrder.push(currentCall); + // Simulate async delay + await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); + return true; + }); + + const attributeNames = ["attr1", "attr2", "attr3"]; + await deleteAttributes(target, attributeNames); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + // All calls should have been initiated quickly (in parallel) + expect(callOrder).toEqual([1, 2, 3]); + }); + + it("should handle different return types from libSmartAttributes", async () => { + mockDeleteAttribute + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(undefined); + + await deleteAttributes(target, ["attr1", "attr2", "attr3"]); + + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + }); + }); + + describe("integration tests", () => { + const target = "character-123"; + + it("should handle a complete workflow", async () => { + // Setup mocks + mockGetAttribute.mockResolvedValue(undefined); // Attribute doesn't exist + mockSetAttribute.mockResolvedValue(undefined); + mockDeleteAttribute.mockResolvedValue(true); + + // Get attribute (should be undefined initially) + const initialValue = await getAttributes(target, ["strength"]); + expect(initialValue).toEqual({ strength: undefined }); + + // Set attribute + await setAttributes(target, [{ name: "strength", current: 18 }], {}); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", {}); + + // Mock that attribute now exists + mockGetAttribute.mockResolvedValue(18); + const updatedValue = await getAttributes(target, ["strength"]); + expect(updatedValue).toEqual({ strength: 18 }); + + // Delete attribute + await deleteAttributes(target, ["strength"]); + expect(mockDeleteAttribute).toHaveBeenCalledWith(target, "strength"); + }); + + it("should handle batch operations efficiently", async () => { + mockSetAttribute.mockResolvedValue(undefined); + mockDeleteAttribute.mockResolvedValue(true); + + const attributes: Attribute[] = [ + { name: "str", current: 18, max: 20 }, + { name: "dex", current: 16, max: 18 }, + { name: "con", current: 14, max: 16 } + ]; + + // Set all attributes + await setAttributes(target, attributes, {}); + expect(mockSetAttribute).toHaveBeenCalledTimes(6); // 3 current + 3 max + + // Delete all attributes + await deleteAttributes(target, ["str", "dex", "con"]); + expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should handle error scenarios gracefully", async () => { + // Test that errors in individual operations are properly propagated + mockSetAttribute.mockRejectedValue(new Error("Permission denied")); + + const attributes: Attribute[] = [ + { name: "strength", current: 18 } + ]; + + await expect(setAttributes(target, attributes, {})) + .rejects.toThrow("Permission denied"); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/chat.test.ts b/ChatSetAttr/src/__tests__/unit/chat.test.ts new file mode 100644 index 0000000000..acf42955c0 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/chat.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getPlayerName, sendMessages, sendErrors } from "../../modules/chat"; + +// Mock the templates +vi.mock("../../templates/messages", () => ({ + createChatMessage: vi.fn(), + createErrorMessage: vi.fn(), +})); + +// Mock Roll20 globals +const mockPlayer = { + get: vi.fn(), +}; + +const mockGetObj = vi.fn(); +const mockSendChat = vi.fn(); + +global.getObj = mockGetObj; +global.sendChat = mockSendChat; + +import { createChatMessage, createErrorMessage } from "../../templates/messages"; +const mockCreateChatMessage = vi.mocked(createChatMessage); +const mockCreateErrorMessage = vi.mocked(createErrorMessage); + +describe("chat", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getPlayerName", () => { + it("should return player display name when player exists", () => { + mockPlayer.get.mockReturnValue("John Doe"); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player123"); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player123"); + expect(mockPlayer.get).toHaveBeenCalledWith("_displayname"); + expect(result).toBe("John Doe"); + }); + + it("should return 'Unknown Player' when player does not exist", () => { + mockGetObj.mockReturnValue(null); + + const result = getPlayerName("nonexistent"); + + expect(mockGetObj).toHaveBeenCalledWith("player", "nonexistent"); + expect(result).toBe("Unknown Player"); + }); + + it("should return 'Unknown Player' when player exists but has no display name", () => { + mockPlayer.get.mockReturnValue(null); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player456"); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player456"); + expect(mockPlayer.get).toHaveBeenCalledWith("_displayname"); + expect(result).toBe("Unknown Player"); + }); + + it("should return 'Unknown Player' when player exists but display name is undefined", () => { + mockPlayer.get.mockReturnValue(undefined); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player789"); + + expect(result).toBe("Unknown Player"); + }); + + it("should return empty string when player has empty display name", () => { + mockPlayer.get.mockReturnValue(""); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player101"); + + expect(result).toBe(""); + }); + + it("should handle display names with special characters", () => { + mockPlayer.get.mockReturnValue("Player-42_Test!"); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player102"); + + expect(result).toBe("Player-42_Test!"); + }); + + it("should handle display names with spaces", () => { + mockPlayer.get.mockReturnValue(" Spaced Name "); + mockGetObj.mockReturnValue(mockPlayer); + + const result = getPlayerName("player103"); + + expect(result).toBe(" Spaced Name "); + }); + }); + + describe("sendMessages", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateChatMessage.mockReturnValue("formatted-chat-message"); + }); + + it("should send a whispered chat message to the player", () => { + const messages = ["Message 1", "Message 2"]; + + sendMessages("player123", "Test Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Test Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should handle empty messages array", () => { + const messages: string[] = []; + + sendMessages("player123", "Empty Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Empty Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should handle single message", () => { + const messages = ["Single message"]; + + sendMessages("player123", "Single Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Single Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should handle messages with special characters", () => { + const messages = ["Message with \"quotes\"", "Message with \"apostrophes\"", "Message with "]; + + sendMessages("player123", "Special Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Special Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should handle unknown player correctly", () => { + mockGetObj.mockReturnValue(null); + const messages = ["Test message"]; + + sendMessages("unknown-player", "Test Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Test Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Unknown Player\" formatted-chat-message"); + }); + + it("should handle player names with quotes", () => { + mockPlayer.get.mockReturnValue("Player \"Nickname\" Smith"); + mockGetObj.mockReturnValue(mockPlayer); + const messages = ["Test message"]; + + sendMessages("player123", "Test Header", messages); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Player \"Nickname\" Smith\" formatted-chat-message"); + }); + + it("should handle empty header", () => { + const messages = ["Test message"]; + + sendMessages("player123", "", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("", messages); + }); + + it("should handle long message arrays", () => { + const messages = Array.from({ length: 100 }, (_, i) => `Message ${i + 1}`); + + sendMessages("player123", "Long Header", messages); + + expect(mockCreateChatMessage).toHaveBeenCalledWith("Long Header", messages); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + }); + + describe("sendErrors", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateErrorMessage.mockReturnValue("formatted-error-message"); + }); + + it("should not send message when errors array is empty", () => { + const errors: string[] = []; + + sendErrors("player123", "Error Header", errors); + + expect(mockCreateErrorMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should send error message when errors exist", () => { + const errors = ["Error 1", "Error 2"]; + + sendErrors("player123", "Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should handle single error", () => { + const errors = ["Single error"]; + + sendErrors("player123", "Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should handle errors with special characters", () => { + const errors = ["Error with \"quotes\"", "Error with ", "Error with & symbols"]; + + sendErrors("player123", "Special Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Special Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should handle unknown player correctly", () => { + mockGetObj.mockReturnValue(null); + const errors = ["Test error"]; + + sendErrors("unknown-player", "Error Header", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Error Header", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Unknown Player\" formatted-error-message"); + }); + + it("should handle empty header", () => { + const errors = ["Test error"]; + + sendErrors("player123", "", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("", errors); + }); + + it("should handle long error arrays", () => { + const errors = Array.from({ length: 50 }, (_, i) => `Error ${i + 1}`); + + sendErrors("player123", "Many Errors", errors); + + expect(mockCreateErrorMessage).toHaveBeenCalledWith("Many Errors", errors); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should handle player names with special whisper characters", () => { + mockPlayer.get.mockReturnValue("Player@123"); + mockGetObj.mockReturnValue(mockPlayer); + const errors = ["Test error"]; + + sendErrors("player123", "Error Header", errors); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Player@123\" formatted-error-message"); + }); + }); + + describe("integration scenarios", () => { + beforeEach(() => { + mockCreateChatMessage.mockReturnValue("chat-message"); + mockCreateErrorMessage.mockReturnValue("error-message"); + }); + + it("should handle multiple calls with same player", () => { + mockPlayer.get.mockReturnValue("Consistent Player"); + mockGetObj.mockReturnValue(mockPlayer); + + sendMessages("player123", "Header 1", ["Message 1"]); + sendErrors("player123", "Error Header", ["Error 1"]); + sendMessages("player123", "Header 2", ["Message 2"]); + + expect(mockSendChat).toHaveBeenCalledTimes(3); + expect(mockSendChat).toHaveBeenNthCalledWith(1, "ChatSetAttr", "/w \"Consistent Player\" chat-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(2, "ChatSetAttr", "/w \"Consistent Player\" error-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(3, "ChatSetAttr", "/w \"Consistent Player\" chat-message"); + }); + + it("should handle different players in sequence", () => { + const mockPlayer1 = { get: vi.fn().mockReturnValue("Player One") }; + const mockPlayer2 = { get: vi.fn().mockReturnValue("Player Two") }; + + mockGetObj.mockImplementation((type, id) => { + if (id === "player1") return mockPlayer1; + if (id === "player2") return mockPlayer2; + return null; + }); + + sendMessages("player1", "Header", ["Message for P1"]); + sendMessages("player2", "Header", ["Message for P2"]); + + expect(mockSendChat).toHaveBeenNthCalledWith(1, "ChatSetAttr", "/w \"Player One\" chat-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(2, "ChatSetAttr", "/w \"Player Two\" chat-message"); + }); + + it("should handle mixed success and error scenarios", () => { + mockPlayer.get.mockReturnValue("Mixed Player"); + mockGetObj.mockReturnValue(mockPlayer); + + // Send success message first + sendMessages("player123", "Success", ["Operation completed"]); + + // Try to send empty error (should not send) + sendErrors("player123", "No Errors", []); + + // Send actual error + sendErrors("player123", "Real Error", ["Something went wrong"]); + + expect(mockSendChat).toHaveBeenCalledTimes(2); // Only success and real error + expect(mockSendChat).toHaveBeenNthCalledWith(1, "ChatSetAttr", "/w \"Mixed Player\" chat-message"); + expect(mockSendChat).toHaveBeenNthCalledWith(2, "ChatSetAttr", "/w \"Mixed Player\" error-message"); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/commands.test.ts b/ChatSetAttr/src/__tests__/unit/commands.test.ts new file mode 100644 index 0000000000..8ebc902e6a --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/commands.test.ts @@ -0,0 +1,558 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Attribute } from "../../types"; +import { + setattr, + modattr, + modbattr, + resetattr, + delattr, + handlers, +} from "../../modules/commands"; +import { getAttributes } from "../../modules/attributes"; + +// Mock the attributes module +vi.mock("../../modules/attributes", () => ({ + getAttributes: vi.fn(), +})); + +const mockGetAttributes = vi.mocked(getAttributes); + +const feedbackMock = { public: false }; + +describe("commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("setattr", () => { + it("should set current values for attributes", async () => { + const changes: Attribute[] = [ + { name: "strength", current: 15 }, + { name: "dexterity", current: 12 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 15, + dexterity: 12, + }); + expect(result.messages).toEqual([ + "Set attribute 'strength' on ID: char1.", + "Set attribute 'dexterity' on ID: char1.", + ]); + expect(result.errors).toEqual([]); + }); + + it("should set max values for attributes", async () => { + const changes: Attribute[] = [ + { name: "hp", max: 25 }, + { name: "mp", max: 15 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 25, + mp_max: 15, + }); + expect(result.messages).toEqual([ + "Set attribute 'hp' on ID: char1.", + "Set attribute 'mp' on ID: char1.", + ]); + expect(result.errors).toEqual([]); + }); + + it("should set both current and max values", async () => { + const changes: Attribute[] = [ + { name: "hp", current: 20, max: 25 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 20, + hp_max: 25, + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + { current: 15 }, // no name + { name: "strength", current: 16 }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 16, + }); + }); + + it("should handle string and boolean values", async () => { + const changes: Attribute[] = [ + { name: "name", current: "Gandalf" }, + { name: "active", current: true }, + ]; + + const result = await setattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + name: "Gandalf", + active: true, + }); + }); + }); + + describe("modattr", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + strength: 10, + hp: 15, + hp_max: 20, + }); + }); + + it("should modify current values with addition", async () => { + const changes: Attribute[] = [ + { name: "strength", current: "+5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 15, + }); + }); + + it("should modify current values with subtraction", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "-3" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 12, + }); + }); + + it("should modify current values with multiplication", async () => { + const changes: Attribute[] = [ + { name: "strength", current: "*2" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 20, + }); + }); + + it("should modify current values with division", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "/3" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 5, + }); + }); + + it("should handle division by zero safely", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "/0" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 15, // original value unchanged + }); + }); + + it("should modify max values", async () => { + const changes: Attribute[] = [ + { name: "hp", max: "+5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 25, + }); + }); + + it("should handle absolute values (no operator)", async () => { + const changes: Attribute[] = [ + { name: "strength", current: 18 }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 28, // 10 + 18 (treated as addition) + }); + }); + + it("should handle undefined base values", async () => { + mockGetAttributes.mockResolvedValue({}); + + const changes: Attribute[] = [ + { name: "newattr", current: "+5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + newattr: 5, + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + { current: "+5" }, // no name + { name: "strength", current: "+2" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 12, + }); + }); + }); + + describe("modbattr", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + hp: 15, + hp_max: 20, + mp: 8, + mp_max: 10, + }); + }); + + it("should modify current value and enforce bounds", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "+10" }, // current goes to 25, max stays 20 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 20, + }); + }); + + it("should modify max value and adjust current if needed", async () => { + const changes: Attribute[] = [ + { name: "hp", max: "-5" }, // max becomes 15, current is 15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 15, + hp: 15, // current bounded by new max + }); + }); + + it("should modify both current and max values", async () => { + const changes: Attribute[] = [ + { name: "mp", current: "+5", max: "+5" }, // current: 13, max: 15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + mp: 13, + mp_max: 15, + }); + }); + + it("should handle case where current exceeds new max", async () => { + const changes: Attribute[] = [ + { name: "hp", max: "-10" }, // max becomes 10, current is 15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp_max: 10, + hp: 10, // current reduced to new max + }); + }); + + it("should handle undefined max values gracefully", async () => { + mockGetAttributes.mockResolvedValue({ + newattr: 5, + }); + + const changes: Attribute[] = [ + { name: "newattr", current: "+3" }, + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + newattr: 8, // no max constraint + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + { current: "+5", max: "+5" }, // no name + { name: "hp", current: "+1" }, + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 16, + }); + }); + }); + + describe("resetattr", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + hp: 10, + hp_max: 25, + mp: 5, + mp_max: 15, + strength: 12, // no max value + }); + }); + + it("should reset current to max value", async () => { + const changes: Attribute[] = [ + { name: "hp" }, + { name: "mp" }, + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 25, + mp: 15, + }); + }); + + it("should reset to 0 when no max value exists", async () => { + const changes: Attribute[] = [ + { name: "strength" }, + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + strength: 0, + }); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + {}, // no name + { name: "hp" }, + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 25, + }); + }); + + it("should handle mixed scenarios", async () => { + const changes: Attribute[] = [ + { name: "hp" }, // has max + { name: "strength" }, // no max + ]; + + const result = await resetattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 25, + strength: 0, + }); + }); + }); + + describe("delattr", () => { + it("should mark attributes for deletion", async () => { + const changes: Attribute[] = [ + { name: "oldattr" }, + { name: "tempattr" }, + ]; + + const result = await delattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + oldattr: undefined, + oldattr_max: undefined, + tempattr: undefined, + tempattr_max: undefined, + }); + expect(result.messages).toEqual([ + "Deleted attribute 'oldattr' on ID: char1.", + "Deleted attribute 'tempattr' on ID: char1.", + ]); + expect(result.errors).toEqual([]); + }); + + it("should skip attributes without names", async () => { + const changes: Attribute[] = [ + {}, // no name + { name: "validattr" }, + ]; + + const result = await delattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + validattr: undefined, + validattr_max: undefined, + }); + }); + + it("should handle empty changes array", async () => { + const changes: Attribute[] = []; + + const result = await delattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({}); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + }); + + describe("handlers dictionary", () => { + it("should contain all command handlers", () => { + expect(handlers).toHaveProperty("setattr", setattr); + expect(handlers).toHaveProperty("modattr", modattr); + expect(handlers).toHaveProperty("modbattr", modbattr); + expect(handlers).toHaveProperty("resetattr", resetattr); + expect(handlers).toHaveProperty("delattr", delattr); + }); + + it("should have correct handler signatures", () => { + expect(typeof handlers.setattr).toBe("function"); + expect(typeof handlers.modattr).toBe("function"); + expect(typeof handlers.modbattr).toBe("function"); + expect(typeof handlers.resetattr).toBe("function"); + expect(typeof handlers.delattr).toBe("function"); + }); + }); + + describe("edge cases and error handling", () => { + it("should handle NaN values in modifications", async () => { + mockGetAttributes.mockResolvedValue({ + attr: "not-a-number", + }); + + const changes: Attribute[] = [ + { name: "attr", current: "+invalid" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.errors).toContain("Attribute 'attr' is not number-valued and so cannot be modified."); + }); + + it("should handle very large numbers", async () => { + mockGetAttributes.mockResolvedValue({ + bignum: 1000000, + }); + + const changes: Attribute[] = [ + { name: "bignum", current: "*1000" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + bignum: 1000000000, + }); + }); + + it("should handle negative results in bounded attributes", async () => { + mockGetAttributes.mockResolvedValue({ + resource: 5, + resource_max: 10, + }); + + const changes: Attribute[] = [ + { name: "resource", current: "-20" }, // would go to -15 + ]; + + const result = await modbattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + resource: 0, + }); + }); + }); + + describe("integration scenarios", () => { + beforeEach(() => { + mockGetAttributes.mockResolvedValue({ + hp: 15, + hp_max: 20, + mp: 8, + mp_max: 10, + strength: 14, + dexterity: 12, + }); + }); + + it("should handle multiple attributes in a single command", async () => { + const changes: Attribute[] = [ + { name: "hp", current: "+5" }, + { name: "mp", current: "-2" }, + { name: "strength", current: "*1.5" }, + ]; + + const result = await modattr(changes, "char1", [], false, feedbackMock); + + expect(result.result).toEqual({ + hp: 20, + mp: 6, + strength: 21, // 14 * 1.5 = 21 + }); + }); + + it("should handle attribute queries with referenced attributes", async () => { + mockGetAttributes.mockImplementation((target, attributeNames) => { + const allAttrs = { + hp: 15, + hp_max: 20, + mp: 8, + mp_max: 10, + strength: 14, + referenced_attr: 5, + }; + + const result: Record = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + result[name] = allAttrs[name as keyof typeof allAttrs]; + } + } + return Promise.resolve(result); + }); + + const changes: Attribute[] = [ + { name: "hp", current: "+2" }, + ]; + + const result = await modattr(changes, "char1", ["referenced_attr"], false, feedbackMock); + + expect(mockGetAttributes).toHaveBeenCalledWith("char1", expect.arrayContaining(["referenced_attr", "hp"])); + expect(result.result).toEqual({ + hp: 17, + }); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/config.test.ts b/ChatSetAttr/src/__tests__/unit/config.test.ts new file mode 100644 index 0000000000..1109da6bc0 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/config.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getConfig, setConfig } from "../../modules/config"; + +describe("config", () => { + beforeEach(() => { + // Reset state before each test + global.state = { + ChatSetAttr: {} + }; + }); + + describe("getConfig", () => { + it("should return default config when no state config exists", () => { + // Clear the state entirely + global.state = {}; + + const config = getConfig(); + + expect(config).toEqual({ + version: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + flags: [] + }); + }); + + it("should return default config when ChatSetAttr state is undefined", () => { + global.state = { ChatSetAttr: undefined }; + + + const config = getConfig(); + + expect(config).toEqual({ + version: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + flags: [] + }); + }); + + it("should return default config when config property is undefined", () => { + global.state = { ChatSetAttr: {} }; + + const config = getConfig(); + + expect(config).toEqual({ + version: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + flags: [] + }); + }); + + it("should merge state config with default config", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + playersCanEvaluate: true + }; + + const config = getConfig(); + + expect(config).toEqual({ + version: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true, + flags: [] + }); + }); + + it("should override default values with state values", () => { + global.state.ChatSetAttr = { + version: 4, + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: false, + globalconfigCache: { + lastsaved: 1234567890 + } + }; + + const config = getConfig(); + + expect(config).toEqual({ + version: 4, + globalconfigCache: { + lastsaved: 1234567890 + }, + playersCanTargetParty: true, + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: false, + flags: [] + }); + }); + + it("should handle partial globalconfigCache override", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 9876543210 + } + }; + + const config = getConfig(); + + expect(config.globalconfigCache).toEqual({ + lastsaved: 9876543210 + }); + expect(config.version).toBe("2.0"); + expect(config.playersCanModify).toBe(false); + }); + + it("should handle extra properties in state config", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + extraProperty: "should be included", + anotherExtra: 42 + }; + + const config = getConfig(); + + expect(config).toEqual({ + version: "2.0", + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: true, + playersCanEvaluate: false, + useWorkers: true, + flags: [], + extraProperty: "should be included", + anotherExtra: 42 + }); + }); + + it("should return a new object each time (not reference to state)", () => { + const config1 = getConfig(); + const config2 = getConfig(); + + expect(config1).not.toBe(config2); + expect(config1).toEqual(config2); + }); + }); + + describe("setConfig", () => { + it("should set config in empty state", () => { + global.state = {}; + + + setConfig({ playersCanModify: true }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + }); + + it("should initialize ChatSetAttr when undefined", () => { + global.state = { ChatSetAttr: undefined }; + + + setConfig({ playersCanEvaluate: true }); + + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + }); + + it("should merge new config with existing config", () => { + global.state.ChatSetAttr = { + playersCanModify: true, + version: 2 + }; + + setConfig({ playersCanEvaluate: true }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.version).toBe(2); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + }); + + it("should override existing config values", () => { + global.state.ChatSetAttr = { + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true + }; + + setConfig({ + playersCanModify: true, + useWorkers: false + }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(false); + expect(global.state.ChatSetAttr.useWorkers).toBe(false); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + }); + + it("should handle complex nested objects", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000 + } + }; + + setConfig({ + globalconfigCache: { + lastsaved: 2000, + newProperty: "test" + } + }); + + // setConfig always overwrites globalconfigCache.lastsaved with Date.now() + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBeGreaterThan(2000); + }); + + it("should preserve other ChatSetAttr properties", () => { + global.state = { + ChatSetAttr: { + playersCanModify: false, + otherProperty: "should be preserved", + anotherProp: 123 + } + }; + + + setConfig({ playersCanEvaluate: true }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(false); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(global.state.ChatSetAttr.otherProperty).toBe("should be preserved"); + expect(global.state.ChatSetAttr.anotherProp).toBe(123); + }); + + it("should handle empty config object", () => { + global.state.ChatSetAttr = { + playersCanModify: true + }; + + setConfig({}); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + }); + + it("should handle null and undefined values", () => { + global.state.ChatSetAttr = { + playersCanModify: true + }; + + setConfig({ + playersCanEvaluate: null, + useWorkers: undefined, + version: 0 + }); + + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(null); + expect(global.state.ChatSetAttr.useWorkers).toBe(undefined); + expect(global.state.ChatSetAttr.version).toBe(0); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + }); + + it("should handle various data types", () => { + setConfig({ + stringProp: "test", + numberProp: 42, + booleanProp: true, + arrayProp: [1, 2, 3], + objectProp: { nested: "value" }, + nullProp: null, + undefinedProp: undefined + }); + + expect(global.state.ChatSetAttr.stringProp).toBe("test"); + expect(global.state.ChatSetAttr.numberProp).toBe(42); + expect(global.state.ChatSetAttr.booleanProp).toBe(true); + expect(global.state.ChatSetAttr.arrayProp).toEqual([1, 2, 3]); + expect(global.state.ChatSetAttr.objectProp).toEqual({ nested: "value" }); + expect(global.state.ChatSetAttr.nullProp).toBe(null); + expect(global.state.ChatSetAttr.undefinedProp).toBe(undefined); + expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); + expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + }); + }); + + describe("integration tests", () => { + it("should work correctly when setting and getting config", () => { + // Start with empty state + global.state = {}; + + + // Set some config + setConfig({ + playersCanModify: true, + version: 5 + }); + + // Get config should return defaults merged with set values + const config = getConfig(); + + expect(config.version).toBe(5); + expect(config.playersCanModify).toBe(true); + expect(config.playersCanEvaluate).toBe(false); + expect(config.playersCanTargetParty).toBe(true); + expect(config.useWorkers).toBe(true); + expect(config.globalconfigCache).toBeDefined(); + expect(typeof config.globalconfigCache.lastsaved).toBe("number"); + }); + + it("should handle multiple setConfig calls", () => { + global.state = {}; + + + setConfig({ playersCanModify: true }); + setConfig({ playersCanEvaluate: true }); + setConfig({ useWorkers: false }); + + const config = getConfig(); + + expect(config.playersCanModify).toBe(true); + expect(config.playersCanEvaluate).toBe(true); + expect(config.useWorkers).toBe(false); + expect(config.version).toBe("2.0"); // Default value + }); + + it("should handle overriding previously set values", () => { + global.state = {}; + + + setConfig({ playersCanModify: true }); + expect(getConfig().playersCanModify).toBe(true); + + setConfig({ playersCanModify: false }); + expect(getConfig().playersCanModify).toBe(false); + }); + + it("should maintain state persistence between calls", () => { + global.state = {}; + + + setConfig({ playersCanModify: true }); + setConfig({ playersCanEvaluate: true }); + + // Both values should persist + const config = getConfig(); + expect(config.playersCanModify).toBe(true); + expect(config.playersCanEvaluate).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should handle when state is null", () => { + // @ts-expect-error we're deliberately setting to null + global.state = null; + + + expect(() => getConfig()).not.toThrow(); + }); + + it("should handle when state is undefined", () => { + // @ts-expect-error we're deliberately setting to undefined + global.state = undefined; + + + expect(() => getConfig()).not.toThrow(); + }); + + it("should handle circular references in setConfig", () => { + global.state = {}; + + + const circularObj: Record = { a: 1 }; + circularObj.self = circularObj; + + expect(() => setConfig(circularObj)).not.toThrow(); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/feedback.test.ts b/ChatSetAttr/src/__tests__/unit/feedback.test.ts new file mode 100644 index 0000000000..ff071e05f6 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/feedback.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { createFeedbackMessage } from "../../modules/feedback"; +import type { AttributeRecord, FeedbackObject } from "../../types"; + +describe("createFeedbackMessage", () => { + const mockStartingValues: AttributeRecord = { + hp: 10, + hp_max: 20, + strength: 15, + strength_max: 18 + }; + + const mockTargetValues: AttributeRecord = { + hp: 25, + hp_max: 30, + strength: 16, + strength_max: 20 + }; + + it("should return empty string when feedback is undefined", () => { + const result = createFeedbackMessage("John", undefined, mockStartingValues, mockTargetValues); + expect(result).toBe(""); + }); + + it("should return feedback content when no placeholders exist", () => { + const feedback: FeedbackObject = { public: false, content: "Simple message" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Simple message"); + }); + + it("should replace _CHARNAME_ with character name", () => { + const feedback: FeedbackObject = { public: false, content: "Hello _CHARNAME_!" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Hello John!"); + }); + + it("should replace _NAME0_ with first attribute name", () => { + const feedback: FeedbackObject = { public: false, content: "Changed _NAME0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Changed hp"); + }); + + it("should replace _TCUR0_ with starting current value", () => { + const feedback: FeedbackObject = { public: false, content: "From _TCUR0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("From 10"); + }); + + it("should replace _TMAX0_ with starting max value", () => { + const feedback: FeedbackObject = { public: false, content: "Max was _TMAX0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Max was 20"); + }); + + it("should replace _CUR0_ with target current value", () => { + const feedback: FeedbackObject = { public: false, content: "Now _CUR0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Now 25"); + }); + + it("should replace _MAX0_ with target max value", () => { + const feedback: FeedbackObject = { public: false, content: "Max now _MAX0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Max now 30"); + }); + + it("should handle multiple placeholders in one message", () => { + const feedback: FeedbackObject = { + public: false, + content: "_CHARNAME_: _NAME0_ changed from _TCUR0_ to _CUR0_ (max: _TMAX0_ to _MAX0_)" + }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("John: hp changed from 10 to 25 (max: 20 to 30)"); + }); + + it("should handle different attribute indices", () => { + const feedback: FeedbackObject = { public: false, content: "_NAME1_ is _CUR1_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("strength is 16"); + }); + + it("should return empty string for invalid attribute index", () => { + const feedback: FeedbackObject = { public: false, content: "_NAME99_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe(""); + }); + + it("should handle missing max attributes gracefully", () => { + const limitedStarting: AttributeRecord = { hp: 10 }; + const limitedTarget: AttributeRecord = { hp: 25 }; + const feedback: FeedbackObject = { public: false, content: "_TMAX0_ to _MAX0_" }; + const result = createFeedbackMessage("John", feedback, limitedStarting, limitedTarget); + expect(result).toBe("undefined to undefined"); + }); + + it("should handle empty target values", () => { + const feedback: FeedbackObject = { public: false, content: "_NAME0_" }; + const result = createFeedbackMessage("John", feedback, mockStartingValues, {}); + expect(result).toBe(""); + }); + + it("should handle complex message with multiple attributes", () => { + const feedback: FeedbackObject = { + public: false, + content: "_CHARNAME_ updated: _NAME0_: _TCUR0_→_CUR0_, _NAME1_: _TCUR1_→_CUR1_" + }; + const result = createFeedbackMessage("Alice", feedback, mockStartingValues, mockTargetValues); + expect(result).toBe("Alice updated: hp: 10→25, strength: 15→16"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/helpers.test.ts b/ChatSetAttr/src/__tests__/unit/helpers.test.ts new file mode 100644 index 0000000000..a8180cbdbf --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/helpers.test.ts @@ -0,0 +1,82 @@ +import { it, expect, describe } from "vitest"; +import { toStringOrUndefined, calculateBoundValue, cleanValue } from "../../modules/helpers"; + +describe("toStringOrUndefined", () => { + it("returns undefined for undefined input", () => { + expect(toStringOrUndefined(undefined)).toBeUndefined(); + }); + + it("returns undefined for null input", () => { + expect(toStringOrUndefined(null)).toBeUndefined(); + }); + + it("converts numbers to strings", () => { + expect(toStringOrUndefined(42)).toBe("42"); + }); + + it("converts booleans to strings", () => { + expect(toStringOrUndefined(true)).toBe("true"); + expect(toStringOrUndefined(false)).toBe("false"); + }); + + it("returns strings unchanged", () => { + expect(toStringOrUndefined("hello")).toBe("hello"); + }); +}); + +describe("calculateBoundValue", () => { + it("returns 0 when value and max are undefined", () => { + expect(calculateBoundValue(undefined, undefined)).toBe(0); + }); + + it("returns the value when max is undefined", () => { + expect(calculateBoundValue(10, undefined)).toBe(10); + }); + + it("returns 0 when value is undefined", () => { + expect(calculateBoundValue(undefined, 20)).toBe(0); + }); + + it("returns the value when it is less than max", () => { + expect(calculateBoundValue(15, 20)).toBe(15); + }); + + it("returns max when value exceeds max", () => { + expect(calculateBoundValue(25, 20)).toBe(20); + }); +}); + +describe("cleanValue", () => { + it("trims whitespace from the value", () => { + expect(cleanValue(" hello world ")).toBe("hello world"); + }); + + it("removes surrounding single quotes", () => { + expect(cleanValue("'hello'")).toBe("hello"); + expect(cleanValue(" 'hello' ")).toBe("hello"); + }); + + it("removes surrounding double quotes", () => { + expect(cleanValue("\"hello\"")).toBe("hello"); + expect(cleanValue(" \"hello\" ")).toBe("hello"); + }); + + it("removes surrounding mixed quotes", () => { + expect(cleanValue("'hello\"")).toBe("hello"); + expect(cleanValue("\"hello'")).toBe("hello"); + }); + + it("maintains spacing within the value", () => { + expect(cleanValue(" ' hello world ' ")).toBe(" hello world "); + }); + + it("only replaces surrounding quotes", () => { + expect(cleanValue(" \"he'llo\" ")).toBe("he'llo"); + expect(cleanValue(" 'he\"llo' ")).toBe("he\"llo"); + }); + + it("handles symbols and special characters in quotes", () => { + expect(cleanValue("'@#$% special chars '")).toBe("@#$% special chars "); + expect(cleanValue("\"1234!@#$%^&*()_+\"")).toBe("1234!@#$%^&*()_+"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/message.test.ts b/ChatSetAttr/src/__tests__/unit/message.test.ts new file mode 100644 index 0000000000..7394568291 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -0,0 +1,643 @@ +import { describe, it, expect } from "vitest"; +import { + extractMessageFromRollTemplate, + parseMessage, +} from "../../modules/message"; + +describe("message", () => { + describe("extractMessageFromRollTemplate", () => { + const createMockMessage = (content: string): Roll20ChatMessage => ({ + content, + who: "TestPlayer", + type: "general", + playerid: "player123", + rolltemplate: undefined, + inlinerolls: undefined, + selected: undefined, + }); + + describe("valid command extraction", () => { + it("should extract setattr command", () => { + const msg = createMockMessage("&{template:default} {{name=Test}} {{setattr=!setattr --strength 18!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength 18"); + }); + + it("should extract modattr command", () => { + const msg = createMockMessage("&{template:default} {{modattr=!modattr --strength +2!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!modattr --strength +2"); + }); + + it("should extract modbattr command", () => { + const msg = createMockMessage("&{template:default} {{modbattr=!modbattr --hitpoints +5!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!modbattr --hitpoints +5"); + }); + + it("should extract resetattr command", () => { + const msg = createMockMessage("&{template:default} {{resetattr=!resetattr --strength!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!resetattr --strength"); + }); + + it("should extract delattr command", () => { + const msg = createMockMessage("&{template:default} {{delattr=!delattr --skill_athletics!!!}} {{other=content}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!delattr --skill_athletics"); + }); + }); + + describe("complex command extraction", () => { + it("should extract command with multiple options", () => { + const msg = createMockMessage("&{template:default} {{r1=[[1d20]]}} !setattr --sel --strength|18|20 --dexterity|14 --silent!!! {{r2=[[2d6]]}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --sel --strength|18|20 --dexterity|14 --silent"); + }); + + it("should extract command with placeholders", () => { + const msg = createMockMessage("&{template:default} {{r1=[[1d20]]}} !modattr --sel --hitpoints|+%constitution_modifier%!!! {{r2=[[2d6]]}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!modattr --sel --hitpoints|+%constitution_modifier%"); + }); + + it("should handle whitespace in commands", () => { + const msg = createMockMessage("&{template:default} {{r1=[[1d20]]}} !setattr --strength 18 !!! {{r2=[[2d6]]}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength 18"); + }); + }); + + describe("multiple commands", () => { + it("should extract first matching command when multiple are present", () => { + const msg = createMockMessage("{{setattr=!setattr --strength 18!!!}} {{modattr=!modattr --dex +2!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength 18"); + }); + }); + + describe("invalid cases", () => { + it("should return false when no commands are found", () => { + const msg = createMockMessage("&{template:default} {{name=Test}} {{description=No commands here}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + + it("should return false when command exists but no !!! terminator", () => { + const msg = createMockMessage("{{setattr=!setattr --strength 18}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + + it("should return false when empty content", () => { + const msg = createMockMessage(""); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + + it("should return false when command keyword exists but no actual command", () => { + const msg = createMockMessage("This message contains the word setattr but no command"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe(false); + }); + }); + + describe("edge cases", () => { + it("should handle commands with special characters", () => { + const msg = createMockMessage("{{setattr=!setattr --repeating_skills_CREATE_name|Acrobatics!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --repeating_skills_CREATE_name|Acrobatics"); + }); + + it("should handle commands with numbers", () => { + const msg = createMockMessage("{{setattr=!setattr --skill_1|5 --skill_2|10!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --skill_1|5 --skill_2|10"); + }); + + it("should handle commands with equals signs", () => { + const msg = createMockMessage("{{setattr=!setattr --strength|18 --formula|2+3=5!!!}}"); + const result = extractMessageFromRollTemplate(msg); + expect(result).toBe("!setattr --strength|18 --formula|2+3=5"); + }); + }); + }); + + describe("parseMessage", () => { + describe("operation extraction", () => { + it("should extract setattr operation", () => { + const result = parseMessage("!setattr --strength 18"); + expect(result.operation).toBe("setattr"); + }); + + it("should extract modattr operation", () => { + const result = parseMessage("!modattr --strength +2"); + expect(result.operation).toBe("modattr"); + }); + + it("should extract modbattr operation", () => { + const result = parseMessage("!modbattr --hitpoints +5"); + expect(result.operation).toBe("modbattr"); + }); + + it("should extract resetattr operation", () => { + const result = parseMessage("!resetattr --strength"); + expect(result.operation).toBe("resetattr"); + }); + + it("should extract delattr operation", () => { + const result = parseMessage("!delattr --skill_athletics"); + expect(result.operation).toBe("delattr"); + }); + + it("should throw error for empty command", () => { + expect(() => parseMessage("")).toThrow("Invalid command: "); + }); + + it("should throw error for invalid command", () => { + expect(() => parseMessage("!invalidcmd --test")).toThrow("Invalid command: invalidcmd"); + }); + }); + + describe("command option overrides", () => { + it("should override setattr with mod option", () => { + const result = parseMessage("!setattr --mod --strength +2"); + expect(result.operation).toBe("modattr"); + }); + + it("should override setattr with modb option", () => { + const result = parseMessage("!setattr --modb --hitpoints +5"); + expect(result.operation).toBe("modbattr"); + }); + + it("should override setattr with reset option", () => { + const result = parseMessage("!setattr --reset --strength"); + expect(result.operation).toBe("resetattr"); + }); + + it("should handle multiple command options (last one wins)", () => { + const result = parseMessage("!setattr --mod --reset --strength"); + expect(result.operation).toBe("resetattr"); + }); + }); + + describe("options parsing", () => { + it("should parse silent option", () => { + const result = parseMessage("!setattr --silent --strength 18"); + expect(result.options.silent).toBe(true); + }); + + it("should parse replace option", () => { + const result = parseMessage("!setattr --replace --strength 18"); + expect(result.options.replace).toBe(true); + }); + + it("should parse nocreate option", () => { + const result = parseMessage("!setattr --nocreate --strength 18"); + expect(result.options.nocreate).toBe(true); + }); + + it("should parse mute option", () => { + const result = parseMessage("!setattr --mute --strength 18"); + expect(result.options.mute).toBe(true); + }); + + it("should parse evaluate option", () => { + const result = parseMessage("!setattr --evaluate --strength 18"); + expect(result.options.evaluate).toBe(true); + }); + + it("should parse multiple options", () => { + const result = parseMessage("!setattr --silent --replace --evaluate --strength 18"); + expect(result.options.silent).toBe(true); + expect(result.options.replace).toBe(true); + expect(result.options.evaluate).toBe(true); + expect(result.options.mute).toBeUndefined(); + }); + }); + + describe("target parsing", () => { + it("should parse all target", () => { + const result = parseMessage("!setattr --all --strength 18"); + expect(result.targeting).toContain("all"); + }); + + it("should parse allgm target", () => { + const result = parseMessage("!setattr --allgm --strength 18"); + expect(result.targeting).toContain("allgm"); + }); + + it("should parse allplayers target", () => { + const result = parseMessage("!setattr --allplayers --strength 18"); + expect(result.targeting).toContain("allplayers"); + }); + + it("should parse charid target", () => { + const result = parseMessage("!setattr --charid -Abc123 --strength 18"); + expect(result.targeting).toContain("charid -Abc123"); + }); + + it("should parse name target", () => { + const result = parseMessage("!setattr --name Gandalf --strength 18"); + expect(result.targeting).toContain("name Gandalf"); + }); + + it("should parse sel target", () => { + const result = parseMessage("!setattr --sel --strength 18"); + expect(result.targeting).toContain("sel"); + }); + + it("should parse multiple targets", () => { + const result = parseMessage("!setattr --sel --name Gandalf --strength 18"); + expect(result.targeting).toContain("sel"); + expect(result.targeting).toContain("name Gandalf"); + }); + }); + + describe("attribute changes parsing", () => { + it("should parse simple attribute name", () => { + const result = parseMessage("!setattr --sel --strength"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ name: "strength" }); + }); + + it("should parse attribute with current value", () => { + const result = parseMessage("!setattr --sel --strength|18"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "strength", + current: "18", + }); + }); + + it("should parse attribute with current and max values", () => { + const result = parseMessage("!setattr --sel --strength|18|20"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "strength", + current: "18", + max: "20", + }); + }); + + it("should parse attribute with empty current but max value", () => { + const result = parseMessage("!setattr --sel --strength||20"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "strength", + max: "20", + }); + }); + + it("should parse multiple attributes", () => { + const result = parseMessage("!setattr --sel --strength|18 --dexterity|14|16 --constitution"); + expect(result.changes).toHaveLength(3); + expect(result.changes[0]).toEqual({ name: "strength", current: "18" }); + expect(result.changes[1]).toEqual({ name: "dexterity", current: "14", max: "16" }); + expect(result.changes[2]).toEqual({ name: "constitution" }); + }); + + it("should handle attributes with numbers and underscores", () => { + const result = parseMessage("!setattr --sel --skill_1|5 --attr_test_2|value"); + expect(result.changes).toHaveLength(2); + expect(result.changes[0]).toEqual({ name: "skill_1", current: "5" }); + expect(result.changes[1]).toEqual({ name: "attr_test_2", current: "value" }); + }); + }); + + describe("referenced attributes parsing", () => { + it("should extract references from current values", () => { + const result = parseMessage("!setattr --sel --hitpoints|%constitution%"); + expect(result.references).toContain("%constitution%"); + }); + + it("should extract references from max values", () => { + const result = parseMessage("!setattr --sel --hitpoints|10|%constitution%"); + expect(result.references).toContain("%constitution%"); + }); + + it("should extract multiple references from same attribute", () => { + const result = parseMessage("!setattr --sel --total|%strength% + %dexterity%"); + expect(result.references).toContain("%strength%"); + expect(result.references).toContain("%dexterity%"); + }); + + it("should extract references from multiple attributes", () => { + const result = parseMessage("!setattr --sel --hitpoints|%constitution%|%constitution_max% --armor|%dexterity%"); + expect(result.references).toContain("%constitution%"); + expect(result.references).toContain("%constitution_max%"); + expect(result.references).toContain("%dexterity%"); + }); + + it("should handle attributes with underscores and numbers in references", () => { + const result = parseMessage("!setattr --sel --total|%skill_1% + %attr_test_2%"); + expect(result.references).toContain("%skill_1%"); + expect(result.references).toContain("%attr_test_2%"); + }); + + it("should not extract references from non-string values", () => { + const result = parseMessage("!setattr --sel --strength|18"); + expect(result.references).toHaveLength(0); + }); + + it("should handle complex expressions with references", () => { + const result = parseMessage("!setattr --sel --formula|%base% * 2 + %bonus%|%max_formula%"); + expect(result.references).toContain("%base%"); + expect(result.references).toContain("%bonus%"); + expect(result.references).toContain("%max_formula%"); + }); + }); + + describe("complex parsing scenarios", () => { + it("should parse command with all components", () => { + const result = parseMessage("!setattr --silent --replace --sel --name Gandalf --strength|%base_str%|20 --dexterity|14"); + + expect(result.operation).toBe("setattr"); + expect(result.options.silent).toBe(true); + expect(result.options.replace).toBe(true); + expect(result.targeting).toContain("sel"); + expect(result.targeting).toContain("name Gandalf"); + expect(result.changes).toHaveLength(2); + expect(result.changes[0]).toEqual({ name: "strength", current: "%base_str%", max: "20" }); + expect(result.changes[1]).toEqual({ name: "dexterity", current: "14" }); + expect(result.references).toContain("%base_str%"); + }); + + it("should handle mixed command options and regular options", () => { + const result = parseMessage("!setattr --mod --silent --evaluate --sel --strength|%base% + 2"); + + expect(result.operation).toBe("modattr"); + expect(result.options.silent).toBe(true); + expect(result.options.evaluate).toBe(true); + expect(result.targeting).toContain("sel"); + expect(result.changes[0]).toEqual({ name: "strength", current: "%base% + 2" }); + expect(result.references).toContain("%base%"); + }); + }); + + describe("edge cases", () => { + it("should handle extra whitespace", () => { + const result = parseMessage(" !setattr --sel --strength | 18 "); + expect(result.operation).toBe("setattr"); + expect(result.targeting).toContain("sel"); + expect(result.changes[0]).toEqual({ name: "strength", current: "18" }); // Properly parsed with pipes + }); + + it("should ignore empty parts from double separators", () => { + const result = parseMessage("!setattr --sel ---- --strength|18"); + expect(result.operation).toBe("setattr"); + expect(result.targeting).toContain("sel"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ name: "strength", current: "18" }); + }); + + it("should handle attribute names with pipes but no values", () => { + const result = parseMessage("!setattr --sel --test|"); + expect(result.changes[0]).toEqual({ name: "test" }); + }); + + it("should handle attributes with multiple pipes", () => { + const result = parseMessage("!setattr --sel --test|val1|val2|val3"); + expect(result.changes[0]).toEqual({ + name: "test", + current: "val1", + max: "val2", // Only first two values after pipe are used + }); + }); + }); + + describe("feedback parsing", () => { + it("should parse fb-public option", () => { + const result = parseMessage("!setattr --sel --fb-public --strength|18"); + expect(result.feedback.public).toBe(true); + }); + + it("should parse fb-from option with single word value", () => { + const result = parseMessage("!setattr --sel --fb-from TestGM --strength|18"); + expect(result.feedback.from).toBe("TestGM"); + expect(result.feedback.public).toBe(false); // default + }); + + it("should parse fb-header option with single word value", () => { + const result = parseMessage("!setattr --sel --fb-header Custom --strength|18"); + expect(result.feedback.header).toBe("Custom"); + expect(result.feedback.public).toBe(false); // default + }); + + it("should parse fb-content option with single word value", () => { + const result = parseMessage("!setattr --sel --fb-content Custom --strength|18"); + expect(result.feedback.content).toBe("Custom"); + expect(result.feedback.public).toBe(false); // default + }); + + it("should parse multiple feedback options", () => { + const result = parseMessage("!setattr --sel --fb-public --fb-from TestGM --fb-header Custom --strength|18"); + expect(result.feedback.public).toBe(true); + expect(result.feedback.from).toBe("TestGM"); + expect(result.feedback.header).toBe("Custom"); + expect(result.feedback.content).toBeUndefined(); + }); + + it("should parse all feedback options together", () => { + const result = parseMessage("!setattr --sel --fb-public --fb-from TestGM --fb-header Test --fb-content Message --strength|18"); + expect(result.feedback.public).toBe(true); + expect(result.feedback.from).toBe("TestGM"); + expect(result.feedback.header).toBe("Test"); + expect(result.feedback.content).toBe("Message"); + }); + + it("should handle feedback options with no value gracefully", () => { + const result = parseMessage("!setattr --sel --fb-from --strength|18"); + expect(result.feedback.from).toBe(""); + expect(result.feedback.public).toBe(false); + }); + + it("should default feedback to public false when no feedback options", () => { + const result = parseMessage("!setattr --sel --strength|18"); + expect(result.feedback.public).toBe(false); + expect(result.feedback.from).toBeUndefined(); + expect(result.feedback.header).toBeUndefined(); + expect(result.feedback.content).toBeUndefined(); + }); + + it("should handle mixed feedback and regular options", () => { + const result = parseMessage("!setattr --silent --fb-public --fb-from TestGM --sel --strength|18"); + expect(result.options.silent).toBe(true); + expect(result.feedback.public).toBe(true); + expect(result.feedback.from).toBe("TestGM"); + expect(result.targeting).toContain("sel"); + }); + }); + + describe("return value structure", () => { + it("should return all expected properties", () => { + const result = parseMessage("!setattr --sel --strength|18"); + + expect(result).toHaveProperty("operation"); + expect(result).toHaveProperty("options"); + expect(result).toHaveProperty("targeting"); + expect(result).toHaveProperty("changes"); + expect(result).toHaveProperty("references"); + expect(result).toHaveProperty("feedback"); + + expect(typeof result.operation).toBe("string"); + expect(typeof result.options).toBe("object"); + expect(Array.isArray(result.targeting)).toBe(true); + expect(Array.isArray(result.changes)).toBe(true); + expect(Array.isArray(result.references)).toBe(true); + expect(typeof result.feedback).toBe("object"); + }); + + it("should return empty arrays when no matches found", () => { + const result = parseMessage("!setattr"); + + expect(result.targeting).toEqual([]); + expect(result.changes).toEqual([]); + expect(result.references).toEqual([]); + expect(result.options).toEqual({}); + expect(result.feedback).toEqual({ public: false }); + }); + }); + + describe("quote handling and space stripping", () => { + describe("feedback option quote handling", () => { + it("strips single quotes from fb-header value", () => { + const message = "!setattr --fb-header 'Terrible Wounds'"; + const result = parseMessage(message); + expect(result.feedback.header).toBe("Terrible Wounds"); + }); + + it("strips double quotes from fb-header value", () => { + const message = "!setattr --fb-header \"Terrible Wounds\""; + const result = parseMessage(message); + expect(result.feedback.header).toBe("Terrible Wounds"); + }); + + it("preserves trailing spaces within quotes for fb-header", () => { + const message = "!setattr --fb-header \"Terrible Wounds \""; + const result = parseMessage(message); + expect(result.feedback.header).toBe("Terrible Wounds "); + }); + + it("strips trailing spaces without quotes for fb-header", () => { + const message = "!setattr --fb-header Terrible"; + const result = parseMessage(message); + expect(result.feedback.header).toBe("Terrible"); + }); + + it("strips single quotes from fb-content value", () => { + const message = "!setattr --fb-content 'Character'"; + const result = parseMessage(message); + expect(result.feedback.content).toBe("Character"); + }); + + it("preserves internal quotes and spaces when enclosed in outer quotes", () => { + const message = "!setattr --fb-content 'Quote:'"; + const result = parseMessage(message); + expect(result.feedback.content).toBe("Quote:"); + }); + + it("strips mixed quote types (single on one side, double on other)", () => { + const message = "!setattr --fb-from 'Player\""; + const result = parseMessage(message); + expect(result.feedback.from).toBe("Player"); + }); + }); + + describe("attribute value quote handling", () => { + it("strips single quotes from attribute current value", () => { + const message = "!setattr --sel --hp|'25'"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("25"); + }); + + it("strips single quotes from attribute max value", () => { + const message = "!setattr --sel --hp|10|'50'"; + const result = parseMessage(message); + expect(result.changes[0].max).toBe("50"); + }); + + it("preserves trailing spaces within quotes for attribute values", () => { + const message = "!setattr --sel --details|'This is a long message with multiple words and some odd spacing '"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("This is a long message with multiple words and some odd spacing "); + }); + + it("strips trailing spaces without quotes for attribute values", () => { + const message = "!setattr --sel --description|Some text with trailing spaces "; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("Some text with trailing spaces"); + }); + + it("handles quotes in both current and max values", () => { + const message = "!setattr --sel --stat|'Current Value '|'Max Value '"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("Current Value "); + expect(result.changes[0].max).toBe("Max Value "); + }); + + it("strips double quotes from attribute values", () => { + const message = "!setattr --sel --name|\"Character Name\""; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("Character Name"); + }); + + it("handles empty values with quotes", () => { + const message = "!setattr --sel --empty|''"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe(""); + }); + + it("handles values with only spaces inside quotes", () => { + const message = "!setattr --sel --spaces|' '"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe(" "); + }); + }); + + describe("complex quote scenarios", () => { + it("handles multiple attributes with mixed quote usage", () => { + const message = "!setattr --sel --name|'John Doe' --hp|25 --description|'A brave warrior ' --ac|\"18\""; + const result = parseMessage(message); + expect(result.changes).toHaveLength(4); + expect(result.changes[0].current).toBe("John Doe"); + expect(result.changes[1].current).toBe("25"); + expect(result.changes[2].current).toBe("A brave warrior "); + expect(result.changes[3].current).toBe("18"); + }); + + it("handles nested quotes correctly", () => { + const message = "!setattr --sel --speech|'He said \"Hello there!\" loudly'"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("He said \"Hello there!\" loudly"); + }); + + it("handles quotes with special characters and spaces", () => { + const message = "!setattr --sel --special|'@^$% special chars '"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("@^$% special chars "); + }); + }); + + describe("edge cases", () => { + it("handles single quote character as value", () => { + const message = "!setattr --sel --apostrophe|\"'\""; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("'"); + }); + + it("handles double quote character as value", () => { + const message = "!setattr --sel --quote|'\"'"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("\""); + }); + + it("ignores unmatched quotes", () => { + const message = "!setattr --sel --unmatched|'missing end quote"; + const result = parseMessage(message); + expect(result.changes[0].current).toBe("'missing end quote"); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/modifications.test.ts b/ChatSetAttr/src/__tests__/unit/modifications.test.ts new file mode 100644 index 0000000000..9d907f298d --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/modifications.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect } from "vitest"; +import { + processModifierValue, + processModifierName, + type ProcessModifierNameOptions, +} from "../../modules/modifications"; +import type { AttributeRecord } from "../../types"; + +describe("modifications", () => { + describe("processModifierValue", () => { + const mockAttributes: AttributeRecord = { + strength: 18, + dexterity: 14, + constitution: 16, + intelligence: 12, + wisdom: 13, + charisma: 10, + level: 5, + hitpoints: 45, + armorclass: 15, + name: "TestCharacter", + active: true, + }; + + describe("alias character replacement", () => { + it("should replace < with [ and > with ]", () => { + const result = processModifierValue("<<1d6>>", {}, { shouldAlias: true }); + expect(result).toBe("[[1d6]]"); + }); + + it("should replace ~ with -", () => { + const result = processModifierValue("~value", {}, { shouldAlias: true }); + expect(result).toBe("-value"); + }); + + it("should replace ; with ?", () => { + const result = processModifierValue(";{query}", {}, { shouldAlias: true }); + expect(result).toBe("?{query}"); + }); + + it("should replace ` with @", () => { + const result = processModifierValue("`{attribute}", {}, { shouldAlias: true }); + expect(result).toBe("@{attribute}"); + }); + + it("should replace multiple alias characters", () => { + const result = processModifierValue("<<1d6>>~;{query}+`{attribute}", {}, { shouldAlias: true }); + expect(result).toBe("[[1d6]]-?{query}+@{attribute}"); + }); + }); + + describe("placeholder replacement", () => { + it("should replace single placeholder with attribute value", () => { + const result = processModifierValue("%strength%", mockAttributes); + expect(result).toBe("18"); + }); + + it("should replace multiple placeholders", () => { + const result = processModifierValue("%strength% + %dexterity%", mockAttributes); + expect(result).toBe("18 + 14"); + }); + + it("should handle string attribute values", () => { + const result = processModifierValue("Hello %name%", mockAttributes); + expect(result).toBe("Hello TestCharacter"); + }); + + it("should handle boolean attribute values", () => { + const result = processModifierValue("Active: %active%", mockAttributes); + expect(result).toBe("Active: true"); + }); + + it("should leave placeholder unchanged if attribute not found", () => { + const result = processModifierValue("%nonexistent%", mockAttributes); + expect(result).toBe("%nonexistent%"); + }); + + it("should handle mixed existing and non-existing placeholders", () => { + const result = processModifierValue("%strength% + %nonexistent%", mockAttributes); + expect(result).toBe("18 + %nonexistent%"); + }); + + it("should handle placeholders with underscores and numbers", () => { + const attributes = { skill_1: 5, attr_test_2: "value" }; + const result = processModifierValue("%skill_1% and %attr_test_2%", attributes); + expect(result).toBe("5 and value"); + }); + + it("should handle empty attribute record", () => { + const result = processModifierValue("%strength%", {}); + expect(result).toBe("%strength%"); + }); + }); + + describe("evaluation", () => { + it("should not evaluate by default", () => { + const result = processModifierValue("2 + 3", {}); + expect(result).toBe("2 + 3"); + }); + + it("should evaluate when shouldEvaluate is true", () => { + const result = processModifierValue("2 + 3", {}, { shouldEvaluate: true }); + expect(result).toBe(5); + }); + + it("should evaluate with placeholder replacement", () => { + const result = processModifierValue("%strength% + %dexterity%", mockAttributes, { shouldEvaluate: true }); + expect(result).toBe(32); + }); + + it("should handle complex expressions", () => { + const result = processModifierValue("(5 + 3) * 2", {}, { shouldEvaluate: true }); + expect(result).toBe(16); + }); + + it("should return original value if evaluation fails", () => { + const result = processModifierValue("invalid expression +++", {}, { shouldEvaluate: true }); + expect(result).toBe("invalid expression +++"); + }); + }); + + describe("combined functionality", () => { + it("should handle alias replacement, placeholders, and evaluation together", () => { + const attributes = { base: 10, bonus: 5 }; + const result = processModifierValue("<%base%> + <%bonus%>", attributes, { shouldEvaluate: true, shouldAlias: true }); + expect(result).toBe("105"); // becomes "[10] + [5]" which evaluates to string concatenation + }); + + it("should process in correct order: aliases, then placeholders, then evaluation", () => { + const attributes = { test: 8 }; + const result = processModifierValue("<%test%> ~ 2", attributes, { shouldEvaluate: true, shouldAlias: true }); + expect(result).toBe(6); // [8] - 2 = 6 + }); + }); + + describe("edge cases", () => { + it("should handle empty string", () => { + const result = processModifierValue("", {}); + expect(result).toBe(""); + }); + + it("should handle string with only placeholders", () => { + const result = processModifierValue("%nonexistent%", {}); + expect(result).toBe("%nonexistent%"); + }); + + it("should handle string with only alias characters", () => { + const result = processModifierValue("<>~;`", {}, { shouldAlias: true }); + expect(result).toBe("[]-?@"); + }); + + it("should handle undefined attributes gracefully", () => { + const attributes = { test: undefined }; + const result = processModifierValue("%test%", attributes); + expect(result).toBe("%test%"); + }); + + it("should handle missing attributes as undefined", () => { + const attributes = {}; + const result = processModifierValue("%nonexistent%", attributes); + expect(result).toBe("%nonexistent%"); + }); + + it("should handle zero values", () => { + const attributes = { test: 0 }; + const result = processModifierValue("%test%", attributes); + expect(result).toBe("0"); + }); + + it("should handle false boolean values", () => { + const attributes = { test: false }; + const result = processModifierValue("%test%", attributes); + expect(result).toBe("false"); + }); + + it("should handle malformed placeholders", () => { + const result = processModifierValue("%incomplete", {}); + expect(result).toBe("%incomplete"); + }); + + it("should handle nested placeholders", () => { + const attributes = { outer: "%inner%", inner: "value" }; + const result = processModifierValue("%outer%", attributes); + expect(result).toBe("%inner%"); // Should not recursively process + }); + }); + }); + + + + describe("processModifierName", () => { + describe("CREATE replacement", () => { + it("should replace CREATE with repeatingID when both are provided", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row123", + repOrder: [""], + }; + const result = processModifierName("repeating_skills_CREATE_name", options); + expect(result).toBe("repeating_skills_row123_name"); + }); + + it("should not replace CREATE when repeatingID is not provided", () => { + const options: ProcessModifierNameOptions = { + repOrder: [""], + }; + const result = processModifierName("repeating_skills_CREATE_name", options); + expect(result).toBe("repeating_skills_CREATE_name"); + }); + + it("should not replace CREATE when repeatingID is empty", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "", + repOrder: [""], + }; + const result = processModifierName("repeating_skills_CREATE_name", options); + expect(result).toBe("repeating_skills_CREATE_name"); + }); + + it("should handle multiple CREATE occurrences", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row456", + repOrder: [""], + }; + const result = processModifierName("CREATE_CREATE_name", options); + expect(result).toBe("row456_CREATE_name"); // Only replaces first occurrence + }); + + it("should handle names without CREATE", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row789", + repOrder: [""], + }; + const result = processModifierName("regular_attribute_name", options); + expect(result).toBe("regular_attribute_name"); + }); + }); + + describe("row index replacement", () => { + it("should replace $0 with first item from repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("repeating_skills_$0_name", options); + expect(result).toBe("repeating_skills_row1_name"); + }); + + it("should replace $1 with second item from repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("repeating_skills_$1_name", options); + expect(result).toBe("repeating_skills_row2_name"); + }); + + it("should replace $2 with third item from repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("repeating_skills_$2_name", options); + expect(result).toBe("repeating_skills_row3_name"); + }); + + it("should handle out of bounds index gracefully", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2"] + }; + const result = processModifierName("repeating_skills_$5_name", options); + expect(result).toBe("repeating_skills_$5_name"); + }); + + it("should not replace when repOrder is empty", () => { + const options: ProcessModifierNameOptions = { + repOrder: [], + }; + const result = processModifierName("repeating_skills_$0_name", options); + expect(result).toBe("repeating_skills_$0_name"); + }); + + it("should handle single item repOrder", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["onlyrow"], + }; + const result = processModifierName("repeating_skills_$0_name", options); + expect(result).toBe("repeating_skills_onlyrow_name"); + }); + + it("should handle names without row index patterns", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["row1", "row2", "row3"], + }; + const result = processModifierName("regular_attribute_name", options); + expect(result).toBe("regular_attribute_name"); + }); + }); + + describe("edge cases", () => { + it("should handle empty name", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row123", + repOrder: ["row1", "row2"], + }; + const result = processModifierName("", options); + expect(result).toBe(""); + }); + + it("should handle name with only CREATE", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "replacement", + repOrder: [""], + }; + const result = processModifierName("CREATE", options); + expect(result).toBe("replacement"); + }); + + it("should handle name with only row index", () => { + const options: ProcessModifierNameOptions = { + repOrder: ["first", "second"], + }; + const result = processModifierName("$1", options); + expect(result).toBe("second"); + }); + + it("should handle options with undefined values", () => { + const options: ProcessModifierNameOptions = { + repeatingID: undefined, + repOrder: [""], + }; + const result = processModifierName("repeating_CREATE_$0_name", options); + expect(result).toBe("repeating_CREATE_$0_name"); + }); + + it("should handle special characters in repeatingID", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "row-123_special!", + repOrder: [""], + }; + const result = processModifierName("repeating_CREATE_name", options); + expect(result).toBe("repeating_row-123_special!_name"); + }); + + it("should handle case sensitivity", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "replacement", + repOrder: [""], + }; + const result = processModifierName("repeating_create_name", options); + expect(result).toBe("repeating_create_name"); // Should not replace lowercase 'create' + }); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/observer.test.ts b/ChatSetAttr/src/__tests__/unit/observer.test.ts new file mode 100644 index 0000000000..8593469334 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/observer.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ObserverCallback } from "../../types"; +import { notifyObservers, registerObserver } from "../../modules/observer"; + +describe("observer", () => { + beforeEach(async () => { + // Reset modules to clear the observers state + vi.resetModules(); + }); + + describe("registerObserver", () => { + it("should add a callback for a new event", () => { + const mockCallback: ObserverCallback = vi.fn(); + + registerObserver("add", mockCallback); + + // Verify by triggering notification + notifyObservers("add", "exampleID", "exampleAttribute", "newValue", "oldValue"); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalledWith("add", "exampleID", "exampleAttribute", "newValue", "oldValue"); + }); + + it("should add multiple callbacks for the same event", () => { + const mockCallback1: ObserverCallback = vi.fn(); + const mockCallback2: ObserverCallback = vi.fn(); + + registerObserver("change", mockCallback1); + registerObserver("change", mockCallback2); + + notifyObservers("change", "exampleID", "exampleAttribute", "newValue", "oldValue"); + + expect(mockCallback1).toHaveBeenCalledTimes(1); + expect(mockCallback2).toHaveBeenCalledTimes(1); + expect(mockCallback1).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", "newValue", "oldValue"); + expect(mockCallback2).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", "newValue", "oldValue"); + }); + + it("should add callbacks for different events", () => { + const addCallback: ObserverCallback = vi.fn(); + const changeCallback: ObserverCallback = vi.fn(); + const destroyCallback: ObserverCallback = vi.fn(); + + registerObserver("add", addCallback); + registerObserver("change", changeCallback); + registerObserver("destroy", destroyCallback); + + notifyObservers("add", "exampleID", "exampleAttribute", "value1", "value2"); + notifyObservers("change", "exampleID", "exampleAttribute", "value3", "value4"); + notifyObservers("destroy", "exampleID", "exampleAttribute", "value5", "value6"); + + expect(addCallback).toHaveBeenCalledTimes(1); + expect(changeCallback).toHaveBeenCalledTimes(1); + expect(destroyCallback).toHaveBeenCalledTimes(1); + + expect(addCallback).toHaveBeenCalledWith("add", "exampleID", "exampleAttribute", "value1", "value2"); + expect(changeCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", "value3", "value4"); + expect(destroyCallback).toHaveBeenCalledWith("destroy", "exampleID", "exampleAttribute", "value5", "value6"); + }); + + it("should allow the same callback to be added multiple times", () => { + const mockCallback: ObserverCallback = vi.fn(); + + registerObserver("add", mockCallback); + registerObserver("add", mockCallback); + + notifyObservers("add", "exampleID", "exampleAttribute", "newValue", "oldValue"); + + // Should be called twice since it was added twice + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + }); + + describe("notifyObservers", () => { + it("should call all callbacks for a given event", () => { + const mockCallback1: ObserverCallback = vi.fn(); + const mockCallback2: ObserverCallback = vi.fn(); + const mockCallback3: ObserverCallback = vi.fn(); + + registerObserver("change", mockCallback1); + registerObserver("change", mockCallback2); + registerObserver("change", mockCallback3); + + notifyObservers("change", "exampleID", "exampleAttribute", 100, 50); + + expect(mockCallback1).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", 100, 50); + expect(mockCallback2).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", 100, 50); + expect(mockCallback3).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", 100, 50); + }); + + it("should handle notification when no observers exist for event", () => { + // This should not throw an error + expect(() => { + notifyObservers("add", "exampleID", "exampleAttribute", "newValue", "oldValue"); + }).not.toThrow(); + }); + + it("should only notify observers for the specific event", () => { + const addCallback: ObserverCallback = vi.fn(); + const changeCallback: ObserverCallback = vi.fn(); + + registerObserver("add", addCallback); + registerObserver("change", changeCallback); + + notifyObservers("add", "exampleID", "exampleAttribute", "value1", "value2"); + + expect(addCallback).toHaveBeenCalledWith("add", "exampleID", "exampleAttribute", "value1", "value2"); + expect(changeCallback).not.toHaveBeenCalled(); + }); + + it("should handle different attribute value types", () => { + const mockCallback: ObserverCallback = vi.fn(); + registerObserver("change", mockCallback); + + // Test with numbers + notifyObservers("change", "exampleID", "exampleAttribute", 25, 10); + expect(mockCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", 25, 10); + + // Test with strings + notifyObservers("change", "exampleID", "exampleAttribute", "newString", "oldString"); + expect(mockCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", "newString", "oldString"); + + // Test with booleans + notifyObservers("change", "exampleID", "exampleAttribute", true, false); + expect(mockCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", true, false); + + // Test with undefined + notifyObservers("change", "exampleID", "exampleAttribute", undefined, "someValue"); + expect(mockCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", undefined, "someValue"); + }); + + it("should handle callback execution errors gracefully", () => { + const errorCallback: ObserverCallback = vi.fn(() => { + throw new Error("Callback error"); + }); + const normalCallback: ObserverCallback = vi.fn(); + + registerObserver("destroy", errorCallback); + registerObserver("destroy", normalCallback); + + // This should not prevent other callbacks from executing + expect(() => { + notifyObservers("destroy", "targetID", "exampleAttribute", "value1", "value2"); + }).toThrow("Callback error"); + + expect(errorCallback).toHaveBeenCalled(); + }); + + it("should call callbacks in the order they were added", () => { + const callOrder: number[] = []; + + const callback1: ObserverCallback = vi.fn(() => callOrder.push(1)); + const callback2: ObserverCallback = vi.fn(() => callOrder.push(2)); + const callback3: ObserverCallback = vi.fn(() => callOrder.push(3)); + + registerObserver("add", callback1); + registerObserver("add", callback2); + registerObserver("add", callback3); + + notifyObservers("add", "exampleID", "exampleAttribute", "value", "oldValue"); + + expect(callOrder).toEqual([1, 2, 3]); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/repeating.test.ts b/ChatSetAttr/src/__tests__/unit/repeating.test.ts new file mode 100644 index 0000000000..a706265c01 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/repeating.test.ts @@ -0,0 +1,626 @@ +import { describe, it, expect, beforeEach, vi, type MockedFunction } from "vitest"; +import { + extractRepeatingParts, + combineRepeatingParts, + isRepeatingAttribute, + hasCreateIdentifier, + hasIndexIdentifier, + convertRepOrderToArray, + getIDFromIndex, + getRepOrderForSection, + extractRepeatingAttributes, + getAllSectionNames, + getAllRepOrders, + processRepeatingAttributes, + type RepeatingParts +} from "../../modules/repeating"; +import type { Attribute } from "../../types"; + +describe("repeating", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("extractRepeatingParts", () => { + it("should extract parts from a valid repeating attribute", () => { + const result = extractRepeatingParts("repeating_weapons_-abc123_name"); + expect(result).toEqual({ + section: "weapons", + identifier: "-abc123", + field: "name" + }); + }); + + it("should extract parts with underscore in field name", () => { + const result = extractRepeatingParts("repeating_spells_-def456_spell_level"); + expect(result).toEqual({ + section: "spells", + identifier: "-def456", + field: "spell_level" + }); + }); + + it("should extract parts with CREATE identifier", () => { + const result = extractRepeatingParts("repeating_inventory_CREATE_item_name"); + expect(result).toEqual({ + section: "inventory", + identifier: "CREATE", + field: "item_name" + }); + }); + + it("should extract parts with index identifier", () => { + const result = extractRepeatingParts("repeating_attacks_$1_attack_name"); + expect(result).toEqual({ + section: "attacks", + identifier: "$1", + field: "attack_name" + }); + }); + + it("should return null for non-repeating attributes", () => { + const result = extractRepeatingParts("strength"); + expect(result).toBeNull(); + }); + + it("should return null for empty string", () => { + const result = extractRepeatingParts(""); + expect(result).toBeNull(); + }); + + it("should return null for empty parts", () => { + const result = extractRepeatingParts("repeating_"); + expect(result).toBeNull(); + }); + }); + + describe("combineRepeatingParts", () => { + it("should combine parts into a valid repeating attribute name", () => { + const parts: RepeatingParts = { + section: "weapons", + identifier: "-abc123", + field: "name" + }; + const result = combineRepeatingParts(parts); + expect(result).toBe("repeating_weapons_-abc123_name"); + }); + + it("should handle parts with underscores in field", () => { + const parts: RepeatingParts = { + section: "spells", + identifier: "-def456", + field: "spell_level" + }; + const result = combineRepeatingParts(parts); + expect(result).toBe("repeating_spells_-def456_spell_level"); + }); + + it("should handle CREATE identifier", () => { + const parts: RepeatingParts = { + section: "inventory", + identifier: "CREATE", + field: "item_name" + }; + const result = combineRepeatingParts(parts); + expect(result).toBe("repeating_inventory_CREATE_item_name"); + }); + + it("should error on empty parts", () => { + const parts: RepeatingParts = { + section: "", + identifier: "", + field: "" + }; + expect(() => combineRepeatingParts(parts)).toThrowError(); + }); + }); + + describe("isRepeatingAttribute", () => { + it("should return true for valid repeating attributes", () => { + expect(isRepeatingAttribute("repeating_weapons_-abc123_name")).toBe(true); + expect(isRepeatingAttribute("repeating_spells_CREATE_spell_name")).toBe(true); + expect(isRepeatingAttribute("repeating_attacks_$1_attack_bonus")).toBe(true); + }); + + it("should return false for non-repeating attributes", () => { + expect(isRepeatingAttribute("strength")).toBe(false); + expect(isRepeatingAttribute("dexterity")).toBe(false); + expect(isRepeatingAttribute("hp")).toBe(false); + }); + + it("should return false for malformed repeating attributes", () => { + expect(isRepeatingAttribute("repeating_only_two")).toBe(false); + expect(isRepeatingAttribute("repeating_")).toBe(false); + expect(isRepeatingAttribute("")).toBe(false); + }); + + it("should return true for attributes that start with repeating_ and have minimal structure", () => { + expect(isRepeatingAttribute("repeating_a_b_c")).toBe(true); + }); + }); + + describe("hasCreateIdentifier", () => { + it("should return true for CREATE identifier", () => { + expect(hasCreateIdentifier("repeating_weapons_CREATE_name")).toBe(true); + }); + + it("should return false for non-CREATE identifiers", () => { + expect(hasCreateIdentifier("repeating_weapons_-abc123_name")).toBe(false); + expect(hasCreateIdentifier("repeating_attacks_$1_bonus")).toBe(false); + expect(hasCreateIdentifier("repeating_spells_normal_id_name")).toBe(false); + }); + }); + + describe("hasIndexIdentifier", () => { + it("should return true for valid index identifiers", () => { + expect(hasIndexIdentifier("repeating_weapons_$1_name")).toBe(true); + expect(hasIndexIdentifier("repeating_spells_$10_spell_name")).toBe(true); + expect(hasIndexIdentifier("repeating_attacks_$999_bonus")).toBe(true); + }); + + it("should return false for non-index identifiers", () => { + expect(hasIndexIdentifier("repeating_weapons_-abc123_name")).toBe(false); + expect(hasIndexIdentifier("repeating_spells_CREATE_spell_name")).toBe(false); + expect(hasIndexIdentifier("repeating_attacks_normal_id_bonus")).toBe(false); + }); + + it("should return false for invalid index formats", () => { + expect(hasIndexIdentifier("repeating_weapons_$abc_name")).toBe(false); + expect(hasIndexIdentifier("repeating_spells_1_spell_name")).toBe(false); + expect(hasIndexIdentifier("repeating_attacks_$_bonus")).toBe(false); + expect(hasIndexIdentifier("repeating_test_$$1_field")).toBe(false); + }); + + it("should return false for non-repeating attributes", () => { + expect(hasIndexIdentifier("strength")).toBe(false); + expect(hasIndexIdentifier("$1")).toBe(false); + }); + + it("should handle leading zeros in index", () => { + expect(hasIndexIdentifier("repeating_test_$01_field")).toBe(true); + expect(hasIndexIdentifier("repeating_test_$001_field")).toBe(true); + }); + }); + + describe("convertRepOrderToArray", () => { + it("should convert comma-separated string to array", () => { + const result = convertRepOrderToArray("-abc123,-def456,-ghi789"); + expect(result).toEqual(["-abc123", "-def456", "-ghi789"]); + }); + + it("should handle spaces around commas", () => { + const result = convertRepOrderToArray("-abc123, -def456 , -ghi789"); + expect(result).toEqual(["-abc123", "-def456", "-ghi789"]); + }); + + it("should handle single item", () => { + const result = convertRepOrderToArray("-abc123"); + expect(result).toEqual(["-abc123"]); + }); + + it("should handle empty string", () => { + const result = convertRepOrderToArray(""); + expect(result).toEqual([""]); + }); + + it("should handle string with only commas", () => { + const result = convertRepOrderToArray(",,"); + expect(result).toEqual(["", "", ""]); + }); + + it("should handle mixed spacing", () => { + const result = convertRepOrderToArray(" -abc123 , -def456, -ghi789 "); + expect(result).toEqual(["-abc123", "-def456", "-ghi789"]); + }); + }); + + describe("getIDFromIndex", () => { + const repOrder = ["-abc123", "-def456", "-ghi789"]; + + it("should return row ID for valid index identifiers", () => { + expect(getIDFromIndex("repeating_weapons_$1_name", repOrder)).toBe("-abc123"); + expect(getIDFromIndex("repeating_weapons_$2_name", repOrder)).toBe("-def456"); + expect(getIDFromIndex("repeating_weapons_$3_name", repOrder)).toBe("-ghi789"); + }); + + it("should return null for index out of range", () => { + expect(getIDFromIndex("repeating_weapons_$0_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$4_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$-1_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$999_name", repOrder)).toBeNull(); + }); + + it("should return null for non-index identifiers", () => { + expect(getIDFromIndex("repeating_weapons_CREATE_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_-abc123_name", repOrder)).toBeNull(); + }); + + it("should return null for non-repeating attributes", () => { + expect(getIDFromIndex("strength", repOrder)).toBeNull(); + }); + + it("should return null for invalid index format", () => { + expect(getIDFromIndex("repeating_weapons_$abc_name", repOrder)).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$_name", repOrder)).toBeNull(); + }); + + it("should handle empty repOrder array", () => { + expect(getIDFromIndex("repeating_weapons_$1_name", [])).toBeNull(); + }); + + it("should handle leading zeros in index", () => { + // Leading zeros should be parsed correctly (01 -> 1, 02 -> 2) + expect(getIDFromIndex("repeating_weapons_$01_name", repOrder)).toBe("-abc123"); + expect(getIDFromIndex("repeating_weapons_$02_name", repOrder)).toBe("-def456"); + }); + }); + + describe("getRepOrderForSection", () => { + let mockGetAttribute: MockedFunction; + + beforeEach(() => { + mockGetAttribute = vi.fn(); + libSmartAttributes.getAttribute = mockGetAttribute; + }); + + it("should call libSmartAttributes.getAttribute with correct parameters", async () => { + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + await getRepOrderForSection("char123", "weapons"); + + expect(mockGetAttribute).toHaveBeenCalledWith("char123", "_reporder_repeating_weapons"); + }); + + it("should return the reporder value", async () => { + const mockRepOrder = "-abc123,-def456,-ghi789"; + mockGetAttribute.mockResolvedValue(mockRepOrder); + + const result = await getRepOrderForSection("char123", "weapons"); + + expect(result).toBe(mockRepOrder); + }); + + it("should return undefined when libSmartAttributes returns undefined", async () => { + mockGetAttribute.mockResolvedValue(undefined); + + const result = await getRepOrderForSection("char123", "weapons"); + + expect(result).toBeUndefined(); + }); + + it("should handle different section names", async () => { + mockGetAttribute.mockResolvedValue("-test123"); + + await getRepOrderForSection("char456", "spells"); + + expect(mockGetAttribute).toHaveBeenCalledWith("char456", "_reporder_repeating_spells"); + }); + }); + + describe("extractRepeatingAttributes", () => { + it("should filter only repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "dexterity", current: "14" }, + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" }, + { name: "hp", current: "50" } + ]; + + const result = extractRepeatingAttributes(attributes); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe("repeating_weapons_-abc123_name"); + expect(result[1].name).toBe("repeating_spells_CREATE_spell_name"); + }); + + it("should return empty array when no repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { name: "dexterity", current: "14" }, + { name: "hp", current: "50" } + ]; + + const result = extractRepeatingAttributes(attributes); + + expect(result).toEqual([]); + }); + + it("should handle attributes without names", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { current: "14" }, // No name + { name: "repeating_weapons_-abc123_name", current: "Sword" } + ]; + + const result = extractRepeatingAttributes(attributes); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("repeating_weapons_-abc123_name"); + }); + + it("should handle empty array", () => { + const result = extractRepeatingAttributes([]); + expect(result).toEqual([]); + }); + }); + + describe("getAllSectionNames", () => { + it("should extract unique section names from repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "repeating_weapons_-def456_damage", current: "1d8" }, + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" }, + { name: "repeating_inventory_$1_item", current: "Potion" }, + { name: "repeating_spells_-ghi789_level", current: "3" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toHaveLength(3); + expect(result).toContain("weapons"); + expect(result).toContain("spells"); + expect(result).toContain("inventory"); + expect(result.sort()).toEqual(["inventory", "spells", "weapons"]); + }); + + it("should return empty array for no repeating attributes", () => { + const attributes: Attribute[] = [ + { name: "strength", current: "18" }, + { name: "dexterity", current: "14" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toEqual([]); + }); + + it("should handle attributes without names", () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { current: "14" }, // No name + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toHaveLength(2); + expect(result).toContain("weapons"); + expect(result).toContain("spells"); + }); + + it("should handle empty array", () => { + const result = getAllSectionNames([]); + expect(result).toEqual([]); + }); + + it("should handle malformed repeating attributes gracefully", () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "repeating_invalid", current: "bad" }, + { name: "repeating_spells_CREATE_spell_name", current: "Fireball" } + ]; + + const result = getAllSectionNames(attributes); + + expect(result).toHaveLength(2); + expect(result).toContain("weapons"); + expect(result).not.toContain("invalid"); // "repeating_invalid" has section "invalid" + expect(result).toContain("spells"); + }); + }); + + describe("getAllRepOrders", () => { + let mockGetAttribute: MockedFunction; + + beforeEach(() => { + mockGetAttribute = vi.fn(); + libSmartAttributes.getAttribute = mockGetAttribute; + }); + + it("should get reporders for all sections", async () => { + mockGetAttribute + .mockResolvedValueOnce("-abc123,-def456") // weapons + .mockResolvedValueOnce("-ghi789,-jkl101"); // spells + + const result = await getAllRepOrders("char123", ["weapons", "spells"]); + + expect(mockGetAttribute).toHaveBeenCalledWith("char123", "_reporder_repeating_weapons"); + expect(mockGetAttribute).toHaveBeenCalledWith("char123", "_reporder_repeating_spells"); + expect(result).toEqual({ + weapons: ["-abc123", "-def456"], + spells: ["-ghi789", "-jkl101"] + }); + }); + + it("should handle sections with no reporder", async () => { + mockGetAttribute + .mockResolvedValueOnce("-abc123,-def456") // weapons + .mockResolvedValueOnce(undefined); // spells - no reporder + + const result = await getAllRepOrders("char123", ["weapons", "spells"]); + + expect(result).toEqual({ + weapons: ["-abc123", "-def456"], + spells: [] + }); + }); + + it("should handle empty section names array", async () => { + const result = await getAllRepOrders("char123", []); + + expect(result).toEqual({}); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + + it("should handle single section", async () => { + mockGetAttribute.mockResolvedValue("-abc123"); + + const result = await getAllRepOrders("char123", ["weapons"]); + + expect(result).toEqual({ + weapons: ["-abc123"] + }); + }); + }); + + describe("processRepeatingAttributes", () => { + let mockGetAttribute: MockedFunction; + + beforeEach(() => { + mockGetAttribute = vi.fn(); + libSmartAttributes.getAttribute = mockGetAttribute; + vi.stubGlobal("libUUID", { + generateRowID: vi.fn().mockReturnValue("-new123") + }); + }); + + it("should process normal repeating attributes unchanged", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Sword" }, + { name: "strength", current: "18" } // Non-repeating + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "Sword" + }); + }); + + it("should process CREATE identifiers by generating new IDs", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_CREATE_name", current: "New Sword" } + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "New Sword" + }); + expect(libUUID.generateRowID).toHaveBeenCalled(); + }); + + it("should process index identifiers correctly", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_$1_name", current: "First Weapon" }, + { name: "repeating_weapons_$2_damage", current: "1d8" } + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + const result = await processRepeatingAttributes("char123", attributes); + + // Should resolve $1 -> -abc123 and $2 -> -def456 + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "First Weapon" + }); + expect(result[1]).toEqual({ + name: "repeating_weapons_-def456_damage", + current: "1d8" + }); + }); + + it("should skip attributes with invalid index identifiers", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_$1_name", current: "First Weapon" }, + { name: "repeating_weapons_$5_name", current: "Invalid Index" } // Index out of range + ]; + + mockGetAttribute.mockResolvedValue("-abc123,-def456"); + + const result = await processRepeatingAttributes("char123", attributes); + + // $1 should work, $5 should be skipped (out of range) + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "First Weapon" + }); + }); + + it("should handle mixed attribute types", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_-abc123_name", current: "Existing Sword" }, + { name: "repeating_weapons_CREATE_name", current: "New Sword" }, + { name: "repeating_weapons_$1_damage", current: "1d8" }, + { name: "repeating_spells_CREATE_spell", current: "New Spell" } + ]; + + mockGetAttribute + .mockResolvedValueOnce("-abc123,-def456") // weapons + .mockResolvedValueOnce("-ghi789"); // spells + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ + name: "repeating_weapons_-abc123_name", + current: "Existing Sword" + }); + expect(result[1]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "New Sword" + }); + expect(result[2]).toEqual({ + name: "repeating_weapons_-abc123_damage", + current: "1d8" + }); + expect(result[3]).toEqual({ + name: "repeating_spells_-new123_spell", + current: "New Spell" + }); + }); + + it("should handle attributes without names", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_CREATE_name", current: "New Sword" }, + { current: "No name" } // No name property + ]; + + mockGetAttribute.mockResolvedValue("-abc123"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "New Sword" + }); + }); + + it("should handle malformed repeating attributes", async () => { + const attributes: Attribute[] = [ + { name: "repeating_weapons_CREATE_name", current: "Valid" }, + { name: "repeating_invalid", current: "Invalid" } // Malformed but valid structure + ]; + + mockGetAttribute.mockResolvedValue("-abc123"); + + const result = await processRepeatingAttributes("char123", attributes); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: "repeating_weapons_-new123_name", + current: "Valid" + }); + }); + + it("should handle empty attributes array", async () => { + const result = await processRepeatingAttributes("char123", []); + + expect(result).toEqual([]); + expect(mockGetAttribute).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/targets.test.ts b/ChatSetAttr/src/__tests__/unit/targets.test.ts new file mode 100644 index 0000000000..bec9929083 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/targets.test.ts @@ -0,0 +1,456 @@ +import { beforeEach, it, expect, describe, vi } from "vitest"; +import { generateTargets } from "../../modules/targets"; +import { checkPermissionForTarget, getPermissions } from "../../modules/permissions"; +import { getConfig } from "../../modules/config"; + +const makeMockMessage = ( + content: string = "", + selected: string[] = [] +): Roll20ChatMessage => { + return { + who: "testPlayer", + content, + selected: selected.map(id => ({ _id: id })), + } as Roll20ChatMessage; +}; + +const makeMockCharacter = ( + id: string, + controlledBy: string | null = null, + inParty: boolean = false +): Roll20Character => { + return { + id, + get: (prop: string) => { + if (prop === "controlledby") return controlledBy || ""; + if (prop === "inParty") return inParty; + return ""; + }, + } as Roll20Character; +}; + +const makeMockToken = ( + id: string, + represents: string | null = null +): Roll20Graphic => { + return { + id, + get: (prop: string) => { + if (prop === "represents") return represents || ""; + if (prop === "_subtype") return "token"; + return ""; + }, + } as Roll20Graphic; +}; + +vi.mock("../../modules/permissions", () => { + return { + getPermissions: vi.fn(), + checkPermissionForTarget: vi.fn(), + }; +}); + +vi.mock("../../modules/config", () => { + return { + getConfig: vi.fn(), + }; +}); + +describe("generateTargets", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("target = all", () => { + it("should report an error if the user is not a GM", () => { + // arrange + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: false }); + const message = makeMockMessage("", []); + + // act + const result = generateTargets(message, ["all"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'all' target option."); + }); + + it("should return all character IDs for 'all' target", () => { + // arrange + const message = makeMockMessage("", []); + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", null); + const characterThree = makeMockCharacter("char3", "player2"); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + characterThree, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["all"]); + + // assert + expect(result.targets).toEqual(["char1", "char2", "char3"]); + }); + }); + + describe("target = allgm", () => { + it("should report an error if the user is not a GM", () => { + // arrange + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: false }); + const message = makeMockMessage("", []); + + // act + const result = generateTargets(message, ["allgm"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'allgm' target option."); + }); + + it("should return all GM character IDs for 'allgm' target", () => { + // arrange + const message = makeMockMessage("", []); + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", null); + const characterThree = makeMockCharacter("char3", "player2"); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + characterThree, + ] as Roll20Character[]); + + // act + const { targets } = generateTargets(message, ["allgm"]); + + // assert + expect(targets).toEqual(["char2"]); + }); + }); + + describe("target = allplayers", () => { + it("should report an error if the user is not a GM", () => { + // arrange + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: false }); + const message = makeMockMessage("", []); + + // act + const result = generateTargets(message, ["allplayers"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'allplayers' target option."); + }); + + it("should return all player character IDs for 'allplayers' target", () => { + // arrange + const message = makeMockMessage("", []); + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", null); + const characterThree = makeMockCharacter("char3", "player2"); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + characterThree, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["allplayers"]); + + // assert + expect(result.targets).toEqual(["char1", "char3"]); + }); + }); + + describe("target = sel", () => { + it("should return character IDs based on selected tokens", () => { + // arrange + const characterOne = makeMockCharacter("char1"); + const characterTwo = makeMockCharacter("char2"); + const tokenOne = makeMockToken("token1", "char1"); + const tokenTwo = makeMockToken("token2", "char2"); + const message = makeMockMessage("", ["token1", "token2"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic") { + if (id === "token1") return tokenOne; + if (id === "token2") return tokenTwo; + } + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + } + return null; + }); + + // act + const result = generateTargets(message, ["sel"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + }); + + it("should handle missing tokens gracefully", () => { + // arrange + const message = makeMockMessage("", ["token1", "token2"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockReturnValue(null); + + // act + const result = generateTargets(message, ["sel"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Selected token with ID token1 not found."); + expect(result.errors).toContain("Selected token with ID token2 not found."); + }); + + it("should handle tokens that don't represent characters", () => { + // arrange + const tokenOne = makeMockToken("token1", ""); + const message = makeMockMessage("", ["token1"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic" && id === "token1") return tokenOne; + return null; + }); + + // act + const result = generateTargets(message, ["sel"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Token with ID token1 does not represent a character."); + }); + }); + + describe("target = charid", () => { + it("should return character IDs if the player has permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["charid char1,char2"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + }); + + it("should report an error for character IDs without permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockImplementation((playerID: string, target: string) => { + return target !== "char3"; + }); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["charid char1,char3,char2"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toContain("Permission error. You do not have permission to modify character with ID char3."); + }); + }); + + describe("target = name", () => { + it("should return character IDs based on names if the player has permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + const name = props.name as string; + if (name === "Alice") return [characterOne]; + if (name === "Bob") return [characterTwo]; + if (name === "Charlie") return [characterThree]; + return []; + }); + + // act + const result = generateTargets(message, ["name Alice,Bob"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + }); + + it("should report an error for names without permission", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1"); + const characterTwo = makeMockCharacter("char2", "player1"); + const characterThree = makeMockCharacter("char3", "player2"); + const message = makeMockMessage("", []); + vi.mocked(checkPermissionForTarget).mockImplementation((playerID: string, target: string) => { + return target !== "char3"; + }); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + const name = props.name as string; + if (name === "Alice") return [characterOne]; + if (name === "Bob") return [characterTwo]; + if (name === "Charlie") return [characterThree]; + return []; + }); + + // act + const result = generateTargets(message, ["name Alice,Charlie,Bob"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toContain("Permission error. You do not have permission to modify character with name \"Charlie\"."); + }); + }); + + describe("target = party", () => { + it("should return all party character IDs when GM", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", null, true); + const message = makeMockMessage("", []); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: true, canModify: true }); + vi.mocked(getConfig).mockReturnValue({ playersCanTargetParty: false } as ReturnType); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["party"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toEqual([]); + }); + + it("should report an error if player cannot target party", () => { + // arrange + const message = makeMockMessage("", []); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(getConfig).mockReturnValue({ playersCanTargetParty: false } as ReturnType); + + // act + const result = generateTargets(message, ["party"]); + + // assert + expect(result.targets).toEqual([]); + expect(result.errors).toContain("Only GMs can use the 'party' target option."); + }); + + it("should return party character IDs when player is allowed", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", null, true); + const message = makeMockMessage("", []); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(getConfig).mockReturnValue({ playersCanTargetParty: true } as ReturnType); + vi.mocked(global.findObjs).mockReturnValueOnce([ + characterOne, + characterTwo, + ] as Roll20Character[]); + + // act + const result = generateTargets(message, ["party"]); + + // assert + expect(result.targets).toEqual(["char1", "char2"]); + expect(result.errors).toEqual([]); + }); + }); + + describe("target = sel-party", () => { + it("should return only party characters from selected tokens", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", "player1", false); + const characterThree = makeMockCharacter("char3", "player1", true); + const tokenOne = makeMockToken("token1", "char1"); + const tokenTwo = makeMockToken("token2", "char2"); + const tokenThree = makeMockToken("token3", "char3"); + const message = makeMockMessage("", ["token1", "token2", "token3"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic") { + if (id === "token1") return tokenOne; + if (id === "token2") return tokenTwo; + if (id === "token3") return tokenThree; + } + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["sel-party"]); + + // assert + expect(result.targets).toEqual(["char1", "char3"]); + }); + }); + + describe("target = sel-noparty", () => { + it("should return only non-party characters from selected tokens", () => { + // arrange + const characterOne = makeMockCharacter("char1", "player1", true); + const characterTwo = makeMockCharacter("char2", "player1", false); + const characterThree = makeMockCharacter("char3", "player1", true); + const tokenOne = makeMockToken("token1", "char1"); + const tokenTwo = makeMockToken("token2", "char2"); + const tokenThree = makeMockToken("token3", "char3"); + const message = makeMockMessage("", ["token1", "token2", "token3"]); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.getObj).mockImplementation((type: string, id: string) => { + if (type === "graphic") { + if (id === "token1") return tokenOne; + if (id === "token2") return tokenTwo; + if (id === "token3") return tokenThree; + } + if (type === "character") { + if (id === "char1") return characterOne; + if (id === "char2") return characterTwo; + if (id === "char3") return characterThree; + } + return null; + }); + + // act + const result = generateTargets(message, ["sel-noparty"]); + + // assert + expect(result.targets).toEqual(["char2"]); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/timer.test.ts b/ChatSetAttr/src/__tests__/unit/timer.test.ts new file mode 100644 index 0000000000..098824fafe --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/timer.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { startTimer, clearTimer, clearAllTimers } from "../../modules/timer"; + +describe("timer", () => { + beforeEach(() => { + // Mock timers before each test + vi.useFakeTimers(); + }); + + afterEach(() => { + // Clean up timers after each test + vi.clearAllTimers(); + vi.useRealTimers(); + clearAllTimers(); + }); + + describe("startTimer", () => { + it("should execute callback after specified duration", () => { + const callback = vi.fn(); + const duration = 1000; + + startTimer("test-key", duration, callback); + + // Callback should not be called immediately + expect(callback).not.toHaveBeenCalled(); + + // Fast-forward time + vi.advanceTimersByTime(duration); + + // Callback should now be called + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should use default duration of 50ms when not specified", () => { + const callback = vi.fn(); + + startTimer("test-key", undefined, callback); + + // Advance by default duration (50ms) + vi.advanceTimersByTime(50); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should clear existing timer when starting new timer with same key", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const duration = 1000; + + // Start first timer + startTimer("same-key", duration, callback1); + + // Advance time partially + vi.advanceTimersByTime(duration / 2); + + // Start second timer with same key + startTimer("same-key", duration, callback2); + + // Advance time to complete the original duration + vi.advanceTimersByTime(duration / 2); + + // First callback should not be called (timer was cleared) + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + + // Advance time to complete the second timer + vi.advanceTimersByTime(duration / 2); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should handle multiple timers with different keys", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + startTimer("key1", 100, callback1); + startTimer("key2", 200, callback2); + startTimer("key3", 300, callback3); + + // Advance to first timer completion + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + // Advance to second timer completion + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).not.toHaveBeenCalled(); + + // Advance to third timer completion + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledTimes(1); + }); + + it("should remove timer from map after execution", () => { + const callback = vi.fn(); + + startTimer("cleanup-test", 100, callback); + + // Timer should be active + vi.advanceTimersByTime(50); + expect(callback).not.toHaveBeenCalled(); + + // Complete the timer + vi.advanceTimersByTime(50); + expect(callback).toHaveBeenCalledTimes(1); + + // Starting a new timer with same key should not interfere + const callback2 = vi.fn(); + startTimer("cleanup-test", 100, callback2); + vi.advanceTimersByTime(100); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should handle zero duration", () => { + const callback = vi.fn(); + + startTimer("zero-duration", 0, callback); + + // Should execute immediately on next tick + vi.advanceTimersByTime(0); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("clearTimer", () => { + it("should prevent timer from executing when cleared", () => { + const callback = vi.fn(); + + startTimer("clear-test", 1000, callback); + + // Advance time partially + vi.advanceTimersByTime(500); + expect(callback).not.toHaveBeenCalled(); + + // Clear the timer + clearTimer("clear-test"); + + // Advance time past original completion + vi.advanceTimersByTime(1000); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should handle clearing non-existent timer gracefully", () => { + // This should not throw an error + expect(() => { + clearTimer("non-existent-key"); + }).not.toThrow(); + }); + + it("should only clear timer for specified key", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + startTimer("key1", 1000, callback1); + startTimer("key2", 1000, callback2); + + // Clear only one timer + clearTimer("key1"); + + // Advance time + vi.advanceTimersByTime(1000); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should allow clearing already completed timer", () => { + const callback = vi.fn(); + + startTimer("completed-test", 100, callback); + + // Complete the timer + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(1); + + // Clearing should not cause issues + expect(() => { + clearTimer("completed-test"); + }).not.toThrow(); + }); + }); + + describe("clearAllTimers", () => { + it("should clear all active timers", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + startTimer("key1", 1000, callback1); + startTimer("key2", 1500, callback2); + startTimer("key3", 2000, callback3); + + // Advance time partially + vi.advanceTimersByTime(500); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + // Clear all timers + clearAllTimers(); + + // Advance time past all original completion times + vi.advanceTimersByTime(2000); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + }); + + it("should handle clearing when no timers exist", () => { + // This should not throw an error + expect(() => { + clearAllTimers(); + }).not.toThrow(); + }); + + it("should allow starting new timers after clearing all", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + // Start and clear timers + startTimer("key1", 1000, callback1); + clearAllTimers(); + + // Start new timer + startTimer("key2", 500, callback2); + vi.advanceTimersByTime(500); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should clear timers even if some have already completed", () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + startTimer("key1", 100, callback1); // Will complete + startTimer("key2", 1000, callback2); // Will be cleared + + // Complete first timer + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + + // Clear all timers (including the remaining active one) + clearAllTimers(); + + // Advance time + vi.advanceTimersByTime(1000); + expect(callback2).not.toHaveBeenCalled(); + }); + }); + + describe("edge cases and integration", () => { + it("should handle rapid timer creation and clearing", () => { + const callback = vi.fn(); + + // Rapidly create and clear timers + for (let i = 0; i < 10; i++) { + startTimer("rapid-test", 1000, callback); + if (i < 9) { + clearTimer("rapid-test"); + } + } + + // Only the last timer should remain + vi.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should handle timer callback that throws an error", () => { + const errorCallback = vi.fn(() => { + throw new Error("Timer callback error"); + }); + const normalCallback = vi.fn(); + + startTimer("error-test", 100, errorCallback); + startTimer("normal-test", 200, normalCallback); + + // The error should not prevent other timers from working + expect(() => { + vi.advanceTimersByTime(100); + }).toThrow("Timer callback error"); + + // Normal timer should still work + vi.advanceTimersByTime(100); + expect(normalCallback).toHaveBeenCalledTimes(1); + }); + + it("should handle callback that starts another timer", () => { + const callback2 = vi.fn(); + const callback1 = vi.fn(() => { + startTimer("chained-timer", 100, callback2); + }); + + startTimer("initial-timer", 100, callback1); + + // Complete first timer + vi.advanceTimersByTime(100); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + + // Complete chained timer + vi.advanceTimersByTime(100); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it("should handle very long durations", () => { + const callback = vi.fn(); + const longDuration = 1000000; // 1 million ms (about 16 minutes) + + startTimer("long-timer", longDuration, callback); + + // Advance by a large amount (but less than the duration) + vi.advanceTimersByTime(999999); + expect(callback).not.toHaveBeenCalled(); + + // Clear the timer + clearTimer("long-timer"); + + // Advance past the original duration to ensure it doesn't execute + vi.advanceTimersByTime(2); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should maintain timer isolation between different keys", () => { + const callbacks = Array.from({ length: 5 }, () => vi.fn()); + + // Start multiple timers with different keys and durations + callbacks.forEach((callback, index) => { + startTimer(`key-${index}`, (index + 1) * 100, callback); + }); + + // Clear one timer in the middle + clearTimer("key-2"); + + // Advance time to complete all timers + vi.advanceTimersByTime(500); + + // Check that only the cleared timer didn't execute + callbacks.forEach((callback, index) => { + if (index === 2) { + expect(callback).not.toHaveBeenCalled(); + } else { + expect(callback).toHaveBeenCalledTimes(1); + } + }); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/update.test.ts b/ChatSetAttr/src/__tests__/unit/update.test.ts new file mode 100644 index 0000000000..dab8b23c6d --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/update.test.ts @@ -0,0 +1,990 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { AttributeRecord } from "../../types"; +import { makeUpdate } from "../../modules/updates"; + +// Mock the config module +vi.mock("../../modules/config", () => ({ + getConfig: vi.fn(), +})); + +// Mock libSmartAttributes global +const mocklibSmartAttributes = { + getAttribute: vi.fn(), + setAttribute: vi.fn(), + deleteAttribute: vi.fn(), +}; + +global.libSmartAttributes = mocklibSmartAttributes; + +import { getConfig } from "../../modules/config"; +const mockGetConfig = vi.mocked(getConfig); + +describe("updates", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetConfig.mockReturnValue({ setWithWorker: false }); + }); + + describe("Setting Attributes", () => { + it("should set regular current attributes", async () => { + const results: Record = { + "char1": { + "strength": 15, + "dexterity": 12, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: false } + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "dexterity", + 12, + "current", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should set max attributes with _max suffix", async () => { + const results: Record = { + "char1": { + "hp_max": 25, + "mp_max": 15, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "hp", + 25, + "max", + { noCreate: false, setWithWorker: false } + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "mp", + 15, + "max", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should handle mixed current and max attributes", async () => { + const results: Record = { + "char1": { + "hp": 20, + "hp_max": 25, + "strength": 14, + "mp_max": 10, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(4); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 20, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 25, "max", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 14, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "mp", 10, "max", expect.any(Object) + ); + }); + + it("should convert undefined values to empty strings", async () => { + const results: Record = { + "char1": { + "attribute1": undefined, + "attribute2_max": undefined, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "attribute1", + "", + "current", + { noCreate: false, setWithWorker: false } + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "attribute2", + "", + "max", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should handle different value types", async () => { + const results: Record = { + "char1": { + "name": "Gandalf", + "level": 10, + "active": true, + "bonus": 1.5, + "zero": 0, + "falsy": false, + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "name", "Gandalf", "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "level", 10, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "active", true, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "bonus", 1.5, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "zero", 0, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "falsy", false, "current", expect.any(Object) + ); + }); + + it("should handle multiple targets", async () => { + const results: Record = { + "char1": { "strength": 15 }, + "char2": { "dexterity": 12 }, + "char3": { "wisdom": 14 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(3); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 15, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char2", "dexterity", 12, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char3", "wisdom", 14, "current", expect.any(Object) + ); + }); + + it("should handle setAttribute errors", async () => { + const results: Record = { + "char1": { + "success": 15, + "failure": 12, + }, + }; + + mocklibSmartAttributes.setAttribute + .mockResolvedValueOnce(undefined) // success succeeds + .mockRejectedValueOnce(new Error("Failed to set failure")); // failure fails + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'failure' on target 'char1': Error: Failed to set failure", + ]); + }); + + it("should handle mixed success and failure across multiple attributes", async () => { + const results: Record = { + "char1": { + "success1": 10, + "failure1": 20, + "success2": 30, + "failure2": 40, + }, + }; + + mocklibSmartAttributes.setAttribute + .mockResolvedValueOnce(undefined) // success1 + .mockRejectedValueOnce(new Error("Error 1")) // failure1 + .mockResolvedValueOnce(undefined) // success2 + .mockRejectedValueOnce(new Error("Error 2")); // failure2 + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'failure1' on target 'char1': Error: Error 1", + "Failed to set attribute 'failure2' on target 'char1': Error: Error 2", + ]); + }); + + it("should handle non-Error thrown objects", async () => { + const results: Record = { + "char1": { + "attr1": "value1", + "attr2": "value2", + "attr3": "value3", + }, + }; + + mocklibSmartAttributes.setAttribute + .mockRejectedValueOnce("String error") + .mockRejectedValueOnce(null) + .mockRejectedValueOnce(undefined); + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'attr1' on target 'char1': String error", + "Failed to set attribute 'attr2' on target 'char1': null", + "Failed to set attribute 'attr3' on target 'char1': undefined", + ]); + }); + + it("should handle edge case attribute names", async () => { + const results: Record = { + "char1": { + "_max": "value", // attribute named exactly "_max" + "a_max": "value", // single character before _max + "": "empty_name", // empty string name + "max": "value", // attribute named "max" without underscore + "not_max_attribute": 10, // contains "max" but doesn't end with "_max" + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + const result = await makeUpdate("setattr", results); + + // "_max" should be treated as a max attribute with empty actualName + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "value", "max", expect.any(Object) + ); + // "a_max" should be treated as max attribute for "a" + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "a", "value", "max", expect.any(Object) + ); + // Empty string name should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "empty_name", "current", expect.any(Object) + ); + // "max" without underscore should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "max", "value", "current", expect.any(Object) + ); + // "not_max_attribute" should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "not_max_attribute", 10, "current", expect.any(Object) + ); + + expect(result.errors).toEqual([]); + }); + }); + + describe("other setting commands", () => { + it("should handle modattr the same as setattr", async () => { + const results: Record = { + "char1": { "strength": 15, "hp_max": 25 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("modattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 15, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 25, "max", expect.any(Object) + ); + }); + + it("should handle modbattr the same as setattr", async () => { + const results: Record = { + "char1": { "dexterity": 12, "mp_max": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("modbattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "dexterity", 12, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "mp", 15, "max", expect.any(Object) + ); + }); + + it("should handle resetattr the same as setattr", async () => { + const results: Record = { + "char1": { "wisdom": 14, "sp_max": 20 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("resetattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "wisdom", 14, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "sp", 20, "max", expect.any(Object) + ); + }); + }); + + describe("delattr - comprehensive functionality tests", () => { + it("should delete regular attributes", async () => { + const results: Record = { + "char1": { + "oldAttribute": "someValue", // value should be ignored for delete + "temporaryAttr": 42, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "oldAttribute", + "current" + ); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "temporaryAttr", + "current" + ); + }); + + it("should handle multiple targets", async () => { + const results: Record = { + "char1": { "oldAttr1": "value" }, + "char2": { "oldAttr2": "value" }, + "char3": { "oldAttr3": "value" }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should handle deleteAttribute errors", async () => { + const results: Record = { + "char1": { + "attr1": "value", + "attr2": "value", + }, + }; + + mocklibSmartAttributes.deleteAttribute + .mockResolvedValueOnce(undefined) // attr1 succeeds + .mockRejectedValueOnce(new Error("Cannot delete attr2")); // attr2 fails + + const result = await makeUpdate("delattr", results); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'attr2' on target 'char1': Error: Cannot delete attr2", + ]); + }); + }); + + describe("options handling", () => { + it("should use default options when none provided", async () => { + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: false } + ); + }); + + it("should use provided noCreate option", async () => { + const results: Record = { + "char1": { "strength": 15 }, + }; + const options = { noCreate: true }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results, options); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: true, setWithWorker: false } + ); + }); + + it("should use setWithWorker from config", async () => { + mockGetConfig.mockReturnValue({ setWithWorker: true }); + + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: true } + ); + }); + + it("should combine options and config", async () => { + mockGetConfig.mockReturnValue({ setWithWorker: true }); + + const results: Record = { + "char1": { "strength": 15 }, + }; + const options = { noCreate: true }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results, options); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: true, setWithWorker: true } + ); + }); + }); + + describe("edge cases", () => { + it("should handle empty results", async () => { + const results: Record = {}; + + const result = await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).not.toHaveBeenCalled(); + expect(mocklibSmartAttributes.deleteAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("should handle targets with no attributes", async () => { + const results: Record = { + "char1": {}, + "char2": {}, + }; + + const result = await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("should handle mixed success and failure", async () => { + const results: Record = { + "char1": { + "success1": 10, + "failure1": 20, + "success2": 30, + "failure2": 40, + }, + }; + + mocklibSmartAttributes.setAttribute + .mockResolvedValueOnce(undefined) // success1 + .mockRejectedValueOnce(new Error("Error 1")) // failure1 + .mockResolvedValueOnce(undefined) // success2 + .mockRejectedValueOnce(new Error("Error 2")); // failure2 + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'failure1' on target 'char1': Error: Error 1", + "Failed to set attribute 'failure2' on target 'char1': Error: Error 2", + ]); + }); + + it("should handle non-Error thrown objects", async () => { + const results: Record = { + "char1": { "attr": "value" }, + }; + + mocklibSmartAttributes.setAttribute.mockRejectedValue("String error"); + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'attr' on target 'char1': String error", + ]); + }); + + it("should handle null and undefined thrown objects", async () => { + const results: Record = { + "char1": { + "attr1": "value1", + "attr2": "value2", + }, + }; + + mocklibSmartAttributes.setAttribute + .mockRejectedValueOnce(null) + .mockRejectedValueOnce(undefined); + + const result = await makeUpdate("setattr", results); + + expect(result.errors).toEqual([ + "Failed to set attribute 'attr1' on target 'char1': null", + "Failed to set attribute 'attr2' on target 'char1': undefined", + ]); + }); + }); + + describe("attribute name processing", () => { + it("should correctly identify and process _max attributes", async () => { + const results: Record = { + "char1": { + "hp": 20, + "hp_max": 25, + "strength_max": 18, + "not_max_attribute": 10, // contains "max" but doesn't end with "_max" + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(1, + "char1", "hp", 20, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(2, + "char1", "hp", 25, "max", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(3, + "char1", "strength", 18, "max", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenNthCalledWith(4, + "char1", "not_max_attribute", 10, "current", expect.any(Object) + ); + }); + + it("should handle edge case attribute names", async () => { + const results: Record = { + "char1": { + "_max": "value", // attribute named exactly "_max" + "a_max": "value", // single character before _max + "": "empty_name", // empty string name + "max": "value", // attribute named "max" without underscore + "not_max_attribute": 10, // contains "max" but doesn't end with "_max" + }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + // "_max" should be treated as a max attribute with empty actualName + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "value", "max", expect.any(Object) + ); + // "a_max" should be treated as max attribute for "a" + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "a", "value", "max", expect.any(Object) + ); + // Empty string name should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "", "empty_name", "current", expect.any(Object) + ); + // "max" without underscore should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "max", "value", "current", expect.any(Object) + ); + // "not_max_attribute" should be current type + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "not_max_attribute", 10, "current", expect.any(Object) + ); + + const result = await makeUpdate("setattr", results); + expect(result.errors).toEqual([]); + }); + }); + + describe("other setting commands - verification tests", () => { + it("should handle modattr the same as setattr", async () => { + const results: Record = { + "char1": { "strength": 15, "hp_max": 25 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("modattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "strength", 15, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "hp", 25, "max", expect.any(Object) + ); + }); + + it("should handle modbattr the same as setattr", async () => { + const results: Record = { + "char1": { "dexterity": 12, "mp_max": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("modbattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "dexterity", 12, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "mp", 15, "max", expect.any(Object) + ); + }); + + it("should handle resetattr the same as setattr", async () => { + const results: Record = { + "char1": { "wisdom": 14, "sp_max": 20 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("resetattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "wisdom", 14, "current", expect.any(Object) + ); + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", "sp", 20, "max", expect.any(Object) + ); + }); + }); + + describe("delattr - comprehensive functionality tests", () => { + it("should delete regular attributes", async () => { + const results: Record = { + "char1": { + "strength": 15, + "dexterity": 12, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "strength", + "current" + ); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "dexterity", + "current" + ); + }); + + it("should delete max attributes with _max suffix", async () => { + const results: Record = { + "char1": { + "hp_max": 25, + "mp_max": 15, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(2); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "hp", + "max" + ); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( + "char1", + "mp", + "max" + ); + }); + + it("should handle multiple targets", async () => { + const results: Record = { + "char1": { "strength": 15 }, + "char2": { "dexterity": 12 }, + "char3": { "wisdom": 14 }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(3); + }); + + it("should handle deleteAttribute errors", async () => { + const results: Record = { + "char1": { + "strength": 15, + "dexterity": 12, + }, + }; + + mocklibSmartAttributes.deleteAttribute + .mockResolvedValueOnce(undefined) // strength succeeds + .mockRejectedValueOnce(new Error("Failed to delete dexterity")); // dexterity fails + + const result = await makeUpdate("delattr", results); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'dexterity' on target 'char1': Error: Failed to delete dexterity", + ]); + }); + + it("should ignore attribute values for deletion", async () => { + const results: Record = { + "char1": { + "attr1": "any value", + "attr2": 123, + "attr3": "", + "attr4": undefined, + "attr5": true, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + const result = await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(5); + // Values should be ignored - only character and attribute name matter + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr1", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr2", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr3", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr4", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr5", "current"); + + expect(result.errors).toEqual([]); + }); + + it("should handle mixed current and max attribute deletions", async () => { + const results: Record = { + "char1": { + "hp": 20, + "hp_max": 25, + "strength": 14, + "mp_max": 10, + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(4); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "hp", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "hp", "max"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "strength", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "mp", "max"); + }); + + it("should handle delete errors for mixed current and max attributes", async () => { + const results: Record = { + "char1": { + "hp": 20, + "hp_max": 25, + }, + }; + + mocklibSmartAttributes.deleteAttribute + .mockResolvedValueOnce(undefined) // hp current succeeds + .mockRejectedValueOnce(new Error("Max deletion failed")); // hp max fails + + const result = await makeUpdate("delattr", results); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'hp' on target 'char1': Error: Max deletion failed", + ]); + }); + + it("should handle edge case attribute names for deletion", async () => { + const results: Record = { + "char1": { + "_max": "value", // attribute named exactly "_max" + "a_max": "value", // single character before _max + "": "empty_name", // empty string name + "max": "value", // attribute named "max" without underscore + }, + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + const result = await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "", "max"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "a", "max"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "max", "current"); + + expect(result.errors).toEqual([]); + }); + + it("should handle non-Error thrown objects during deletion", async () => { + const results: Record = { + "char1": { + "attr1": "value1", + "attr2": "value2", + "attr3": "value3", + }, + }; + + mocklibSmartAttributes.deleteAttribute + .mockRejectedValueOnce("String error") + .mockRejectedValueOnce(null) + .mockRejectedValueOnce(undefined); + + const result = await makeUpdate("delattr", results); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'attr1' on target 'char1': String error", + "Failed to delete attribute 'attr2' on target 'char1': null", + "Failed to delete attribute 'attr3' on target 'char1': undefined", + ]); + }); + + it("should handle large number of attributes for deletion", async () => { + const results: Record = { + "char1": Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`attr${i}`, `value${i}`]) + ), + }; + + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + + await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(10); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr0", "current"); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "attr9", "current"); + }); + + it("should handle deletion with empty results", async () => { + const results: Record = {}; + + const result = await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + + it("should handle target with no attributes", async () => { + const results: Record = { + "char1": {}, + }; + + const result = await makeUpdate("delattr", results); + + expect(mocklibSmartAttributes.deleteAttribute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([]); + expect(result.errors).toEqual([]); + }); + }); + + describe("configuration handling", () => { + it("should use setWithWorker from config", async () => { + mockGetConfig.mockReturnValue({ setWithWorker: true }); + + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: true } + ); + }); + + it("should handle undefined config", async () => { + mockGetConfig.mockReturnValue(undefined as unknown as ReturnType); + + const results: Record = { + "char1": { "strength": 15 }, + }; + + mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + + await makeUpdate("setattr", results); + + expect(mocklibSmartAttributes.setAttribute).toHaveBeenCalledWith( + "char1", + "strength", + 15, + "current", + { noCreate: false, setWithWorker: false } + ); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/versioning.test.ts b/ChatSetAttr/src/__tests__/unit/versioning.test.ts new file mode 100644 index 0000000000..6de3bc3791 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/versioning.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { checkForUpdates } from "../../modules/versioning"; +import { v2_0 } from "../../versions/version2"; +import { getConfig, setConfig } from "../../modules/config"; + +vi.mock("../../versions/version2", () => { + return { + v2_0: { + appliesTo: "<=1.10", + version: "2.0", + update: vi.fn(), + }, + }; +}); + +const version2 = vi.mocked(v2_0); + +vi.mock("../../modules/config", () => { + return { + getConfig: vi.fn(() => ({ version: "1.10" })), + setConfig: vi.fn(), + }; +}); + +describe("versioning", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset v2_0.appliesTo to its default value + vi.mocked(v2_0).appliesTo = "<=1.10"; + }); + + describe("checkForUpdates", () => { + it("should update version when current version is less than or equal to target", () => { + // arrange + + // act + checkForUpdates("1.10"); + + // assert + expect(version2.update).toHaveBeenCalled(); + }); + + it("should update version when current version is less than target", () => { + // arrange + + // act + checkForUpdates("1.9"); + + // assert + expect(version2.update).toHaveBeenCalled(); + }); + + it("should not update version when current version is greater than target", () => { + // arrange + + // act + checkForUpdates("1.11"); + + // assert + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should not update version when current version is greater than target (major version)", () => { + // arrange + + // act + checkForUpdates("2.0"); + + // assert + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should handle version strings with patch numbers", () => { + // arrange + + // act + checkForUpdates("1.10.0"); + + // assert + expect(version2.update).toHaveBeenCalled(); + }); + + it("should handle version strings with patch numbers that exceed target", () => { + // arrange + + // act + checkForUpdates("1.10.1"); + + // assert + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should call setConfig with updated version after update", () => { + // arrange + const mockConfig = { version: "1.10" }; + vi.mocked(getConfig).mockReturnValue(mockConfig); + + // act + checkForUpdates("1.9"); + + // assert + expect(version2.update).toHaveBeenCalled(); + expect(setConfig).toHaveBeenCalledWith(expect.objectContaining({ + version: "2.0" + })); + }); + + it("should handle empty version strings gracefully", () => { + // arrange + + // act & assert + expect(() => checkForUpdates("")).not.toThrow(); + // Empty string gets parsed as version "0.0.0", which is <= "1.10" + expect(version2.update).toHaveBeenCalled(); + }); + + it("should handle malformed version strings gracefully", () => { + // arrange + + // act & assert + expect(() => checkForUpdates("invalid.version")).not.toThrow(); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should handle version with only major number", () => { + // arrange + + // act + checkForUpdates("1"); + + // assert + expect(version2.update).toHaveBeenCalled(); + }); + }); + + describe("different comparison operators", () => { + beforeEach(() => { + // Reset the mock to use different appliesTo values for each test + vi.clearAllMocks(); + }); + + it("should handle < operator correctly", () => { + // arrange + vi.mocked(v2_0).appliesTo = "<1.10"; + + // act + checkForUpdates("1.9"); + + // assert + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.10"); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should handle >= operator correctly", () => { + // arrange + vi.mocked(v2_0).appliesTo = ">=1.10"; + + // act + checkForUpdates("1.10"); + + // assert + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.11"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.9"); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should handle > operator correctly", () => { + // arrange + vi.mocked(v2_0).appliesTo = ">1.10"; + + // act + checkForUpdates("1.11"); + + // assert + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.10"); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should handle = operator correctly", () => { + // arrange + vi.mocked(v2_0).appliesTo = "=1.10"; + + // act + checkForUpdates("1.10"); + + // assert + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.9"); + expect(version2.update).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.11"); + expect(version2.update).not.toHaveBeenCalled(); + }); + }); + + describe("edge cases", () => { + it("should skip version objects with invalid appliesTo format", () => { + // arrange + // Using type assertion to test invalid input handling + Object.defineProperty(vi.mocked(v2_0), "appliesTo", { + value: "invalid1.10", + writable: true, + configurable: true + }); + + // act & assert + expect(() => checkForUpdates("1.9")).not.toThrow(); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should handle appliesTo with extra whitespace", () => { + // arrange + vi.mocked(v2_0).appliesTo = "<= 1.10 "; + + // act + checkForUpdates("1.9"); + + // assert + expect(version2.update).toHaveBeenCalled(); + }); + + it("should handle multiple version objects in sequence", () => { + // This test would require mocking the VERSION_HISTORY array directly + // Since we can't easily do that with the current setup, we'll test the behavior + // by ensuring the version is updated correctly after the first update + + // arrange + const mockConfig = { version: "1.9" }; + vi.mocked(getConfig).mockReturnValue(mockConfig); + + // act + checkForUpdates("1.9"); + + // assert + expect(version2.update).toHaveBeenCalled(); + expect(setConfig).toHaveBeenCalledWith(expect.objectContaining({ + version: "2.0" + })); + }); + }); + + describe("version comparison logic", () => { + it("should correctly compare major versions", () => { + // arrange + vi.mocked(v2_0).appliesTo = "<=2.0"; + + // act & assert + checkForUpdates("1.0"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("2.0"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("3.0"); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should correctly compare minor versions when major versions are equal", () => { + // arrange + vi.mocked(v2_0).appliesTo = "<=1.5"; + + // act & assert + checkForUpdates("1.4"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.5"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.6"); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should correctly compare patch versions when major and minor versions are equal", () => { + // arrange + vi.mocked(v2_0).appliesTo = "<=1.5.3"; + + // act & assert + checkForUpdates("1.5.2"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.5.3"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.5.4"); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should treat missing patch versions as 0", () => { + // arrange + vi.mocked(v2_0).appliesTo = "<=1.5.0"; + + // act & assert + checkForUpdates("1.5"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.5.1"); + expect(version2.update).not.toHaveBeenCalled(); + }); + + it("should treat missing minor and patch versions as 0", () => { + // arrange + vi.mocked(v2_0).appliesTo = "<=1.0.0"; + + // act & assert + checkForUpdates("1"); + expect(version2.update).toHaveBeenCalled(); + + vi.clearAllMocks(); + checkForUpdates("1.0.1"); + expect(version2.update).not.toHaveBeenCalledTimes(2); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/utils/chat.test.ts b/ChatSetAttr/src/__tests__/utils/chat.test.ts new file mode 100644 index 0000000000..a950393775 --- /dev/null +++ b/ChatSetAttr/src/__tests__/utils/chat.test.ts @@ -0,0 +1,515 @@ +/* eslint-disable @stylistic/quotes */ +import { describe, it, expect } from "vitest"; +import { h, s } from "../../utils/chat"; + +describe("chat utilities", () => { + describe("h function (JSX helper)", () => { + it("should create a simple HTML tag with no attributes or children", () => { + const result = h("div"); + expect(result).toBe("

"); + }); + + it("should create a tag with text content", () => { + const result = h("p", {}, "Hello World"); + expect(result).toBe("

Hello World

"); + }); + + it("should create a tag with multiple text children", () => { + const result = h("div", {}, "First", "Second", "Third"); + expect(result).toBe("
FirstSecondThird
"); + }); + + it("should create a tag with a single attribute", () => { + const result = h("div", { class: "container" }); + expect(result).toBe("
"); + }); + + it("should create a tag with multiple attributes", () => { + const result = h("input", { type: "text", name: "username", id: "user" }); + expect(result).toBe(""); + }); + + it("should create a tag with attributes and children", () => { + const result = h("button", { type: "submit", class: "btn" }, "Click me"); + expect(result).toBe(""); + }); + + it("should handle empty attributes object", () => { + const result = h("span", {}, "Content"); + expect(result).toBe("Content"); + }); + + it("should filter out null and undefined children", () => { + const result = h("div", {}, "First", null, "Second", undefined, "Third"); + expect(result).toBe("
FirstSecondThird
"); + }); + + it("should handle empty string children", () => { + const result = h("div", {}, "First", "", "Second"); + expect(result).toBe("
FirstSecond
"); + }); + + it("should handle nested HTML structure simulation", () => { + const inner = h("span", {}, "Inner"); + const result = h("div", { class: "outer" }, "Before", inner, "After"); + expect(result).toBe('
BeforeInnerAfter
'); + }); + + it("should handle special characters in attribute values", () => { + const result = h("div", { "data-value": "test & value", title: 'Quote "test"' }); + expect(result).toBe('
'); + }); + + it("should handle special characters in children", () => { + const result = h("p", {}, "Text with & < > characters"); + expect(result).toBe("

Text with & < > characters

"); + }); + + it("should handle numeric string children", () => { + const result = h("div", {}, "Count: ", "42"); + expect(result).toBe("
Count: 42
"); + }); + + it("should handle self-closing tag behavior (treats all tags the same)", () => { + const result = h("br", { class: "line-break" }); + expect(result).toBe('

'); + }); + + it("should handle complex nested attributes", () => { + const result = h("div", { + id: "main", + class: "container fluid", + "data-toggle": "modal", + "aria-label": "Main content" + }, "Content"); + expect(result).toBe('
Content
'); + }); + + it("should handle CSS style attribute", () => { + const result = h("div", { style: "color: red; font-size: 16px;" }, "Styled text"); + expect(result).toBe('
Styled text
'); + }); + + it("should preserve order of attributes", () => { + const result = h("input", { z: "last", a: "first", m: "middle" }); + expect(result).toBe(''); + }); + + it("should handle boolean-like attribute values", () => { + const result = h("input", { disabled: "true", checked: "false" }); + expect(result).toBe(''); + }); + + it("should handle whitespace in children", () => { + const result = h("pre", {}, " Code with spaces "); + expect(result).toBe("
  Code with  spaces  
"); + }); + + describe("edge cases and undefined props", () => { + it("should handle undefined attributes parameter", () => { + const result = h("div", undefined, "Content"); + expect(result).toBe("
Content
"); + }); + + it("should handle undefined attribute values", () => { + const result = h("div", { class: "test", id: undefined as unknown as string }); + expect(result).toBe('
'); + }); + + it("should handle null attribute values", () => { + const result = h("div", { class: "test", id: null as unknown as string }); + expect(result).toBe('
'); + }); + + it("should handle empty string attribute values", () => { + const result = h("input", { type: "text", value: "", placeholder: "" }); + expect(result).toBe(''); + }); + + it("should handle zero as attribute value", () => { + const result = h("div", { tabindex: "0", "data-count": "0" }); + expect(result).toBe('
'); + }); + + it("should handle attributes with special characters in keys", () => { + const result = h("div", { "data-test-value": "test", "aria-label": "label" }); + expect(result).toBe('
'); + }); + + it("should handle empty tag name", () => { + const result = h("", {}, "Content"); + expect(result).toBe("<>Content"); + }); + + it("should handle tag name with numbers", () => { + const result = h("h1", {}, "Heading"); + expect(result).toBe("

Heading

"); + }); + + it("should handle very long attribute values", () => { + const longValue = "a".repeat(1000); + const result = h("div", { "data-long": longValue }); + expect(result).toBe(`
`); + }); + + it("should handle very long children content", () => { + const longContent = "content ".repeat(500); + const result = h("div", {}, longContent); + expect(result).toBe(`
${longContent}
`); + }); + + it("should handle numeric-like strings in children", () => { + const result = h("div", {}, "123", "456.789", "-42"); + expect(result).toBe("
123456.789-42
"); + }); + + it("should handle boolean-like strings in children", () => { + const result = h("div", {}, "true", "false", "null", "undefined"); + expect(result).toBe("
truefalsenullundefined
"); + }); + + it("should handle mixed null, undefined, and valid children", () => { + const result = h("div", {}, "Start", null, undefined, "", "End"); + expect(result).toBe("
StartEnd
"); + }); + + it("should handle attributes with Unicode characters", () => { + const result = h("div", { title: "Café München 🎉", "data-emoji": "👍" }); + expect(result).toBe('
'); + }); + + it("should handle children with Unicode characters", () => { + const result = h("p", {}, "Hello 世界", " Café ☕"); + expect(result).toBe("

Hello 世界 Café ☕

"); + }); + + it("should handle arrays of children without adding commas", () => { + const children = ["First", "Second", "Third"]; + const result = h("div", {}, children); + expect(result).toBe("
FirstSecondThird
"); + }); + + it("should handle nested arrays of children", () => { + const firstGroup = ["A", "B"]; + const secondGroup = ["C", "D"]; + const result = h("div", {}, firstGroup, secondGroup); + expect(result).toBe("
ABCD
"); + }); + + it("should handle arrays mixed with regular children", () => { + const arrayChildren = ["Middle1", "Middle2"]; + const result = h("div", {}, "Start", arrayChildren, "End"); + expect(result).toBe("
StartMiddle1Middle2End
"); + }); + + it("should handle arrays with null and undefined values", () => { + const children = ["First", null, "Second", undefined, "Third"]; + const result = h("div", {}, children); + expect(result).toBe("
FirstSecondThird
"); + }); + + it("should simulate JSX array behavior (like map)", () => { + // This simulates what happens when you do messages.map(msg =>

{msg}

) + const messages = ["Message 1", "Message 2", "Message 3"]; + const paragraphs = messages.map(message => h("p", {}, message)); + const result = h("div", {}, paragraphs); + expect(result).toBe("

Message 1

Message 2

Message 3

"); + }); + + it("should handle deeply nested arrays", () => { + const nestedArray = [["A", "B"], [["C", "D"], "E"]]; + const result = h("div", {}, ...nestedArray); + expect(result).toBe("
ABCDE
"); + }); + + it("should handle empty arrays", () => { + const result = h("div", {}, []); + expect(result).toBe("
"); + }); + + it("should handle arrays containing empty strings", () => { + const children = ["Start", "", "End"]; + const result = h("div", {}, children); + expect(result).toBe("
StartEnd
"); + }); + }); + }); + + describe("s function (style helper)", () => { + it("should convert a simple style object to CSS string", () => { + const result = s({ color: "red" }); + expect(result).toBe("color: red;"); + }); + + it("should convert multiple properties", () => { + const result = s({ color: "red", fontSize: "16px", margin: "10px" }); + expect(result).toBe("color: red;font-size: 16px;margin: 10px;"); + }); + + it("should convert camelCase to kebab-case", () => { + const result = s({ backgroundColor: "blue", borderRadius: "5px" }); + expect(result).toBe("background-color: blue;border-radius: 5px;"); + }); + + it("should handle empty style object", () => { + const result = s({}); + expect(result).toBe(""); + }); + + it("should handle single character properties", () => { + const result = s({ x: "10", y: "20" }); + expect(result).toBe("x: 10;y: 20;"); + }); + + it("should handle complex camelCase conversions", () => { + const result = s({ + WebkitTransform: "rotate(45deg)", + MozUserSelect: "none", + msFilter: "blur(5px)" + }); + expect(result).toBe("webkit-transform: rotate(45deg);moz-user-select: none;ms-filter: blur(5px);"); + }); + + it("should handle CSS custom properties (CSS variables)", () => { + const result = s({ "--main-color": "blue", "--secondary-color": "red" }); + expect(result).toBe("--main-color: blue;--secondary-color: red;"); + }); + + it("should handle numeric values", () => { + const result = s({ zIndex: "999", opacity: "0.5" }); + expect(result).toBe("z-index: 999;opacity: 0.5;"); + }); + + it("should handle properties with multiple capital letters", () => { + const result = s({ WebkitBorderRadius: "10px", MozBorderRadius: "10px" }); + expect(result).toBe("webkit-border-radius: 10px;moz-border-radius: 10px;"); + }); + + it("should handle properties that are already kebab-case", () => { + const result = s({ "font-size": "14px", "line-height": "1.5" }); + expect(result).toBe("font-size: 14px;line-height: 1.5;"); + }); + + it("should handle mixed camelCase and kebab-case properties", () => { + const result = s({ + fontSize: "14px", + "line-height": "1.5", + backgroundColor: "white", + "border-color": "black" + }); + expect(result).toBe("font-size: 14px;line-height: 1.5;background-color: white;border-color: black;"); + }); + + it("should handle properties with units", () => { + const result = s({ + width: "100px", + height: "50vh", + margin: "1rem 2em", + fontSize: "1.2em" + }); + expect(result).toBe("width: 100px;height: 50vh;margin: 1rem 2em;font-size: 1.2em;"); + }); + + it("should handle calc() and other CSS functions", () => { + const result = s({ + width: "calc(100% - 20px)", + transform: "rotate(45deg) scale(1.2)", + background: "linear-gradient(to right, red, blue)" + }); + expect(result).toBe("width: calc(100% - 20px);transform: rotate(45deg) scale(1.2);background: linear-gradient(to right, red, blue);"); + }); + + it("should handle special characters in values", () => { + const result = s({ + content: '"Hello World"', + fontFamily: "'Times New Roman', serif" + }); + expect(result).toBe('content: "Hello World";font-family: \'Times New Roman\', serif;'); + }); + + it("should handle boolean-like string values", () => { + const result = s({ + display: "none", + visibility: "hidden", + pointerEvents: "auto" + }); + expect(result).toBe("display: none;visibility: hidden;pointer-events: auto;"); + }); + + it("should preserve property order", () => { + const result = s({ + zIndex: "1", + color: "red", + fontSize: "12px", + margin: "0" + }); + expect(result).toBe("z-index: 1;color: red;font-size: 12px;margin: 0;"); + }); + + it("should handle shorthand properties", () => { + const result = s({ + margin: "10px 20px 30px 40px", + padding: "5px 10px", + border: "1px solid black", + font: "bold 16px Arial" + }); + expect(result).toBe("margin: 10px 20px 30px 40px;padding: 5px 10px;border: 1px solid black;font: bold 16px Arial;"); + }); + + describe("edge cases and undefined props", () => { + it("should handle undefined style object", () => { + const result = s(undefined as unknown as Record); + expect(result).toBe(""); + }); + + it("should handle object with undefined values", () => { + const result = s({ + color: "red", + fontSize: undefined as unknown as string, + margin: "10px" + }); + expect(result).toBe("color: red;font-size: undefined;margin: 10px;"); + }); + + it("should handle object with null values", () => { + const result = s({ + color: "red", + backgroundColor: null as unknown as string, + padding: "5px" + }); + expect(result).toBe("color: red;background-color: null;padding: 5px;"); + }); + + it("should handle object with empty string values", () => { + const result = s({ + color: "", + fontSize: "16px", + margin: "" + }); + expect(result).toBe("color: ;font-size: 16px;margin: ;"); + }); + + it("should handle object with zero values", () => { + const result = s({ + zIndex: "0", + opacity: "0", + margin: "0", + padding: "0px" + }); + expect(result).toBe("z-index: 0;opacity: 0;margin: 0;padding: 0px;"); + }); + + it("should handle properties with numbers in the name", () => { + const result = s({ + "grid-column-start": "1", + "grid-row-end": "3", + "column-count": "2" + }); + expect(result).toBe("grid-column-start: 1;grid-row-end: 3;column-count: 2;"); + }); + + it("should handle very long property names and values", () => { + const longProp = "a".repeat(100); + const longValue = "b".repeat(200); + const result = s({ [longProp]: longValue }); + expect(result).toBe(`${longProp}: ${longValue};`); + }); + + it("should handle properties with Unicode characters", () => { + const result = s({ + "font-family": "Café Sans", + content: "\"Hello 世界\"", + "--custom-émoji": "🎨" + }); + expect(result).toBe("font-family: Café Sans;content: \"Hello 世界\";--custom-émoji: 🎨;"); + }); + + it("should handle properties with special CSS values", () => { + const result = s({ + display: "inherit", + position: "initial", + color: "unset", + margin: "revert" + }); + expect(result).toBe("display: inherit;position: initial;color: unset;margin: revert;"); + }); + + it("should handle malformed camelCase properties", () => { + const result = s({ + borderTop: "1px", + BorderBottom: "2px", + BORDER_LEFT: "3px", + "border-right": "4px" + }); + expect(result).toBe("border-top: 1px;border-bottom: 2px;border_left: 3px;border-right: 4px;"); + }); + + it("should handle properties with only uppercase letters", () => { + const result = s({ + URL: "test.com", + CSS: "styles", + HTML: "markup" + }); + expect(result).toBe("url: test.com;css: styles;html: markup;"); + }); + + it("should handle single character property names", () => { + const result = s({ + x: "10px", + y: "20px", + z: "30px" + }); + expect(result).toBe("x: 10px;y: 20px;z: 30px;"); + }); + + it("should handle properties with boolean-like string values", () => { + const result = s({ + visibility: "true", + display: "false", + opacity: "null", + zIndex: "undefined" + }); + expect(result).toBe("visibility: true;display: false;opacity: null;z-index: undefined;"); + }); + + it("should handle complex CSS expressions", () => { + const result = s({ + transform: "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)", + filter: "drop-shadow(0 0 10px rgba(0,0,0,0.5))", + clipPath: "polygon(50% 0%, 0% 100%, 100% 100%)" + }); + expect(result).toBe("transform: matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1);filter: drop-shadow(0 0 10px rgba(0,0,0,0.5));clip-path: polygon(50% 0%, 0% 100%, 100% 100%);"); + }); + }); + }); + + describe("integration tests", () => { + it("should work together to create styled HTML elements", () => { + const styles = s({ backgroundColor: "red", padding: "10px" }); + const result = h("div", { style: styles }, "Styled content"); + expect(result).toBe('
Styled content
'); + }); + + it("should handle complex nested structures with styles", () => { + const headerStyle = s({ fontSize: "24px", fontWeight: "bold" }); + const containerStyle = s({ border: "1px solid #ccc", padding: "20px" }); + + const header = h("h1", { style: headerStyle }, "Title"); + const content = h("p", {}, "Some content"); + const result = h("div", { style: containerStyle }, header, content); + + expect(result).toBe('

Title

Some content

'); + }); + + it("should handle multiple styled elements", () => { + const buttonStyle = s({ backgroundColor: "blue", color: "white", padding: "8px 16px" }); + const linkStyle = s({ textDecoration: "none", color: "blue" }); + + const button = h("button", { style: buttonStyle, type: "submit" }, "Submit"); + const link = h("a", { style: linkStyle, href: "#" }, "Link"); + const result = h("div", {}, button, " ", link); + + expect(result).toBe('
Link
'); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/src/env.d.ts b/ChatSetAttr/src/env.d.ts new file mode 100644 index 0000000000..fbf45c837f --- /dev/null +++ b/ChatSetAttr/src/env.d.ts @@ -0,0 +1,18 @@ +/// +/// +/// + +declare function h( + tagName: string, + attributes: Record, + ...children: (string | null | undefined)[] +): string; + +declare namespace JSX { + type Element = string; + interface IntrinsicElements { + [elemName: string]: { + [key: string]: string | undefined; + }; + } +} \ No newline at end of file diff --git a/ChatSetAttr/src/index.ts b/ChatSetAttr/src/index.ts new file mode 100644 index 0000000000..ef0cc04a33 --- /dev/null +++ b/ChatSetAttr/src/index.ts @@ -0,0 +1,11 @@ +import { registerHandlers } from "./modules/main"; +import { update, welcome } from "./modules/versioning"; +import "./utils/chat"; + +on("ready", () => { + registerHandlers(); + update(); + welcome(); +}); + +export { registerObserver } from "./modules/observer"; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/attributes.ts b/ChatSetAttr/src/modules/attributes.ts new file mode 100644 index 0000000000..d35f9c6205 --- /dev/null +++ b/ChatSetAttr/src/modules/attributes.ts @@ -0,0 +1,93 @@ +import type { Attribute, AttributeRecord, AttributeValue } from "../types"; + +// #region Get Attributes +async function getSingleAttribute(target: string, attributeName: string): Promise { + const isMax = attributeName.endsWith("_max"); + const type = isMax ? "max" : "current"; + if (isMax) { + attributeName = attributeName.slice(0, -4); // remove '_max' + } + try { + const attribute = await libSmartAttributes.getAttribute(target, attributeName, type); + return attribute; + } catch { + return undefined; + } +}; + +export async function getAttributes( + target: string, + attributeNames: string[] | AttributeRecord, +): Promise { + const attributes: AttributeRecord = {}; + if (Array.isArray(attributeNames)) { + for (const name of attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } else { + for (const name in attributeNames) { + const cleanName = name.replace(/[^a-zA-Z0-9_]/g, ""); + attributes[cleanName] = await getSingleAttribute(target, cleanName); + } + } + return attributes; +}; + +// #region Set Attributes +export async function setSingleAttribute( + target: string, + attributeName: string, + value: string | number | boolean, + options: Record, + isMax?: boolean +): Promise { + const type = isMax ? "max" : "current"; + await libSmartAttributes.setAttribute(target, attributeName, value, type, options); +}; + +export async function setAttributes( + target: string, + attributes: Attribute[], + options: Record +): Promise { + const promises: Promise[] = []; + for (const attr of attributes) { + if (attr.current === undefined && attr.max === undefined) { + throw new Error("Attribute must have at least a current or max value defined."); + } + if (attr.name === undefined) { + throw new Error("Attribute must have a name defined."); + } + if (attr.current !== undefined) { + const promise = setSingleAttribute(target, attr.name, attr.current, options); + promises.push(promise); + } + if (attr.max !== undefined) { + const isMax = true; + const promise = setSingleAttribute(target, attr.name, attr.max, options, isMax); + promises.push(promise); + } + } + await Promise.all(promises); +}; + +// #region Delete Attributes +export async function deleteSingleAttribute( + target: string, + attributeName: string, +): Promise { + await libSmartAttributes.deleteAttribute(target, attributeName); +}; + +export async function deleteAttributes( + target: string, + attributeNames: string[], +): Promise { + const promises: Promise[] = []; + for (const name of attributeNames) { + const promise = libSmartAttributes.deleteAttribute(target, name); + promises.push(promise); + } + await Promise.all(promises); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts new file mode 100644 index 0000000000..fcfde5f7ba --- /dev/null +++ b/ChatSetAttr/src/modules/chat.ts @@ -0,0 +1,50 @@ +import { createDelayMessage } from "../templates/delay"; +import { createChatMessage, createErrorMessage } from "../templates/messages"; +import { createNotifyMessage } from "../templates/notification"; +import { BUTTON_STYLE } from "../templates/styles"; + +export function getPlayerName(playerID: string): string { + const player = getObj("player", playerID); + return player?.get("_displayname") ?? "Unknown Player"; +}; + +export function sendMessages( + playerID: string, + header: string, + messages: string[], + from: string = "ChatSetAttr", +): void { + const newMessage = createChatMessage(header, messages); + sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); +}; + +export function sendErrors( + playerID: string, + header: string, + errors: string[], + from: string = "ChatSetAttr", +): void { + if (errors.length === 0) return; + const newMessage = createErrorMessage(header, errors); + sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); +}; + +export function sendDelayMessage(silent: boolean = false): void { + if (silent) return; + const delayMessage = createDelayMessage(); + sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); +}; + +export function sendNotification(title: string, content: string, archive?: boolean): void { + const notifyMessage = createNotifyMessage(title, content); + sendChat("ChatSetAttr", "/w gm " + notifyMessage, undefined, { noarchive: archive }); +}; + +export function sendWelcomeMessage(): void { + const welcomeMessage = ` +

Thank you for installing ChatSetAttr.

+

To get started, use the command !setattr-config to configure the script to your needs.

+

For detailed documentation and examples, please use the !setattr-help command or click the button below:

+

Create Journal Handout

`; + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/commands.ts b/ChatSetAttr/src/modules/commands.ts new file mode 100644 index 0000000000..bfd4ce45d5 --- /dev/null +++ b/ChatSetAttr/src/modules/commands.ts @@ -0,0 +1,426 @@ +import type { Command, Attribute, AttributeRecord, AttributeValue, FeedbackObject } from "../types"; +import { getAttributes } from "./attributes"; +import { createFeedbackMessage } from "./feedback"; +import { getCharName } from "./helpers"; +import { notifyObservers } from "./observer"; + +export type HandlerResponse = { + result: AttributeRecord; + messages: string[]; + errors: string[]; +}; + +export type HandlerFunction = ( + changes: Attribute[], + target: string, + referenced: string[], + noCreate: boolean, + feedback: FeedbackObject, +) => Promise; + +// region Command Handlers +export async function setattr( + changes: Attribute[], + target: string, + referenced: string[] = [], + noCreate = false, + feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + const messages: string[] = []; + + const request = createRequestList(referenced, changes, false); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name, current, max } = change; + if (!name) continue; // skip if no name provided + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Missing attribute ${name} not created for ${characterName}.`); + continue; + } + const event = undefinedAttributes.includes(name) ? "add" : "change"; + if (current !== undefined) { + result[name] = current; + notifyObservers( + event, + target, + name, + result[name], + currentValues?.[name] ?? undefined, + ); + } + if (max !== undefined) { + result[`${name}_max`] = max; + notifyObservers( + event, + target, + `${name}_max`, + result[`${name}_max`], + currentValues?.[`${name}_max`] ?? undefined + ); + } + + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + messages.push(newMessage); + } + + return { + result, + messages, + errors, + }; + +}; + +export async function modattr( + changes: Attribute[], + target: string, + referenced: string[], + noCreate = false, + feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + const messages: string[] = []; + + const currentValues = await getCurrentValues(target, referenced, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name, current, max } = change; + if (!name) continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name] ?? 0); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + notifyObservers("change", target, name, result[name], currentValues[name]); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + notifyObservers("change", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + + messages.push(newMessage); + } + + return { + result, + messages, + errors, + }; +}; + +export async function modbattr( + changes: Attribute[], + target: string, + referenced: string[], + noCreate = false, + feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + const messages: string[] = []; + + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name, current, max } = change; + if (!name) continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be modified.`); + continue; + } + const asNumber = Number(currentValues[name]); + if (isNaN(asNumber)) { + errors.push(`Attribute '${name}' is not number-valued and so cannot be modified.`); + continue; + } + if (current !== undefined) { + result[name] = calculateModifiedValue(asNumber, current); + notifyObservers("change", target, name, result[name], currentValues[name]); + } + if (max !== undefined) { + result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); + notifyObservers("change", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + const newMax = result[`${name}_max`] ?? currentValues[`${name}_max`]; + if (newMax !== undefined) { + const start = currentValues[name]; + result[name] = calculateBoundValue( + result[name] ?? start, + newMax, + ); + } + + let newMessage = `Modified attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + + messages.push(newMessage); + } + + return { + result, + messages, + errors, + }; +} + +export async function resetattr( + changes: Attribute[], + target: string, + referenced: string[], + noCreate = false, + feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const errors: string[] = []; + const messages: string[] = []; + + const request = createRequestList(referenced, changes, true); + const currentValues = await getCurrentValues(target, request, changes); + const undefinedAttributes = extractUndefinedAttributes(currentValues); + const characterName = getCharName(target); + + for (const change of changes) { + const { name } = change; + if (!name) continue; + if (undefinedAttributes.includes(name) && noCreate) { + errors.push(`Attribute '${name}' is undefined and cannot be reset.`); + continue; + } + const maxName = `${name}_max`; + if (currentValues[maxName] !== undefined) { + const maxAsNumber = Number(currentValues[maxName]); + if (isNaN(maxAsNumber)) { + errors.push(`Attribute '${maxName}' is not number-valued and so cannot be used to reset '${name}'.`); + continue; + } + result[name] = maxAsNumber; + } else { + result[name] = 0; + } + + notifyObservers("change", target, name, result[name], currentValues[name]); + + let newMessage = `Reset attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + + messages.push(newMessage); + } + + return { + result, + messages, + errors, + }; +} + +export async function delattr( + changes: Attribute[], + target: string, + referenced: string[], + _: boolean, + feedback: FeedbackObject, +): Promise { + const result: AttributeRecord = {}; + const messages: string[] = []; + const currentValues = await getCurrentValues(target, referenced, changes); + const characterName = getCharName(target); + + for (const change of changes) { + const { name } = change; + if (!name) continue; + result[name] = undefined; + result[`${name}_max`] = undefined; + + let newMessage = `Deleted attribute '${name}' on ${characterName}.`; + + notifyObservers("destroy", target, name, result[name], currentValues[name]); + + if (currentValues[`${name}_max`] !== undefined) { + notifyObservers("destroy", target, `${name}_max`, result[`${name}_max`], currentValues[`${name}_max`]); + } + + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + + messages.push(newMessage); + } + return { + result, + messages, + errors: [], + }; +}; + +export type HandlerDictionary = { + [key in Command]?: HandlerFunction; +}; + +export const handlers: HandlerDictionary = { + setattr, + modattr, + modbattr, + resetattr, + delattr, +}; + +// #region Helper Functions + +function createRequestList( + referenced: string[], + changes: Attribute[], + includeMax = true, +): string[] { + const requestSet = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + requestSet.add(change.name); + if (includeMax) { + requestSet.add(`${change.name}_max`); + } + } + } + return Array.from(requestSet); +}; + +function extractUndefinedAttributes( + attributes: AttributeRecord +): string[] { + const names: string[] = []; + for (const name in attributes) { + if (name.endsWith("_max")) continue; + if (attributes[name] === undefined) { + names.push(name); + } + } + return names; +}; + +async function getCurrentValues( + target: string, + referenced: string[], + changes: Attribute[] +): Promise { + const queriedAttributes = new Set([...referenced]); + for (const change of changes) { + if (change.name) { + queriedAttributes.add(change.name); + if (change.max !== undefined) { + queriedAttributes.add(`${change.name}_max`); + } + } + } + const attributes = await getAttributes(target, Array.from(queriedAttributes)); + return attributes; +}; + +function calculateModifiedValue( + baseValue: string | number | boolean | undefined, + modification: string | number | boolean +): number { + const operator = getOperator(modification); + baseValue = Number(baseValue); + if (operator) { + modification = Number(String(modification).substring(1)); + } else { + modification = Number(modification); + } + if (isNaN(baseValue)) baseValue = 0; + if (isNaN(modification)) modification = 0; + return applyCalculation(baseValue, modification, operator); +}; + +function getOperator(value: string | number | boolean) { + if (typeof value === "string") { + const match = value.match(/^([+\-*/])/); + if (match) { + return match[1]; + } + } + return; +}; + +function applyCalculation( + baseValue: number, + modification: number, + operator: string = "+" +): number { + modification = Number(modification); + switch (operator) { + case "+": + return baseValue + modification; + case "-": + return baseValue - modification; + case "*": + return baseValue * modification; + case "/": + return modification !== 0 ? baseValue / modification : baseValue; + default: + return baseValue + modification; + } +}; + +function calculateBoundValue( + currentValue: AttributeValue, + maxValue: AttributeValue +): number { + currentValue = Number(currentValue); + maxValue = Number(maxValue); + if (isNaN(currentValue)) currentValue = 0; + if (isNaN(maxValue)) return currentValue; + return Math.max(Math.min(currentValue, maxValue), 0); +}; diff --git a/ChatSetAttr/src/modules/config.ts b/ChatSetAttr/src/modules/config.ts new file mode 100644 index 0000000000..77948be1cc --- /dev/null +++ b/ChatSetAttr/src/modules/config.ts @@ -0,0 +1,87 @@ +import { createConfigMessage } from "../templates/config"; + +type ScriptConfig = { + version: number | string; + globalconfigCache: { + lastsaved: number; + }; + playersCanTargetParty: boolean; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; + flags: string[]; +}; + +const SCHEMA_VERSION = "2.0"; + +const DEFAULT_CONFIG: ScriptConfig = { + version: SCHEMA_VERSION, + globalconfigCache: { + lastsaved: 0 + }, + playersCanTargetParty: true, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + flags: [] +}; + +export function getConfig() { + const stateConfig = state?.ChatSetAttr || {}; + return { + ...DEFAULT_CONFIG, + ...stateConfig, + }; +}; + +export function setConfig(newConfig: Record) { + const stateConfig = state.ChatSetAttr || {}; + state.ChatSetAttr = { + ...stateConfig, + ...newConfig, + globalconfigCache: { + lastsaved: Date.now() + } + }; +}; + +export function hasFlag(flag: string) { + const config = getConfig(); + return config.flags.includes(flag); +}; + +export function setFlag(flag: string) { + const config = getConfig(); + if (!hasFlag(flag)) { + config.flags.push(flag); + setConfig({ flags: config.flags }); + } +}; + +export function checkConfigMessage(message: string) { + return message.startsWith("!setattr-config"); +}; + +const FLAG_MAP: Record = { + "--players-can-modify": "playersCanModify", + "--players-can-evaluate": "playersCanEvaluate", + "--players-can-target-party": "playersCanTargetParty", + "--use-workers": "useWorkers", +} as const; + +export function handleConfigCommand(message: string) { + message = message.replace("!setattr-config", "").trim(); + const args = message.split(/\s+/); + const newConfig: Record = {}; + for (const arg of args) { + const cleanArg = arg.toLowerCase(); + const flag = FLAG_MAP[cleanArg]; + if (flag !== undefined) { + newConfig[flag] = !getConfig()[flag]; + log(`Toggled config option: ${flag} to ${newConfig[flag]}`); + } + } + setConfig(newConfig); + const configMessage = createConfigMessage(); + sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/feedback.ts b/ChatSetAttr/src/modules/feedback.ts new file mode 100644 index 0000000000..5b506fba7a --- /dev/null +++ b/ChatSetAttr/src/modules/feedback.ts @@ -0,0 +1,48 @@ +import type { AttributeRecord, FeedbackObject } from "../types"; + +export function createFeedbackMessage( + characterName: string, + feedback: FeedbackObject | undefined, + startingValues: AttributeRecord, + targetValues: AttributeRecord, +): string { + let message = feedback?.content ?? ""; + // _NAMEJ_: will insert the attribute name. + // _TCURJ_: will insert what you are changing the current value to (or changing by, if you're using --mod or --modb). + // _TMAXJ_: will insert what you are changing the maximum value to (or changing by, if you're using --mod or --modb). + // _CHARNAME_: will insert the character name. + // _CURJ_: will insert the final current value of the attribute, for this character. + // _MAXJ_: will insert the final maximum value of the attribute, for this character. + + const targetValueKeys = Object.keys(targetValues).filter(key => !key.endsWith("_max")); + message = message.replace("_CHARNAME_", characterName); + + message = message.replace(/_(NAME|TCUR|TMAX|CUR|MAX)(\d+)_/g, (_, key: string, num: string) => { + const index = parseInt(num, 10); + const attributeName = targetValueKeys[index]; + + if (!attributeName) return ""; + + const targetCurrent = startingValues[attributeName]; + const targetMax = startingValues[`${attributeName}_max`]; + const startingCurrent = targetValues[attributeName]; + const startingMax = targetValues[`${attributeName}_max`]; + + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return `${targetCurrent}`; + case "TMAX": + return `${targetMax}`; + case "CUR": + return `${startingCurrent}`; + case "MAX": + return `${startingMax}`; + default: + return ""; + } + }); + + return message; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/help.ts b/ChatSetAttr/src/modules/help.ts new file mode 100644 index 0000000000..0eb9e334c4 --- /dev/null +++ b/ChatSetAttr/src/modules/help.ts @@ -0,0 +1,25 @@ +import { createHelpHandout } from "../templates/help"; + +export function checkHelpMessage(msg: string): boolean { + return msg.trim().toLowerCase().startsWith("!setattrs-help"); +}; + +export function handleHelpCommand(): void { + let handout = findObjs({ + _type: "handout", + name: "ChatSetAttr Help", + })[0]; + + if (!handout) { + handout = createObj("handout", { + name: "ChatSetAttr Help", + }); + } + + const helpContent = createHelpHandout(handout.id); + + handout.set({ + "inplayerjournals": "all", + "notes": helpContent, + }); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/helpers.ts b/ChatSetAttr/src/modules/helpers.ts new file mode 100644 index 0000000000..a42b3a18fc --- /dev/null +++ b/ChatSetAttr/src/modules/helpers.ts @@ -0,0 +1,38 @@ +export function toStringOrUndefined(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return String(value); +}; + +export function calculateBoundValue( + value?: number, + max?: number +): number { + if (value === undefined || max === undefined) { + return value || 0; + } + return Math.min(value, max); +}; + +export function cleanValue(value: string): string { + return value.trim().replace(/^['"](.*)['"]$/g, "$1"); +}; + +export function getCharName( + targetID: string +): string { + const character = getObj("character", targetID); + if (character) { + return character.get("name"); + } + return `ID: ${targetID}`; +}; + +export function asyncTimeout(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts new file mode 100644 index 0000000000..61ea0d45de --- /dev/null +++ b/ChatSetAttr/src/modules/main.ts @@ -0,0 +1,150 @@ +import scriptJson from "../../script.json" assert { type: "json" }; +import type { Attribute, AttributeRecord } from "../types"; +import { getAttributes } from "./attributes"; +import { sendDelayMessage, sendErrors, sendMessages } from "./chat"; +import { handlers } from "./commands"; +import { checkConfigMessage, handleConfigCommand } from "./config"; +import { checkHelpMessage, handleHelpCommand } from "./help"; +import { extractMessageFromRollTemplate, parseMessage, validateMessage } from "./message"; +import { processModifications } from "./modifications"; +import { checkPermissions } from "./permissions"; +import { getAllRepOrders, getAllSectionNames } from "./repeating"; +import { generateTargets } from "./targets"; +import { clearTimer, startTimer } from "./timer"; +import { makeUpdate } from "./updates"; + +function broadcastHeader() { + log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); +}; + +function checkDependencies() { + if (libSmartAttributes === undefined) { + throw new Error("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + } + if (libUUID === undefined) { + throw new Error("libUUID is required but not found. Please ensure the libUUID script is installed."); + } +}; + +async function acceptMessage(msg: Roll20ChatMessage) { + // State + const errors: string[] = []; + const messages: string[] = []; + const result: Record = {}; + + // Parse Message + const { + operation, + targeting, + options, + changes, + references, + feedback, + } = parseMessage(msg.content); + + // Start Timer + startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + + // Preprocess + const { targets, errors: targetErrors } = generateTargets(msg, targeting); + errors.push(...targetErrors); + const request = generateRequest(references, changes); + const command = handlers[operation]; + if (!command) { + errors.push(`No handler found for operation: ${operation}`); + sendErrors(msg.playerid, "Errors", errors); + return; + } + + // Execute + for (const target of targets) { + const attrs = await getAttributes(target, request); + const sectionNames = getAllSectionNames(changes); + const repOrders = await getAllRepOrders(target, sectionNames); + const modifications = processModifications(changes, attrs, options, repOrders); + + const response = await command(modifications, target, references, options.nocreate, feedback); + + if (response.errors.length > 0) { + errors.push(...response.errors); + continue; + } + + messages.push(...response.messages); + result[target] = response.result; + } + + const updateResult = await makeUpdate(operation, result); + + clearTimer("chatsetattr"); + + messages.push(...updateResult.messages); + errors.push(...updateResult.errors); + + if (options.silent) return; + sendErrors(msg.playerid, "Errors", errors, feedback?.from); + if (options.mute) return; + const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; + const feedbackTitle = feedback?.header ?? delSetTitle; + sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); +}; + +export function generateRequest( + references: string[], + changes: Attribute[], +): string[] { + const referenceSet = new Set(references); + for (const change of changes) { + if (change.name && !referenceSet.has(change.name)) { + referenceSet.add(change.name); + } + if (change.max !== undefined) { + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); + } + } + } + return Array.from(referenceSet); +}; + +export function registerHandlers() { + broadcastHeader(); + checkDependencies(); + + on("chat:message", (msg) => { + if (msg.type !== "api") { + const inlineMessage = extractMessageFromRollTemplate(msg); + if (!inlineMessage) return; + msg.content = inlineMessage; + } + const debugReset = msg.content.startsWith("!setattrs-debugreset"); + if (debugReset) { + log("ChatSetAttr: Debug - resetting state."); + state.ChatSetAttr = {}; + return; + } + const debugVersion = msg.content.startsWith("!setattrs-debugversion"); + if (debugVersion) { + log("ChatSetAttr: Debug - setting version to 1.10."); + state.ChatSetAttr.version = "1.10"; + return; + } + const isHelpMessage = checkHelpMessage(msg.content); + if (isHelpMessage) { + handleHelpCommand(); + return; + } + const isConfigMessage = checkConfigMessage(msg.content); + if (isConfigMessage) { + handleConfigCommand(msg.content); + return; + } + const validMessage = validateMessage(msg.content); + if (!validMessage) return; + checkPermissions(msg.playerid); + acceptMessage(msg); + }); +}; + +export { registerObserver } from "./observer"; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/message.ts b/ChatSetAttr/src/modules/message.ts new file mode 100644 index 0000000000..8f3f70c310 --- /dev/null +++ b/ChatSetAttr/src/modules/message.ts @@ -0,0 +1,136 @@ +import { + COMMAND_TYPE, + extractFeedbackKey, + isCommand, + isCommandOption, + isFeedbackOption, + isOption, + OVERRIDE_DICTIONARY, + TARGETS, + type Attribute, + type Command, + type FeedbackObject, + type OptionsRecord +} from "../types"; +import { cleanValue } from "./helpers"; + +// #region Inline Message Extraction and Validation +export function validateMessage(content: string): boolean { + for (const command of COMMAND_TYPE) { + const messageCommand = content.split(" ")[0]; + if (messageCommand === `!${command}`) { + return true; + } + } + return false; +}; + +export function extractMessageFromRollTemplate(msg: Roll20ChatMessage): string | false { + for (const command of COMMAND_TYPE) { + if (msg.content.includes(command)) { + const regex = new RegExp(`(!${command}.*?)!!!`, "gi"); + const match = regex.exec(msg.content); + if (match) return match[1].trim(); + } + } + return false; +}; + +// #region Message Parsing +function extractOperation(parts: string[]): Command { + if (parts.length === 0) throw new Error("Empty command"); + const command = parts.shift()!.slice(1); // remove the leading '!' + const isValidCommand = isCommand(command); + if (!isValidCommand) throw new Error(`Invalid command: ${command}`); + return command; +}; + +function extractReferences(value: string | number | boolean): string[] { + if (typeof value !== "string") return []; + const matches = value.matchAll(/%[a-zA-Z0-9_]+%/g); + return Array.from(matches, m => m[0]); +}; + +function splitMessage(content: string): string[] { + const split = content.split("--").map(part => part.trim()); + return split; +}; + +function includesATarget(part: string): boolean { + if (part.includes("|") || part.includes("#")) return false; + [ part ] = part.split(" ").map(p => p.trim()); + for (const target of TARGETS) { + const isMatch = part.toLowerCase() === target.toLowerCase(); + if (isMatch) return true; + } + return false; +}; + +export function parseMessage(content: string) { + const parts = splitMessage(content); + let operation = extractOperation(parts); + + const targeting: string[] = []; + const options: OptionsRecord = {} as OptionsRecord; + const changes: Attribute[] = []; + const references: string[] = []; + const feedback: FeedbackObject = { public: false }; + + for (const part of parts) { + if (isCommandOption(part)) { + operation = OVERRIDE_DICTIONARY[part]; + } + + else if (isOption(part)) { + options[part] = true; + } + + else if (includesATarget(part)) { + targeting.push(part); + } + + else if (isFeedbackOption(part)) { + const [ key, ...valueParts ] = part.split(" "); + const value = valueParts.join(" "); + const feedbackKey = extractFeedbackKey(key); + if (!feedbackKey) continue; + if (feedbackKey === "public") { + feedback.public = true; + } else { + feedback[feedbackKey] = cleanValue(value); + } + } + + else if (part.includes("|") || part.includes("#")) { + const split = part.split(/[|#]/g).map(p => p.trim()); + const [attrName, attrCurrent, attrMax] = split; + if (!attrName && !attrCurrent && !attrMax) { + continue; + } + const attribute: Attribute = {}; + if (attrName) attribute.name = attrName; + if (attrCurrent) attribute.current = cleanValue(attrCurrent); + if (attrMax) attribute.max = cleanValue(attrMax); + changes.push(attribute); + + const currentMatches = extractReferences(attrCurrent); + const maxMatches = extractReferences(attrMax); + references.push(...currentMatches, ...maxMatches); + } + + else { + const suspectedAttribute = part.replace(/[^a-zA-Z0-9_$]/g, ""); + if (!suspectedAttribute) continue; + changes.push({ name: suspectedAttribute }); + } + } + + return { + operation, + options, + targeting, + changes, + references, + feedback, + }; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/modifications.ts b/ChatSetAttr/src/modules/modifications.ts new file mode 100644 index 0000000000..db56f3727c --- /dev/null +++ b/ChatSetAttr/src/modules/modifications.ts @@ -0,0 +1,145 @@ +import { ALIAS_CHARACTERS, type Attribute, type AttributeRecord, type OptionsRecord } from "../types"; +import { extractRepeatingParts, hasCreateIdentifier } from "./repeating"; + +export type ProcessModifierOptions = { + shouldEvaluate?: boolean; + shouldAlias?: boolean; +}; + +export function processModifierValue( + modification: string, + resolvedAttributes: AttributeRecord, + { + shouldEvaluate = false, + shouldAlias = false + }: ProcessModifierOptions = {} +): string { + let finalValue = replacePlaceholders(modification, resolvedAttributes); + + if (shouldAlias) { + finalValue = replaceAliasCharacters(finalValue); + } + + if (shouldEvaluate) { + finalValue = evaluateExpression(finalValue); + } + + return finalValue; +}; + +function replaceAliasCharacters( + modification: string, +): string { + let result = modification; + for (const alias in ALIAS_CHARACTERS) { + const original = ALIAS_CHARACTERS[alias]; + const regex = new RegExp(`\\${alias}`, "g"); + result = result.replace(regex, original); + } + return result; +}; + +function replacePlaceholders( + value: string, + attributes: AttributeRecord +): string { + if (typeof value !== "string") return value; + return value.replace(/%([a-zA-Z0-9_]+)%/g, (match, name) => { + const replacement = attributes[name]; + return replacement !== undefined ? String(replacement) : match; + }); +}; + +function evaluateExpression( + expression: string, +): string { + try { + const stringValue = String(expression); + const result = eval(stringValue); + return result; + } catch { + return expression; + } +}; + +export type ProcessModifierNameOptions = { + repeatingID?: string; + repOrder: string[]; +}; + +export function processModifierName( + name: string, + { repeatingID, repOrder }: ProcessModifierNameOptions +): string { + let result = name; + const hasCreate = result.includes("CREATE"); + if (hasCreate && repeatingID) { + result = result.replace("CREATE", repeatingID); + } + + const rowIndexMatch = result.match(/\$(\d+)/); + if (rowIndexMatch && repOrder) { + const rowIndex = parseInt(rowIndexMatch[1], 10); + const rowID = repOrder[rowIndex]; + if (!rowID) return result; + result = result.replace(`$${rowIndex}`, rowID); + } + + return result; +}; + +export function processModifications( + modifications: Attribute[], + resolved: AttributeRecord, + options: OptionsRecord, + repOrders: Record, +): Attribute[] { + const processedModifications: Attribute[] = []; + const repeatingID = libUUID.generateRowID(); + + for (const mod of modifications) { + if (!mod.name) continue; + let processedName = mod.name; + const parts = extractRepeatingParts(mod.name); + if (parts) { + const hasCreate = hasCreateIdentifier(parts.identifier); + const repOrder = repOrders[parts.section] || []; + processedName = processModifierName(mod.name, { + repeatingID: hasCreate ? repeatingID : parts.identifier, + repOrder, + }); + } + + let processedCurrent = undefined; + if (mod.current !== "undefined") { + processedCurrent = String(mod.current); + processedCurrent = processModifierValue(processedCurrent, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + + let processedMax = undefined; + if (mod.max !== undefined) { + processedMax = String(mod.max); + processedMax = processModifierValue(processedMax, resolved, { + shouldEvaluate: options.evaluate, + shouldAlias: options.replace, + }); + } + + const processedMod: Attribute = { + name: processedName, + }; + if (processedCurrent !== undefined) { + processedMod.current = processedCurrent; + } + if (processedMax !== undefined) { + processedMod.max = processedMax; + } + + processedModifications.push(processedMod); + } + + return processedModifications; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/observer.ts b/ChatSetAttr/src/modules/observer.ts new file mode 100644 index 0000000000..a04dc3b976 --- /dev/null +++ b/ChatSetAttr/src/modules/observer.ts @@ -0,0 +1,26 @@ +import type { AttributeValue, ObserverCallback, ObserverEvent, ObserverRecord } from "../types"; + +const observers: ObserverRecord = {}; + +export function registerObserver( + event: ObserverEvent, + callback: ObserverCallback +): void { + if (!observers[event]) { + observers[event] = []; + } + observers[event].push(callback); +}; + +export function notifyObservers( + event: ObserverEvent, + targetID: string, + attributeName: string, + newValue: AttributeValue, + oldValue: AttributeValue +): void { + const callbacks = observers[event] || []; + callbacks.forEach(callback => { + callback(event, targetID, attributeName, newValue, oldValue); + }); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/permissions.ts b/ChatSetAttr/src/modules/permissions.ts new file mode 100644 index 0000000000..70d443d9d9 --- /dev/null +++ b/ChatSetAttr/src/modules/permissions.ts @@ -0,0 +1,45 @@ +const permissions = { + playerID: "", + isGM: false, + canModify: false, +}; + +export function checkPermissions(playerID: string) { + const player = getObj("player", playerID); + if (!player) { + throw new Error(`Player with ID ${playerID} not found.`); + } + const isGM = playerIsGM(playerID); + const config = state.ChatSetAttr?.config || {}; + const playersCanModify = config.playersCanModify || false; + const canModify = isGM || playersCanModify; + + setPermissions(playerID, isGM, canModify); +}; + +export function setPermissions(playerID: string, isGM: boolean, canModify: boolean) { + permissions.playerID = playerID; + permissions.isGM = isGM; + permissions.canModify = canModify; +}; + +export function getPermissions() { + return { ...permissions }; +}; + +export function checkPermissionForTarget(playerID: string, target: string): boolean { + const player = getObj("player", playerID); + if (!player) { + return false; + } + const isGM = playerIsGM(playerID); + if (isGM) { + return true; + } + const character = getObj("character", target); + if (!character) { + return false; + } + const controlledBy = (character.get("controlledby") || "").split(","); + return controlledBy.includes(playerID); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/repeating.ts b/ChatSetAttr/src/modules/repeating.ts new file mode 100644 index 0000000000..8de6efb70a --- /dev/null +++ b/ChatSetAttr/src/modules/repeating.ts @@ -0,0 +1,181 @@ +import type { Attribute } from "../types"; + +export type RepeatingParts = { + section: string; + identifier: string; + field: string; +}; + +export function extractRepeatingParts( + attributeName: string +): RepeatingParts | null { + const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); + if (repeating !== "repeating") { + return null; + } + const field = fieldParts.join("_"); + if (!section || !identifier || !field) { + return null; + } + return { + section, + identifier, + field + }; +}; + +export function combineRepeatingParts( + parts: RepeatingParts +): string { + if (!parts.section || !parts.identifier || !parts.field) { + throw new Error("[CHATSETATTR] combineRepeatingParts: All parts (section, identifier, field) must be non-empty strings."); + } + return `repeating_${parts.section}_${parts.identifier}_${parts.field}`; +}; + +export function isRepeatingAttribute( + attributeName: string +): boolean { + const parts = extractRepeatingParts(attributeName); + return parts !== null; +}; + +export function hasCreateIdentifier( + attributeName: string +): boolean { + const parts = extractRepeatingParts(attributeName); + if (parts) { + const hasIndentifier = parts.identifier.toLowerCase().includes("create"); + return hasIndentifier; + } + const hasIndentifier = attributeName.toLowerCase().includes("create"); + return hasIndentifier; +}; + +export function hasIndexIdentifier( + attributeName: string +): boolean { + const parts = extractRepeatingParts(attributeName); + if (!parts) return false; + const hasIndentifier = parts.identifier.match(/^\$(\d+)$/i) !== null; + return hasIndentifier; +}; + +export function convertRepOrderToArray( + repOrder: string +): string[] { + return repOrder.split(",").map(id => id.trim()); +}; + +export function getIDFromIndex( + attributeName: string, + repOrder: string[], +): string | null { + const parts = extractRepeatingParts(attributeName); + if (!parts) return null; + const hasIndex = hasIndexIdentifier(attributeName); + if (!hasIndex) return null; + + // Extract the numeric part from the identifier (e.g., "$1" -> "1") + const match = parts.identifier.match(/^\$(\d+)$/); + if (!match) return null; + + const index = Number(match[1]); + if (isNaN(index) || index < 1 || index > repOrder.length) { + return null; + } + return repOrder[index - 1]; +}; + +export async function getRepOrderForSection( + characterID: string, + section: string, +) { + const repOrderAttribute = `_reporder_repeating_${section}`; + const repOrder = await libSmartAttributes.getAttribute(characterID, repOrderAttribute); + return repOrder; +}; + +export function extractRepeatingAttributes( + attributes: Attribute[] +): Attribute[] { + return attributes.filter(attr => attr.name && isRepeatingAttribute(attr.name)); +}; + +export function getAllSectionNames( + attributes: Attribute[] +): string[] { + const sectionNames: Set = new Set(); + const repeatingAttributes = extractRepeatingAttributes(attributes); + for (const attr of repeatingAttributes) { + if (!attr.name) continue; + const parts = extractRepeatingParts(attr.name); + if (!parts) continue; + sectionNames.add(parts.section); + } + return Array.from(sectionNames); +}; + +export async function getAllRepOrders( + characterID: string, + sectionNames: string[], +) { + const repOrders: Record = {}; + for (const section of sectionNames) { + const repOrderString = await getRepOrderForSection(characterID, section); + if (repOrderString && typeof repOrderString === "string") { + repOrders[section] = convertRepOrderToArray(repOrderString); + } else { + repOrders[section] = []; + } + } + return repOrders; +}; + +export async function processRepeatingAttributes( + characterID: string, + attributes: Attribute[], +) { + const repeatingAttributes = extractRepeatingAttributes(attributes); + const sectionNames = getAllSectionNames(repeatingAttributes); + const repOrders = await getAllRepOrders(characterID, sectionNames); + + const processedAttributes: Attribute[] = []; + + for (const attr of repeatingAttributes) { + if (!attr.name) continue; + const parts = extractRepeatingParts(attr.name); + if (!parts) continue; + + let identifier = parts.identifier; + + const useIndex = hasIndexIdentifier(attr.name); + if (useIndex) { + const repOrderForSection = repOrders[parts.section]; + const rowID = getIDFromIndex(attr.name, repOrderForSection); + if (rowID) { + identifier = rowID; + } else { + continue; + } + } + + const useNewID = hasCreateIdentifier(attr.name); + if (useNewID) { + identifier = libUUID.generateRowID(); + } + + const combinedName = combineRepeatingParts({ + section: parts.section, + identifier, + field: parts.field + }); + + processedAttributes.push({ + ...attr, + name: combinedName + }); + } + + return processedAttributes; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/targets.ts b/ChatSetAttr/src/modules/targets.ts new file mode 100644 index 0000000000..e412188ec6 --- /dev/null +++ b/ChatSetAttr/src/modules/targets.ts @@ -0,0 +1,224 @@ +import type { Target } from "../types"; +import { getConfig } from "./config"; +import { checkPermissionForTarget, getPermissions } from "./permissions"; + +function generateSelectedTargets(message: Roll20ChatMessage, type: Target) { + const errors: string[] = []; + const targets: string[] = []; + + if (!message.selected) return { targets, errors }; + + for (const token of message.selected) { + const tokenObj = getObj("graphic", token._id); + if (!tokenObj) { + errors.push(`Selected token with ID ${token._id} not found.`); + continue; + } + if (tokenObj.get("_subtype") !== "token") { + errors.push(`Selected object with ID ${token._id} is not a token.`); + continue; + } + + const represents = tokenObj.get("represents"); + const character = getObj("character", represents); + if (!character) { + errors.push(`Token with ID ${token._id} does not represent a character.`); + continue; + } + + const inParty = character.get("inParty"); + if (type === "sel-noparty" && inParty) { + continue; + } + if (type === "sel-party" && !inParty) { + continue; + } + + targets.push(character.id); + } + + return { + targets, + errors, + }; +}; + +function generateAllTargets(type: Target) { + const { isGM } = getPermissions(); + const errors: string[] = []; + + if (!isGM) { + errors.push(`Only GMs can use the '${type}' target option.`); + return { + targets: [], + errors, + }; + } + + const characters = findObjs({ _type: "character" }); + if (type === "all") { + return { + targets: characters.map(char => char.id), + errors, + }; + } + + else if (type === "allgm") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !controlledBy; + }).map(char => char.id); + return { + targets, + errors, + }; + } + + else if (type === "allplayers") { + const targets = characters.filter(char => { + const controlledBy = char.get("controlledby"); + return !!controlledBy; + }).map(char => char.id); + + return { + targets, + errors, + }; + } + + return { + targets: [], + errors: [`Unknown target type '${type}'.`], + }; +}; + +function generateCharacterIDTargets(values: string[]) { + const { playerID } = getPermissions(); + const targets: string[] = []; + const errors: string[] = []; + + for (const charID of values) { + const character = getObj("character", charID); + if (!character) { + errors.push(`Character with ID ${charID} not found.`); + continue; + } + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with ID ${charID}.`); + continue; + } + targets.push(characterID); + } + + return { + targets, + errors, + }; +}; + +function generatePartyTargets() { + const { isGM } = getPermissions(); + const { playersCanTargetParty } = getConfig(); + const targets: string[] = []; + const errors: string[] = []; + + if (!isGM && !playersCanTargetParty) { + errors.push("Only GMs can use the 'party' target option."); + return { + targets, + errors, + }; + } + + const characters = findObjs({ _type: "character", inParty: true }); + for (const character of characters) { + const characterID = character.id; + targets.push(characterID); + } + + return { + targets, + errors, + }; +}; + + +function generateNameTargets(values: string[]) { + const { playerID } = getPermissions(); + const targets: string[] = []; + const errors: string[] = []; + + for (const name of values) { + const characters = findObjs({ _type: "character", name: name }); + if (characters.length === 0) { + errors.push(`Character with name "${name}" not found.`); + continue; + } + if (characters.length > 1) { + errors.push(`Multiple characters found with name "${name}". Please use character ID instead.`); + continue; + } + const character = characters[0]; + const characterID = character.id; + const hasPermission = checkPermissionForTarget(playerID, characterID); + if (!hasPermission) { + errors.push(`Permission error. You do not have permission to modify character with name "${name}".`); + continue; + } + targets.push(characterID); + } + + return { + targets, + errors, + }; +}; + +export function generateTargets(message: Roll20ChatMessage, targetOptions: string[]) { + const characterIDs: string[] = []; + const errors: string[] = []; + + for (const option of targetOptions) { + const [type, ...values] = option.split(/[, ]/).map(v => v.trim()).filter(v => v.length > 0); + + if (type === "sel" || type === "sel-noparty" || type === "sel-party") { + const results = generateSelectedTargets(message, type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "all" || type === "allgm" || type === "allplayers") { + const results = generateAllTargets(type); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "charid") { + const results = generateCharacterIDTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "name") { + const results = generateNameTargets(values); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + + else if (type === "party") { + const results = generatePartyTargets(); + characterIDs.push(...results.targets); + errors.push(...results.errors); + } + } + + const targets = Array.from(new Set(characterIDs)); + + return { + targets, + errors, + }; +}; + diff --git a/ChatSetAttr/src/modules/timer.ts b/ChatSetAttr/src/modules/timer.ts new file mode 100644 index 0000000000..430f95c814 --- /dev/null +++ b/ChatSetAttr/src/modules/timer.ts @@ -0,0 +1,37 @@ +import type { TimerMap } from "../types"; + +const timerMap: TimerMap = new Map(); + +export function startTimer( + key: string, + duration = 50, + callback: () => void +): void { + // Clear any existing timer for the same key + const existingTimer = timerMap.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + callback(); + timerMap.delete(key); + }, duration); + + timerMap.set(key, timer); +} + +export function clearTimer(key: string): void { + const timer = timerMap.get(key); + if (timer) { + clearTimeout(timer); + timerMap.delete(key); + } +} + +export function clearAllTimers(): void { + for (const timer of timerMap.values()) { + clearTimeout(timer); + } + timerMap.clear(); +} \ No newline at end of file diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts new file mode 100644 index 0000000000..d14b79023c --- /dev/null +++ b/ChatSetAttr/src/modules/updates.ts @@ -0,0 +1,57 @@ +import { type AttributeRecord, type Command } from "../types"; +import { getConfig } from "./config"; + +type UpdateOptions = { + noCreate?: boolean; +}; + +type UpdateResult = { + errors: string[]; + messages: string[]; +}; + +export async function makeUpdate( + operation: Command, + results: Record, + options?: UpdateOptions +): Promise { + const isSetting = operation !== "delattr"; + const errors: string[] = []; + const messages: string[] = []; + + const { noCreate = false } = options || {}; + const { setWithWorker = false } = getConfig() || {}; + const setOptions = { + noCreate, + setWithWorker, + }; + + for (const target in results) { + for (const name in results[target]) { + const isMax = name.endsWith("_max"); + const type = isMax ? "max" : "current"; + const actualName = isMax ? name.slice(0, -4) : name; + + if (isSetting) { + const value = results[target][name] ?? ""; + + try { + await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + } catch (error: unknown) { + errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); + } + + } else { + + try { + await libSmartAttributes.deleteAttribute(target, actualName, type); + } catch (error: unknown) { + errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); + } + + } + } + } + + return { errors, messages }; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/versioning.ts b/ChatSetAttr/src/modules/versioning.ts new file mode 100644 index 0000000000..35c624df70 --- /dev/null +++ b/ChatSetAttr/src/modules/versioning.ts @@ -0,0 +1,84 @@ +import type { VersionObject } from "../types"; +import { v2_0 } from "../versions/version2"; +import { sendWelcomeMessage } from "./chat"; +import { getConfig, hasFlag, setConfig, setFlag } from "./config"; + +const VERSION_HISTORY: VersionObject[] = [ + v2_0, +]; + +export function welcome() { + const hasWelcomed = hasFlag("welcome"); + if (hasWelcomed) { return; } + + sendWelcomeMessage(); + setFlag("welcome"); +}; + +export function update() { + log("ChatSetAttr: Checking for updates..."); + const config = getConfig(); + let currentVersion = config.version || "1.10"; + + log(`ChatSetAttr: Current version: ${currentVersion}`); + if (currentVersion === 3) { + currentVersion = "1.10"; + } + + log(`ChatSetAttr: Normalized current version: ${currentVersion}`); + checkForUpdates(currentVersion); +}; + +export function checkForUpdates(currentVersion: string): void { + for (const version of VERSION_HISTORY) { + log(`ChatSetAttr: Evaluating version update to ${version.version} (appliesTo: ${version.appliesTo})`); + const applies = version.appliesTo; + const versionString = applies.replace(/(<=|<|>=|>|=)/, "").trim(); + const comparison = applies.replace(versionString, "").trim(); + const compared = compareVersions(currentVersion, versionString); + + let shouldApply = false; + switch (comparison) { + case "<=": + shouldApply = compared <= 0; + break; + case "<": + shouldApply = compared < 0; + break; + case ">=": + shouldApply = compared >= 0; + break; + case ">": + shouldApply = compared > 0; + break; + case "=": + shouldApply = compared === 0; + break; + } + + if (shouldApply) { + version.update(); + currentVersion = version.version; + updateVersionInState(currentVersion); + } + } +} + +function compareVersions(v1: string, v2: string): number { + const [major1, minor1 = 0, patch1 = 0] = v1.split(".").map(Number); + const [major2, minor2 = 0, patch2 = 0] = v2.split(".").map(Number); + + if (major1 !== major2) { + return major1 - major2; + } + if (minor1 !== minor2) { + return minor1 - minor2; + } + return patch1 - patch2; +}; + +function updateVersionInState(newVersion: string): void { + const config = getConfig(); + config.version = newVersion; + setConfig(config); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/config.tsx b/ChatSetAttr/src/templates/config.tsx new file mode 100644 index 0000000000..3eedd71acd --- /dev/null +++ b/ChatSetAttr/src/templates/config.tsx @@ -0,0 +1,92 @@ +import { getConfig } from "../modules/config"; +import { s } from "../utils/chat"; +import { BORDER_RADIUS, COLOR_BLUE, COLOR_GREEN, COLOR_RED, COLOR_WHITE, FONT_SIZE, PADDING } from "./styles"; + +const CONFIG_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_BLUE["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_BLUE["50"], +}); + +const CONFIG_HEADER_STYLE = s({ + color: COLOR_BLUE["400"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, +}); + +const CONFIG_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, +}); + +const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", +}); + +const CONFIG_ROW_STYLE = s({ + marginBottom: PADDING.XS, +}); + +const CONFIG_BUTTON_SHARED = { + color: COLOR_WHITE, + border: "none", + borderRadius: BORDER_RADIUS.SM, + fontSize: FONT_SIZE.SM, + padding: `${PADDING.XS} ${PADDING.SM}`, + textAlign: "center", + width: "100%", +}; + +const CONFIG_BUTTON_STYLE_ON = s({ + backgroundColor: COLOR_GREEN["500"], + ...CONFIG_BUTTON_SHARED, +}); + +const CONFIG_BUTTON_STYLE_OFF = s({ + backgroundColor: COLOR_RED["300"], + ...CONFIG_BUTTON_SHARED, +}); + +const CONFIG_CLEAR_FIX_STYLE = s({ + clear: "both", +}); + +function camelToKebabCase(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +}; + +export function createConfigMessage(): string { + const config = getConfig(); + const configEntries = Object.entries(config); + const relevantEntries = configEntries.filter(([key]) => + key !== "version" && key !== "globalconfigCache" && key !== "flags" + ); + return ( +
+
ChatSetAttr Configuration
+
+ + {relevantEntries.map(([key, value]) => ( + + + + + ))} +
+ {key}: + + + {value ? "Enabled" : "Disabled"} + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/delay.tsx b/ChatSetAttr/src/templates/delay.tsx new file mode 100644 index 0000000000..e39a452b5d --- /dev/null +++ b/ChatSetAttr/src/templates/delay.tsx @@ -0,0 +1,32 @@ +import { s } from "../utils/chat"; +import { BORDER_RADIUS, COLOR_STONE, FONT_SIZE, PADDING } from "./styles"; + +const DELAY_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_STONE["400"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + color: COLOR_STONE["900"], + backgroundColor: COLOR_STONE["50"], +}); + +const DELAY_HEADER_STYLE = s({ + color: COLOR_STONE["700"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, +}); + +const DELAY_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, +}); + +export function createDelayMessage(): string { + return ( +
+
Long Running Query
+
+ The operation is taking a long time to execute. This may be due to a large number of targets or attributes being processed. Please be patient as the operation completes. +
+
+ ); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/help.tsx b/ChatSetAttr/src/templates/help.tsx new file mode 100644 index 0000000000..3d5c08adcd --- /dev/null +++ b/ChatSetAttr/src/templates/help.tsx @@ -0,0 +1,477 @@ +export function createHelpHandout(handoutID: string): string { + + const contents = [ + "Basic Usage", + "Available Commands", + "Target Selection", + "Attribute Syntax", + "Modifier Options", + "Output Control Options", + "Inline Roll Integration", + "Repeating Section Support", + "Special Value Expressions", + "Global Configuration", + "Complete Examples", + "For Developers", + ]; + + function createTableOfContents(): string { + return ( +
    + {contents.map(section => ( +
  1. + {section} +
  2. + ))} +
+ ); + }; + + return ( +
+

ChatSetAttr

+ +

ChatSetAttr is a Roll20 API script that allows users to create, modify, or delete character sheet attributes through chat commands macros. Whether you need to update a single character attribute or make bulk changes across multiple characters, ChatSetAttr provides flexible options to streamline your game management.

+ +

Table of Contents

+ + {createTableOfContents()} + +

Basic Usage

+ +

The script provides several command formats:

+ +
    +
  • !setattr [--options] - Create or modify attributes
  • +
  • !modattr [--options] - Shortcut for !setattr --mod (adds to existing values)
  • +
  • !modbattr [--options] - Shortcut for !setattr --modb (adds to values with bounds)
  • +
  • !resetattr [--options] - Shortcut for !setattr --reset (resets to max values)
  • +
  • !delattr [--options] - Delete attributes
  • +
+ +

Each command requires a target selection option and one or more attributes to modify.

+ +

Basic structure:

+
!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2
+ +

Available Commands

+ +

!setattr

+ +

Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless --nocreate is specified).

+ +

Example:

+
!setattr --sel --hp|25|50 --xp|0|800
+ +

This would set hp to 25, hp_max to 50, xp to 0 and xp_max to 800.

+ +

!modattr

+ +

Adds to existing attribute values (works only with numeric values). Shorthand for !setattr --mod.

+ +

Example:

+
!modattr --sel --hp|-5 --xp|100
+ +

This subtracts 5 from hp and adds 100 to xp.

+ +

!modbattr

+ +

Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for !setattr --modb.

+ +

Example:

+
!modbattr --sel --hp|-25 --xp|2500
+ +

This subtracts 5 from hp but won't reduce it below 0 and increase xp by 25, but won't increase it above mp_xp.

+ +

!resetattr

+ +

Resets attributes to their maximum value. Shorthand for !setattr --reset.

+ +

Example:

+
!resetattr --sel --hp --xp
+ +

This resets hp, and xp to their respective maximum values.

+ +

!delattr

+ +

Deletes the specified attributes.

+ +

Example:

+
!delattr --sel --hp --xp
+ +

This removes the hp and xp attributes.

+ +

Target Selection

+ +

One of these options must be specified to determine which characters will be affected:

+ +

--all

+ +

Affects all characters in the campaign. GM only and should be used with caution, especially in large campaigns.

+ +

Example:

+
!setattr --all --hp|15
+ +

--allgm

+ +

Affects all characters without player controllers (typically NPCs). GM only.

+ +

Example:

+
!setattr --allgm --xp|150
+ +

--allplayers

+ +

Affects all characters with player controllers (typically PCs).

+ +

Example:

+
!setattr --allplayers --hp|15
+ +

--charid

+ +

Affects characters with the specified character IDs. Non-GM players can only affect characters they control.

+ +

Example:

+
!setattr --charid <ID1> <ID2> --xp|150
+ +

--name

+ +

Affects characters with the specified names. Non-GM players can only affect characters they control.

+ +

Example:

+
!setattr --name Gandalf, Frodo Baggins --party|"Fellowship of the Ring"
+ +

--sel

+ +

Affects characters represented by currently selected tokens.

+ +

Example:

+
!setattr --sel --hp|25 --xp|30
+ +

--sel-party

+ +

Affects only party characters represented by currently selected tokens (characters with inParty set to true).

+ +

Example:

+
!setattr --sel-party --inspiration|1
+ +

--sel-noparty

+ +

Affects only non-party characters represented by currently selected tokens (characters with inParty set to false or not set).

+ +

Example:

+
!setattr --sel-noparty --npc_status|"Hostile"
+ +

--party

+ +

Affects all characters marked as party members (characters with inParty set to true). GM only by default, but can be enabled for players with configuration.

+ +

Example:

+
!setattr --party --rest_complete|1
+ +

Attribute Syntax

+ +

The syntax for specifying attributes is:

+
--attributeName|currentValue|maxValue
+ +
    +
  • attributeName is the name of the attribute to modify
  • +
  • currentValue is the value to set (optional for some commands)
  • +
  • maxValue is the maximum value to set (optional)
  • +
+ +

Examples:

+ +
    +
  1. Set current value only: +
    --strength|15
    +
  2. +
  3. Set both current and maximum values: +
    --hp|27|35
    +
  4. +
  5. Set only the maximum value (leave current unchanged): +
    --hp||50
    +
  6. +
  7. Create empty attribute or set to empty: +
    --notes|
    +
  8. +
  9. Use # instead of | (useful in roll queries): +
    --strength#15
    +
  10. +
+ +

Modifier Options

+ +

These options change how attributes are processed:

+ +

--mod

+ +

See !modattr command.

+ +

--modb

+ +

See !modbattr command.

+ +

--reset

+ +

See !resetattr command.

+ +

--nocreate

+ +

Prevents creation of new attributes, only updates existing ones.

+ +

Example:

+
!setattr --sel --nocreate --perception|20 --xp|15
+ +

This will only update perception or xp if it already exists.

+ +

--evaluate

+ +

Evaluates JavaScript expressions in attribute values. GM only by default.

+ +

Example:

+
!setattr --sel --evaluate --hp|2 * 3
+ +

This will set the hp attribute to 6.

+ +

--replace

+ +

Replaces special characters to prevent Roll20 from evaluating them:

+
    +
  • < becomes [
  • +
  • > becomes ]
  • +
  • ~ becomes -
  • +
  • ; becomes ?
  • +
  • ` becomes @
  • +
+ +

Also supports \lbrak, \rbrak, \n, \at, and \ques for [, ], newline, @, and ?.

+ +

Example:

+
!setattr --sel --replace --notes|"Roll <<1d6>> to succeed"
+ +

This stores "Roll [[1d6]] to succeed" without evaluating the roll.

+ +

Output Control Options

+ +

These options control the feedback messages generated by the script:

+ +

--silent

+ +

Suppresses normal output messages (error messages will still appear).

+ +

Example:

+
!setattr --sel --silent --stealth|20
+ +

--mute

+ +

Suppresses all output messages, including errors.

+ +

Example:

+
!setattr --sel --mute --nocreate --new_value|42
+ +

--fb-public

+ +

Sends output publicly to the chat instead of whispering to the command sender.

+ +

Example:

+
!setattr --sel --fb-public --hp|25|25 --status|"Healed"
+ +

--fb-from <NAME>

+ +

Changes the name of the sender for output messages (default is "ChatSetAttr").

+ +

Example:

+
!setattr --sel --fb-from "Healing Potion" --hp|25
+ +

--fb-header <STRING>

+ +

Customizes the header of the output message.

+ +

Example:

+
!setattr --sel --evaluate --fb-header "Combat Effects Applied" --status|"Poisoned" --hp|%hp%-5
+ +

--fb-content <STRING>

+ +

Customizes the content of the output message.

+ +

Example:

+
!setattr --sel --fb-content "Increasing Hitpoints" --hp|10
+ +

Special Placeholders

+ +

For use in --fb-header and --fb-content:

+ +
    +
  • _NAMEJ_ - Name of the Jth attribute being changed
  • +
  • _TCURJ_ - Target current value of the Jth attribute
  • +
  • _TMAXJ_ - Target maximum value of the Jth attribute
  • +
+ +

For use in --fb-content only:

+ +
    +
  • _CHARNAME_ - Name of the character
  • +
  • _CURJ_ - Final current value of the Jth attribute
  • +
  • _MAXJ_ - Final maximum value of the Jth attribute
  • +
+ +

Important: The Jth index starts with 0 at the first item.

+ +

Example:

+
!setattr --sel --fb-header "Healing Effects" --fb-content "_CHARNAME_ healed by _CUR0_ hitpoints --hp|10
+ +

Inline Roll Integration

+ +

ChatSetAttr can be used within roll templates or combined with inline rolls:

+ +

Within Roll Templates

+ +

Place the command between roll template properties and end it with !!!:

+ +
&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}
+ +

Using Inline Rolls in Values

+ +

Inline rolls can be used for attribute values:

+ +
!setattr --sel --hp|[[2d6+5]]
+ +

Roll Queries

+ +

Roll queries can determine attribute values:

+ +
!setattr --sel --hp|?{Set strength to what value?|100}
+ +

Repeating Section Support

+ +

ChatSetAttr supports working with repeating sections:

+ +

Creating New Repeating Items

+ +

Use -CREATE to create a new row in a repeating section:

+ +
!setattr --sel --repeating_inventory_-CREATE_itemname|"Magic Sword" --repeating_inventory_-CREATE_itemweight|2
+ +

Modifying Existing Repeating Items

+ +

Access by row ID:

+ +
!setattr --sel --repeating_inventory_-ID_itemname|"Enchanted Magic Sword"
+ +

Access by index (starts at 0):

+ +
!setattr --sel --repeating_inventory_$0_itemname|"First Item"
+ +

Deleting Repeating Rows

+ +

Delete by row ID:

+ +
!delattr --sel --repeating_inventory_-ID
+ +

Delete by index:

+ +
!delattr --sel --repeating_inventory_$0
+ +

Special Value Expressions

+ +

Attribute References

+ +

Reference other attribute values using %attribute_name%:

+ +
!setattr --sel --evaluate --temp_hp|%hp% / 2
+ +

Resetting to Maximum

+ +

Reset an attribute to its maximum value:

+ +
!setattr --sel --hp|%hp_max%
+ +

Global Configuration

+ +

The script has four global configuration options that can be toggled with !setattr-config:

+ +

--players-can-modify

+ +

Allows players to modify attributes on characters they don't control.

+ +
!setattr-config --players-can-modify
+ +

--players-can-evaluate

+ +

Allows players to use the --evaluate option.

+ +
!setattr-config --players-can-evaluate
+ +

--players-can-target-party

+ +

Allows players to use the --party target option. GM only by default.

+ +
!setattr-config --players-can-target-party
+ +

--use-workers

+ +

Toggles whether the script triggers sheet workers when setting attributes.

+ +
!setattr-config --use-workers
+ +

Complete Examples

+ +

Basic Combat Example

+ +

Reduce a character's HP and status after taking damage:

+ +
!modattr --sel --evaluate --hp|-15 --fb-header "Combat Result" --fb-content "_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!"
+ +

Leveling Up a Character

+ +

Update multiple stats when a character gains a level:

+ +
!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from "Level Up" --fb-header "Character Advanced" --fb-public
+ +

Create New Item in Inventory

+ +

Add a new item to a character's inventory:

+ +
!setattr --sel --repeating_inventory_-CREATE_itemname|"Healing Potion" --repeating_inventory_-CREATE_itemcount|3 --repeating_inventory_-CREATE_itemweight|0.5 --repeating_inventory_-CREATE_itemcontent|"Restores 2d8+2 hit points when consumed"
+ +

Apply Status Effects During Combat

+ +

Apply a debuff to selected enemies in the middle of combat:

+ +
&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|"Restrained"!!! {{duration=1d4 rounds}}
+ +

Party Management Examples

+ +

Give inspiration to all party members after a great roleplay moment:

+ +
!setattr --party --inspiration|1 --fb-public --fb-header "Inspiration Awarded" --fb-content "All party members receive inspiration for excellent roleplay!"
+ +

Apply a long rest to only party characters among selected tokens:

+ +
!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header "Long Rest Complete"
+ +

Set hostile status for non-party characters among selected tokens:

+ +
!setattr --sel-noparty --attitude|"Hostile" --fb-from "DM" --fb-content "Enemies are now hostile!"
+ +

For Developers

+ +

Registering Observers

+ +

If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:

+ +
ChatSetAttr.registerObserver(event, observer);
+ +

Where event is one of:

+
    +
  • "add" - Called when attributes are created
  • +
  • "change" - Called when attributes are modified
  • +
  • "destroy" - Called when attributes are deleted
  • +
+ +

And observer is an event handler function similar to Roll20's built-in event handlers.

+ +

This allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface.

+
+ ); +}; diff --git a/ChatSetAttr/src/templates/messages.tsx b/ChatSetAttr/src/templates/messages.tsx new file mode 100644 index 0000000000..d59b042114 --- /dev/null +++ b/ChatSetAttr/src/templates/messages.tsx @@ -0,0 +1,79 @@ +import { s } from "../utils/chat"; +import { BORDER_RADIUS, COLOR_EMERALD, COLOR_RED, FONT_SIZE, PADDING } from "./styles"; + +// #region Chat Styles +const CHAT_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_EMERALD["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_EMERALD["50"], +}); + +const CHAT_HEADER_STYLE = s({ + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, +}); + +const CHAT_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, +}); + +// #region Error Styles +const ERROR_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_RED["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + backgroundColor: COLOR_RED["50"], +}); + +const ERROR_HEADER_STYLE = s({ + color: COLOR_RED["500"], + fontWeight: "bold", + fontSize: FONT_SIZE.LG, +}); + +const ERROR_BODY_STYLE = s({ + fontSize: FONT_SIZE.SM, +}); + +// #region Message Styles Type +type MessageStyles = { + wrapper: string; + header: string; + body: string; +}; + +// #region Generic Message Creation Function +function createMessage( + header: string, + messages: string[], + styles: MessageStyles +): string { + return ( +
+

{header}

+
+ {messages.map(message =>

{message}

)} +
+
+ ); +} + +// #region Chat Message Function +export function createChatMessage(header: string, messages: string[]): string { + return createMessage(header, messages, { + wrapper: CHAT_WRAPPER_STYLE, + header: CHAT_HEADER_STYLE, + body: CHAT_BODY_STYLE + }); +} + +// #region Error Message Function +export function createErrorMessage(header: string, errors: string[]): string { + return createMessage(header, errors, { + wrapper: ERROR_WRAPPER_STYLE, + header: ERROR_HEADER_STYLE, + body: ERROR_BODY_STYLE + }); +} \ No newline at end of file diff --git a/ChatSetAttr/src/templates/notification.tsx b/ChatSetAttr/src/templates/notification.tsx new file mode 100644 index 0000000000..82a483be92 --- /dev/null +++ b/ChatSetAttr/src/templates/notification.tsx @@ -0,0 +1,32 @@ +import { s } from "../utils/chat"; +import { BORDER_RADIUS, COLOR_BLUE, FONT_SIZE, PADDING } from "./styles"; + +const NOTIFY_WRAPPER_STYLE = s({ + border: `1px solid ${COLOR_BLUE["300"]}`, + borderRadius: BORDER_RADIUS.MD, + padding: PADDING.MD, + color: COLOR_BLUE["800"], + backgroundColor: COLOR_BLUE["100"], +}); + +const NOTIFY_HEADER_STYLE = s({ + color: COLOR_BLUE["900"], + fontSize: FONT_SIZE.LG, + fontWeight: "bold", + marginBottom: PADDING.SM, +}); + +const NOTIFY_BODY_STYLE = s({ + fontSize: FONT_SIZE.MD, +}); + +export function createNotifyMessage(title: string, content: string): string { + return ( +
+
{title}
+
+ {content} +
+
+ ); +}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/styles.ts b/ChatSetAttr/src/templates/styles.ts new file mode 100644 index 0000000000..c096f73491 --- /dev/null +++ b/ChatSetAttr/src/templates/styles.ts @@ -0,0 +1,353 @@ +import { s } from "../utils/chat"; + +export const COLOR_RED = { + "50": "#ffebeb", + "100": "#ffc7c7", + "200": "#ff9e9e", + "300": "#ff7474", + "400": "#ff4a4a", + "500": "#ff2020", + "600": "#e60000", + "700": "#b40000", + "800": "#820000", + "900": "#4f0000", +}; + +export const COLOR_ORANGE = { + "50": "#fff2e6", + "100": "#ffd9b3", + "200": "#ffbf80", + "300": "#ffa64d", + "400": "#ff8c1a", + "500": "#e67300", + "600": "#b34d00", + "700": "#803300", + "800": "#4d1a00", + "900": "#1a0000", +}; + +export const COLOR_AMBER = { + "50": "#fff7e6", + "100": "#ffedb3", + "200": "#ffe380", + "300": "#ffda4d", + "400": "#ffd11a", + "500": "#e6b800", + "600": "#b48f00", + "700": "#827000", + "800": "#514c00", + "900": "#211800", +}; + +export const COLOR_YELLOW = { + "50": "#ffffe6", + "100": "#ffffb3", + "200": "#ffff80", + "300": "#ffff4d", + "400": "#ffff1a", + "500": "#e6e600", + "600": "#b4b400", + "700": "#828200", + "800": "#515100", + "900": "#212100", +}; + +export const COLOR_LIME = { + "50": "#f7ffe6", + "100": "#edffb3", + "200": "#e3ff80", + "300": "#daff4d", + "400": "#d1ff1a", + "500": "#b8e600", + "600": "#8fb400", + "700": "#708200", + "800": "#4c5100", + "900": "#182100", +}; + +export const COLOR_GREEN = { + "50": "#e6ffea", + "100": "#b3ffbf", + "200": "#80ff94", + "300": "#4dff69", + "400": "#1aff3f", + "500": "#00e626", + "600": "#00b31f", + "700": "#008119", + "800": "#004d12", + "900": "#001a0c", +}; + +export const COLOR_EMERALD = { + "50": "#e6fff5", + "100": "#b3ffe6", + "200": "#80ffd6", + "300": "#4dffc7", + "400": "#1affb8", + "500": "#00e6a6", + "600": "#00b48f", + "700": "#008273", + "800": "#004d52", + "900": "#001a21", +}; + +export const COLOR_TEAL = { + "50": "#e6fff9", + "100": "#b3ffed", + "200": "#80ffe0", + "300": "#4dffd4", + "400": "#1affc7", + "500": "#00e6b3", + "600": "#00b48f", + "700": "#00826b", + "800": "#004d47", + "900": "#001a21", +}; + +export const COLOR_CYAN = { + "50": "#e6faff", + "100": "#b3edff", + "200": "#80e0ff", + "300": "#4dd4ff", + "400": "#1ac7ff", + "500": "#00b3e6", + "600": "#0090b4", + "700": "#006d82", + "800": "#004952", + "900": "#001621", +}; + +export const COLOR_BLUE = { + "50": "#e6f0ff", + "100": "#b3d1ff", + "200": "#80b3ff", + "300": "#4d94ff", + "400": "#1a75ff", + "500": "#0066e6", + "600": "#0052b4", + "700": "#003d82", + "800": "#002952", + "900": "#001421", +}; + +export const COLOR_INDIGO = { + "50": "#e6e6ff", + "100": "#b3b3ff", + "200": "#8080ff", + "300": "#4d4dff", + "400": "#1a1aff", + "500": "#0000e6", + "600": "#0000b4", + "700": "#000082", + "800": "#000052", + "900": "#000021", +}; + +export const COLOR_VIOLET = { + "50": "#f0e6ff", + "100": "#d1b3ff", + "200": "#b380ff", + "300": "#944dff", + "400": "#751aff", + "500": "#6600e6", + "600": "#5200b4", + "700": "#3d0082", + "800": "#290052", + "900": "#140021", +}; + +export const COLOR_PURPLE = { + "50": "#fae6ff", + "100": "#edb3ff", + "200": "#e080ff", + "300": "#d44dff", + "400": "#c71aff", + "500": "#b300e6", + "600": "#9000b4", + "700": "#6d0082", + "800": "#4a0052", + "900": "#210021", +}; + +export const COLOR_FUSCHIA = { + "50": "#ffe6ff", + "100": "#ffb3ff", + "200": "#ff80ff", + "300": "#ff4dff", + "400": "#ff1aff", + "500": "#e600e6", + "600": "#b400b4", + "700": "#820082", + "800": "#520052", + "900": "#210021", +}; + +export const COLOR_PINK = { + "50": "#ffe6f0", + "100": "#ffb3d1", + "200": "#ff80b3", + "300": "#ff4d94", + "400": "#ff1a75", + "500": "#e60066", + "600": "#b40052", + "700": "#82003d", + "800": "#520029", + "900": "#210014", +}; + +export const COLOR_ROSE = { + "50": "#ffe6e6", + "100": "#ffb3b3", + "200": "#ff8080", + "300": "#ff4d4d", + "400": "#ff1a1a", + "500": "#e60000", + "600": "#b40000", + "700": "#820000", + "800": "#520000", + "900": "#210000", +}; + +export const COLOR_SLATE = { + "50": "#f8f9fa", + "100": "#e9ecef", + "200": "#dee2e6", + "300": "#ced4da", + "400": "#adb5bd", + "500": "#6c757d", + "600": "#495057", + "700": "#343a40", + "800": "#212529", + "900": "#121416", +}; + +export const COLOR_GRAY = { + "50": "#f9f9f9", + "100": "#e6e6e6", + "200": "#cccccc", + "300": "#b3b3b3", + "400": "#999999", + "500": "#808080", + "600": "#666666", + "700": "#4d4d4d", + "800": "#333333", + "900": "#1a1a1a", +}; + +export const COLOR_ZINC = { + "50": "#fafafa", + "100": "#eaeaea", + "200": "#d4d4d4", + "300": "#a8a8a8", + "400": "#7d7d7d", + "500": "#5c5c5c", + "600": "#3f3f3f", + "700": "#2b2b2b", + "800": "#171717", + "900": "#0a0a0a", +}; + +export const COLOR_NEUTRAL = { + "50": "#fafafa", + "100": "#f5f5f5", + "200": "#e5e5e5", + "300": "#d4d4d4", + "400": "#a3a3a3", + "500": "#737373", + "600": "#525252", + "700": "#404040", + "800": "#262626", + "900": "#171717", +}; + +export const COLOR_STONE = { + "50": "#fafaf9", + "100": "#f5f5f4", + "200": "#e7e5e4", + "300": "#d6d3d1", + "400": "#a8a29e", + "500": "#78716c", + "600": "#57534e", + "700": "#44403c", + "800": "#292524", + "900": "#1c1917", +}; + +export const COLOR_WHITE = "#ffffff"; +export const COLOR_BLACK = "#000000"; + +export const PADDING = { + NONE: "0px", + XS: "2px", + SM: "4px", + MD: "8px", + LG: "12px", + XL: "16px", + XXL: "24px", +}; + +export const MARGIN = { + NONE: "0px", + XS: "2px", + SM: "4px", + MD: "8px", + LG: "12px", + XL: "16px", + XXL: "24px", +}; + +export const BORDER_RADIUS = { + NONE: "0px", + SM: "2px", + MD: "4px", + LG: "8px", + XL: "12px", + FULL: "9999px", +}; + +export const FONT_SIZE = { + XS: "0.75rem", + SM: "0.875rem", + MD: "1rem", + LG: "1.125rem", + XL: "1.25rem", + XXL: "1.5rem", +}; + +export const FONT_WEIGHT = { + LIGHT: "300", + NORMAL: "400", + MEDIUM: "500", + BOLD: "700", + BLACK: "900", +}; + +export const WRAPPER_STYLE = s({ + fontSize: FONT_SIZE.MD, +}); + +export const LI_STYLE = s({ + fontSize: FONT_SIZE.MD, + marginBottom: MARGIN.SM, +}); + +export const HEADING_2_STYLE = s({ + fontSize: FONT_SIZE.LG, + fontWeight: FONT_WEIGHT.BOLD, + marginBottom: MARGIN.MD, +}); + +export const BUTTON_STYLE = s({ + padding: `${PADDING.SM} ${PADDING.MD}`, + borderRadius: BORDER_RADIUS.MD, + fontSize: FONT_SIZE.MD, + fontWeight: FONT_WEIGHT.MEDIUM, + color: COLOR_WHITE, + backgroundColor: COLOR_BLUE["600"], + border: "none", + textDecoration: "none", +}); + +export const PARAGRAPH_SPACING_STYLE = s({ + marginBottom: MARGIN.MD, +}); \ No newline at end of file diff --git a/ChatSetAttr/src/types.ts b/ChatSetAttr/src/types.ts new file mode 100644 index 0000000000..0c15b34aa5 --- /dev/null +++ b/ChatSetAttr/src/types.ts @@ -0,0 +1,187 @@ +// #region Commands + +export const COMMAND_TYPE = [ + "setattr", + "modattr", + "modbattr", + "resetattr", + "delattr" +] as const; + +export type Command = typeof COMMAND_TYPE[number]; + +export function isCommand(command: string): command is Command { + return COMMAND_TYPE.includes(command as Command); +}; + +// #region Command Options + +export const COMMAND_OPTIONS = [ + "mod", + "modb", + "reset" +] as const; + +export type CommandOption = typeof COMMAND_OPTIONS[number]; + +export type OverrideDictionary = Record; + +export const OVERRIDE_DICTIONARY: OverrideDictionary = { + "mod": "modattr", + "modb": "modbattr", + "reset": "resetattr", +} as const; + +export function isCommandOption(option: string): option is CommandOption { + return COMMAND_OPTIONS.includes(option as CommandOption); +}; + +// #region Targets + +export const TARGETS = [ + "all", + "allgm", + "allplayers", + "charid", + "name", + "sel", + "sel-noparty", + "sel-party", + "party", +] as const; + +export type Target = typeof TARGETS[number]; + +export function isTarget(target: string): target is Target { + return TARGETS.includes(target as Target); +}; + +// #region Feedback +export const FEEDBACK_OPTIONS = [ + "fb-public", + "fb-from", + "fb-header", + "fb-content", +] as const; + +export type FeedbackOption = typeof FEEDBACK_OPTIONS[number]; + +export type FeedbackObject = { + public: boolean; + from?: string; + header?: string; + content?: string; +}; + +export function isFeedbackOption(option: string): option is FeedbackOption { + for (const fbOption of FEEDBACK_OPTIONS) { + if (option.startsWith(fbOption)) return true; + } + return false; +}; + +export function extractFeedbackKey(option: string) { + if (option === "fb-public") return "public"; + if (option === "fb-from") return "from"; + if (option === "fb-header") return "header"; + if (option === "fb-content") return "content"; + return false; +}; + +// #region Options +export const OPTIONS = [ + "nocreate", + "evaluate", + "replace", + "silent", + "mute", +] as const; + +export type Option = typeof OPTIONS[number]; + +export type OptionsRecord = Record; + +export function isOption(option: string): option is Option { + return OPTIONS.includes(option as Option); +}; + +// #region Attributes +export type Attribute = { + name?: string; + current?: string | number | boolean; + max?: string | number | boolean; +}; + +export type AttributeValue = string | number | boolean | undefined; + +export type AttributeRecord = Record; + +export type Modification = "increased" | "decreased" | "multiplied" | "divided" | "reset"; + +export type AttributeWithModification = Attribute & { + modification: Modification, + previous: number, + total: number, + current: number +}; + +export type AnyAttribute = Attribute | AttributeWithModification; + +// #region ChangeSet +export type ChangeSetError = { + message: string; + target: string; + attribute?: string; +}; + +export type ChangeSet = { + operation: Command; + targets: string[]; + completed: AnyAttribute[]; + errors: ChangeSetError[]; +}; + +// #region Alias Characters + +export const ALIAS_CHARACTERS: Record = { + "<": "[", + ">": "]", + "~": "-", + ";": "?", + "`": "@", +} as const; + +// #region Versioning + +export type VersionString = + `${number}.${number}` | + `${number}.${number}.${number}` | + `${number}.${number}${string | ""}` | + `${number}.${number}.${number}${string | ""}`; + +export type VersionComparison = + "<=" | + "<" | + ">=" | + ">" | + "=" ; + +export type VersionAppliesTo = `${VersionComparison}${VersionString}`; + +export type VersionObject = { + appliesTo: VersionAppliesTo; + version: VersionString; + update: () => void; +}; + +// #region Observers + +export type ObserverEvent = "add" | "change" | "destroy"; + +export type ObserverCallback = (event: ObserverEvent, targetID: string, attribute: string, newValue: AttributeValue, oldValue: AttributeValue) => void; + +export type ObserverRecord = Record; + +// #region Timers + +export type TimerMap = Map>; \ No newline at end of file diff --git a/ChatSetAttr/src/utils/chat.ts b/ChatSetAttr/src/utils/chat.ts new file mode 100644 index 0000000000..d713d13f60 --- /dev/null +++ b/ChatSetAttr/src/utils/chat.ts @@ -0,0 +1,32 @@ +// #region Style Helpers +function convertCamelToKebab(camel: string): string { + return camel.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +}; + +export function s(styleObject: Record = {}) { + let style = ""; + for (const [key, value] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value};`; + } + return style; +}; + +// #region JSX Helper +type Child = string | null | undefined | Child[]; + +export function h( + tagName: string, + attributes: Record = {}, + ...children: Child[] +): string { + const attrs = Object.entries(attributes ?? {}) + .map(([key, value]) => ` ${key}="${value}"`) + .join(""); + + // Deeply flatten arrays and filter out null/undefined values + const flattenedChildren = children.flat(10).filter(child => child != null); + const childrenContent = flattenedChildren.join(""); + + return `<${tagName}${attrs}>${childrenContent}`; +}; \ No newline at end of file diff --git a/ChatSetAttr/src/versions/version2.ts b/ChatSetAttr/src/versions/version2.ts new file mode 100644 index 0000000000..444eea250b --- /dev/null +++ b/ChatSetAttr/src/versions/version2.ts @@ -0,0 +1,44 @@ +import { sendNotification } from "../modules/chat"; +import { getConfig, setConfig } from "../modules/config"; +import { LI_STYLE, PARAGRAPH_SPACING_STYLE, WRAPPER_STYLE } from "../templates/styles"; +import type { VersionObject } from "../types"; + +export const v2_0: VersionObject = { + appliesTo: "<=1.10", + version: "2.0", + update: () => { + // Update state data + const config = getConfig(); + config.version = "2.0"; + config.playersCanTargetParty = true; + setConfig(config); + + // Send message explaining update + const title = "ChatSetAttr Updated to Version 2.0"; + const content = ` +
+

ChatSetAttr has been updated to version 2.0!

+

This update includes important changes to improve compatibility and performance.

+ + Changelog: +
    +
  • Added compatibility for Beacon sheets, including the new Dungeons and Dragons character sheet.
  • +
  • Added support for targeting party members with the --party flag.
  • +
  • Added support for excluding party members when targeting selected tokens with the --sel-noparty flag.
  • +
  • Added support for including only party members when targeting selected tokens with the --sel-party flag.
  • +
+ +

Please review the updated documentation for details on these new features and how to use them.

+
+ If you encounter any bugs or issues, please report them via the Roll20 Helpdesk +
+
+ If you want to create a handout with the updated documentation, use the command !setattrs-help or click the button below + Create Help Handout +
+
+ `; + + sendNotification(title, content, false); + }, +}; diff --git a/ChatSetAttr/tsconfig.json b/ChatSetAttr/tsconfig.json new file mode 100644 index 0000000000..ff64fadb7a --- /dev/null +++ b/ChatSetAttr/tsconfig.json @@ -0,0 +1,14 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": false, + "jsx": "react", + "jsxFactory": "h", + }, +} diff --git a/ChatSetAttr/tsconfig.script.json b/ChatSetAttr/tsconfig.script.json new file mode 100644 index 0000000000..a9e57fb352 --- /dev/null +++ b/ChatSetAttr/tsconfig.script.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig.json", + "include": ["src", "rollup.config.ts"], + "exclude": [ + "node_modules", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.mock.ts", + ] +} \ No newline at end of file diff --git a/ChatSetAttr/tsconfig.vitest.json b/ChatSetAttr/tsconfig.vitest.json new file mode 100644 index 0000000000..e2876f1f05 --- /dev/null +++ b/ChatSetAttr/tsconfig.vitest.json @@ -0,0 +1,15 @@ +{ + "extends": "tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals", "node", "vitest.setup.ts"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.mock.ts", + "vitest.setup.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/ChatSetAttr/vitest.config.ts b/ChatSetAttr/vitest.config.ts new file mode 100644 index 0000000000..fbe931b1fd --- /dev/null +++ b/ChatSetAttr/vitest.config.ts @@ -0,0 +1,13 @@ +/// +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + typecheck: { + tsconfig: "./tsconfig.vitest.json" + }, + setupFiles: ["./vitest.setup.ts"], + }, +}); \ No newline at end of file diff --git a/ChatSetAttr/vitest.setup.ts b/ChatSetAttr/vitest.setup.ts new file mode 100644 index 0000000000..356bb35015 --- /dev/null +++ b/ChatSetAttr/vitest.setup.ts @@ -0,0 +1,71 @@ +import { vi } from "vitest"; + +import { default as SA } from "lib-smart-attributes"; +import { default as underscore } from "underscore"; + +import { mockedOn, simulateChatMessage, mockTriggerEvent } from "./src/__mocks__/eventHandling.mock"; +import { mockCreateObj, mockFindObjs, mockGetAllObjs, mockGetAttrByName, mockGetObj } from "./src/__mocks__/apiObjects.mock"; +import { getSheetItem, setSheetItem } from "./src/__mocks__/beaconAttributes.mock"; +import { log } from "./src/__mocks__/utility.mock"; +import { h, s } from "./src/utils/chat"; + +// region Global Declarations +declare global { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + var state: Record; + var executeCommand: typeof simulateChatMessage; + var triggerEvent: typeof mockTriggerEvent; + var _: typeof underscore; + var libSmartAttributes: typeof SA; +}; + +// region Libraries +global._ = underscore; + +// region Logging +global.log = log; + +// region Event Handling +global.on = mockedOn; +global.triggerEvent = mockTriggerEvent; +global.executeCommand = simulateChatMessage; + +// region State +global.state = { + ChatSetAttr: { + version: "1.10", + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true + } +}; + +// region Objects +global.getObj = vi.fn(mockGetObj); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +global.findObjs = vi.fn(mockFindObjs) as any; +global.createObj = vi.fn(mockCreateObj); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +global.getAllObjs = vi.fn(mockGetAllObjs) as any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +global.getAttrByName = vi.fn(mockGetAttrByName) as any; + +// region Beacon Attributes +global.getSheetItem = getSheetItem; +global.setSheetItem = setSheetItem; + +// region Utility Functions +global.playerIsGM = vi.fn(); +global.sendChat = vi.fn(); + +// region Requirements +global.libSmartAttributes = SA; +global.libUUID = { + generateRowID: vi.fn(() => "unique-rowid-1234"), + generatelibUUID: vi.fn(() => "unique-libUUID-5678") +}; + +// region JSX Helpers +global.h = h; +global.s = s; +// endregion \ No newline at end of file