From a825a871aae2738ff31b7fb28cb0de970707a9c9 Mon Sep 17 00:00:00 2001 From: Nic Bradley Date: Wed, 5 Nov 2025 00:03:40 +0000 Subject: [PATCH 01/38] 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 From 81152fcd864773b3c49fc06ea410abd8c20d468a Mon Sep 17 00:00:00 2001 From: Nic Bradley Date: Wed, 19 Nov 2025 23:53:28 +0000 Subject: [PATCH 02/38] ChatSetAttr | Improving Errors & Messages --- ChatSetAttr/2.0/ChatSetAttr.js | 236 ++++------- ChatSetAttr/ChatSetAttr.js | 236 ++++------- .../src/__mocks__/beaconAttributes.mock.ts | 1 + .../src/__tests__/templates/messages.test.ts | 44 +-- ChatSetAttr/src/modules/chat.ts | 5 +- ChatSetAttr/src/modules/main.ts | 35 +- ChatSetAttr/src/modules/updates.ts | 2 + ChatSetAttr/src/templates/config.tsx | 46 +-- ChatSetAttr/src/templates/delay.tsx | 23 +- ChatSetAttr/src/templates/messages.tsx | 35 +- ChatSetAttr/src/templates/notification.tsx | 23 +- ChatSetAttr/src/templates/styles.ts | 370 ++---------------- ChatSetAttr/src/versions/version2.ts | 14 +- 13 files changed, 290 insertions(+), 780 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 6d15e25c9b..a139a3ef14 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -67,125 +67,52 @@ var ChatSetAttr = (function (exports) { 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 buttonStyleBase = { + border: "none", + borderRadius: "4px", + padding: "4px 8px", + backgroundColor: "#e91ea2", + color: "#FFFFFF", + cursor: "pointer", + fontWeight: "500", }; - const COLOR_STONE = { - "50": "#fafaf9", - "400": "#a8a29e", - "700": "#44403c", - "900": "#1c1917", + const frameStyleBase = { + border: "1px solid rgba(59, 130, 246, 0.3)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(59, 130, 246, 0.1)", + }; + const frameStyleError = { + border: "1px solid rgba(239, 68, 68, 0.4)", + backgroundColor: "rgba(239, 68, 68, 0.1)", + }; + const headerStyleBase = { + fontSize: "1.5em", + marginBottom: "0.5em", }; - 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, - }); + const DELAY_WRAPPER_STYLE = s(frameStyleBase); + const DELAY_HEADER_STYLE = s(headerStyleBase); 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."))); + h("div", null, "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_WRAPPER_STYLE = s(frameStyleBase); + const CHAT_HEADER_STYLE = s(headerStyleBase); const CHAT_BODY_STYLE = s({ - fontSize: FONT_SIZE.SM, + fontSize: "14px", + lineHeight: "1.4", }); - // #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, + ...frameStyleBase, + ...frameStyleError, }); + const ERROR_HEADER_STYLE = s(headerStyleBase); const ERROR_BODY_STYLE = s({ - fontSize: FONT_SIZE.SM, + fontSize: "14px", + lineHeight: "1.4", }); // #region Generic Message Creation Function function createMessage(header, messages, styles) { @@ -210,26 +137,12 @@ var ChatSetAttr = (function (exports) { }); } - 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, - }); + const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); + const NOTIFY_HEADER_STYLE = s(headerStyleBase); 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))); + h("div", null, content))); } function getPlayerName(playerID) { @@ -261,7 +174,7 @@ var ChatSetAttr = (function (exports) {

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

`; +

Create Journal Handout

`; sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); } @@ -612,21 +525,8 @@ var ChatSetAttr = (function (exports) { 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_WRAPPER_STYLE = s(frameStyleBase); + const CONFIG_HEADER_STYLE = s(headerStyleBase); const CONFIG_TABLE_STYLE = s({ width: "100%", border: "none", @@ -634,24 +534,19 @@ var ChatSetAttr = (function (exports) { borderSpacing: "0 4px", }); const CONFIG_ROW_STYLE = s({ - marginBottom: PADDING.XS, + marginBottom: "4px", }); - 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, + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", }); const CONFIG_BUTTON_STYLE_OFF = s({ - backgroundColor: COLOR_RED["300"], - ...CONFIG_BUTTON_SHARED, + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", }); const CONFIG_CLEAR_FIX_STYLE = s({ clear: "both", @@ -665,7 +560,7 @@ var ChatSetAttr = (function (exports) { 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("div", null, h("table", { style: CONFIG_TABLE_STYLE }, relevantEntries.map(([key, value]) => (h("tr", { style: CONFIG_ROW_STYLE }, h("td", null, h("strong", null, @@ -1906,6 +1801,7 @@ var ChatSetAttr = (function (exports) { if (isSetting) { const value = results[target][name] ?? ""; try { + console.log("Setting attribute", actualName, "on target", target, "to", value, "with type", type); await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); } catch (error) { @@ -1945,15 +1841,28 @@ var ChatSetAttr = (function (exports) { const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); // Start Timer startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + // Check Config and Permissions + const config = getConfig(); + const isGM = playerIsGM(msg.playerid); + if (options.evaluate && !isGM && !config.playersCanEvaluate) { + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); + } + if (targeting.includes("party") && !isGM && !config.playersCanTargetParty) { + return errorOut("You do not have permission to target the party.", msg.playerid, errors); + } + if ((operation === "modattr" || operation === "modbattr") && !isGM && !config.playersCanModify) { + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); + } // Preprocess const { targets, errors: targetErrors } = generateTargets(msg, targeting); errors.push(...targetErrors); + if (targets.length === 0) { + return errorOut("No valid targets found.", msg.playerid, errors); + } 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; + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); } // Execute for (const target of targets) { @@ -1982,6 +1891,11 @@ var ChatSetAttr = (function (exports) { const feedbackTitle = feedback?.header ?? delSetTitle; sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); } + function errorOut(errorText, playerid, errors) { + errors.push("No valid targets found."); + sendErrors(playerid, "Errors", errors); + clearTimer("chatsetattr"); + } function generateRequest(references, changes) { const referenceSet = new Set(references); for (const change of changes) { @@ -2037,6 +1951,14 @@ var ChatSetAttr = (function (exports) { }); } + const LI_STYLE = s({ + marginBottom: "4px", + }); + const WRAPPER_STYLE = s(frameStyleBase); + const PARAGRAPH_SPACING_STYLE = s({ + marginTop: "8px", + marginBottom: "8px", + }); const v2_0 = { appliesTo: "<=1.10", version: "2.0", diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 6d15e25c9b..a139a3ef14 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -67,125 +67,52 @@ var ChatSetAttr = (function (exports) { 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 buttonStyleBase = { + border: "none", + borderRadius: "4px", + padding: "4px 8px", + backgroundColor: "#e91ea2", + color: "#FFFFFF", + cursor: "pointer", + fontWeight: "500", }; - const COLOR_STONE = { - "50": "#fafaf9", - "400": "#a8a29e", - "700": "#44403c", - "900": "#1c1917", + const frameStyleBase = { + border: "1px solid rgba(59, 130, 246, 0.3)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(59, 130, 246, 0.1)", + }; + const frameStyleError = { + border: "1px solid rgba(239, 68, 68, 0.4)", + backgroundColor: "rgba(239, 68, 68, 0.1)", + }; + const headerStyleBase = { + fontSize: "1.5em", + marginBottom: "0.5em", }; - 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, - }); + const DELAY_WRAPPER_STYLE = s(frameStyleBase); + const DELAY_HEADER_STYLE = s(headerStyleBase); 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."))); + h("div", null, "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_WRAPPER_STYLE = s(frameStyleBase); + const CHAT_HEADER_STYLE = s(headerStyleBase); const CHAT_BODY_STYLE = s({ - fontSize: FONT_SIZE.SM, + fontSize: "14px", + lineHeight: "1.4", }); - // #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, + ...frameStyleBase, + ...frameStyleError, }); + const ERROR_HEADER_STYLE = s(headerStyleBase); const ERROR_BODY_STYLE = s({ - fontSize: FONT_SIZE.SM, + fontSize: "14px", + lineHeight: "1.4", }); // #region Generic Message Creation Function function createMessage(header, messages, styles) { @@ -210,26 +137,12 @@ var ChatSetAttr = (function (exports) { }); } - 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, - }); + const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); + const NOTIFY_HEADER_STYLE = s(headerStyleBase); 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))); + h("div", null, content))); } function getPlayerName(playerID) { @@ -261,7 +174,7 @@ var ChatSetAttr = (function (exports) {

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

`; +

Create Journal Handout

`; sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); } @@ -612,21 +525,8 @@ var ChatSetAttr = (function (exports) { 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_WRAPPER_STYLE = s(frameStyleBase); + const CONFIG_HEADER_STYLE = s(headerStyleBase); const CONFIG_TABLE_STYLE = s({ width: "100%", border: "none", @@ -634,24 +534,19 @@ var ChatSetAttr = (function (exports) { borderSpacing: "0 4px", }); const CONFIG_ROW_STYLE = s({ - marginBottom: PADDING.XS, + marginBottom: "4px", }); - 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, + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", }); const CONFIG_BUTTON_STYLE_OFF = s({ - backgroundColor: COLOR_RED["300"], - ...CONFIG_BUTTON_SHARED, + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", }); const CONFIG_CLEAR_FIX_STYLE = s({ clear: "both", @@ -665,7 +560,7 @@ var ChatSetAttr = (function (exports) { 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("div", null, h("table", { style: CONFIG_TABLE_STYLE }, relevantEntries.map(([key, value]) => (h("tr", { style: CONFIG_ROW_STYLE }, h("td", null, h("strong", null, @@ -1906,6 +1801,7 @@ var ChatSetAttr = (function (exports) { if (isSetting) { const value = results[target][name] ?? ""; try { + console.log("Setting attribute", actualName, "on target", target, "to", value, "with type", type); await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); } catch (error) { @@ -1945,15 +1841,28 @@ var ChatSetAttr = (function (exports) { const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); // Start Timer startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + // Check Config and Permissions + const config = getConfig(); + const isGM = playerIsGM(msg.playerid); + if (options.evaluate && !isGM && !config.playersCanEvaluate) { + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); + } + if (targeting.includes("party") && !isGM && !config.playersCanTargetParty) { + return errorOut("You do not have permission to target the party.", msg.playerid, errors); + } + if ((operation === "modattr" || operation === "modbattr") && !isGM && !config.playersCanModify) { + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); + } // Preprocess const { targets, errors: targetErrors } = generateTargets(msg, targeting); errors.push(...targetErrors); + if (targets.length === 0) { + return errorOut("No valid targets found.", msg.playerid, errors); + } 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; + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); } // Execute for (const target of targets) { @@ -1982,6 +1891,11 @@ var ChatSetAttr = (function (exports) { const feedbackTitle = feedback?.header ?? delSetTitle; sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); } + function errorOut(errorText, playerid, errors) { + errors.push("No valid targets found."); + sendErrors(playerid, "Errors", errors); + clearTimer("chatsetattr"); + } function generateRequest(references, changes) { const referenceSet = new Set(references); for (const change of changes) { @@ -2037,6 +1951,14 @@ var ChatSetAttr = (function (exports) { }); } + const LI_STYLE = s({ + marginBottom: "4px", + }); + const WRAPPER_STYLE = s(frameStyleBase); + const PARAGRAPH_SPACING_STYLE = s({ + marginTop: "8px", + marginBottom: "8px", + }); const v2_0 = { appliesTo: "<=1.10", version: "2.0", diff --git a/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts index cd8f31ba5c..cef967280e 100644 --- a/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts +++ b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts @@ -25,6 +25,7 @@ export async function getSheetItem( if (!attribute) { return undefined; } + console.log("Getting attribute", attributeName, "on character", characterId, "with type", type); return attribute[type]; }; diff --git a/ChatSetAttr/src/__tests__/templates/messages.test.ts b/ChatSetAttr/src/__tests__/templates/messages.test.ts index f73d092544..cf32fdb59f 100644 --- a/ChatSetAttr/src/__tests__/templates/messages.test.ts +++ b/ChatSetAttr/src/__tests__/templates/messages.test.ts @@ -62,18 +62,18 @@ describe("messages", () => { 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("border: 1px solid rgba(59, 130, 246, 0.3)"); + expect(result).toContain("border-radius: 8px"); expect(result).toContain("padding: 8px"); - expect(result).toContain("background-color: #e6fff5"); + expect(result).toContain("background-color: rgba(59, 130, 246, 0.1)"); // Check for header styles - expect(result).toContain("font-size: 1.125rem"); - expect(result).toContain("font-weight: bold"); - expect(result).toContain("margin-bottom: 4px"); + expect(result).toContain("font-size: 1.5em"); + expect(result).toContain("margin-bottom: 0.5em"); // Check for body styles - expect(result).toContain("font-size: 0.875rem"); + expect(result).toContain("font-size: 14px"); + expect(result).toContain("line-height: 1.4"); // Should NOT contain error-specific styles expect(result).not.toContain("color: #ff2020"); @@ -199,18 +199,18 @@ describe("messages", () => { 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("border: 1px solid rgba(239, 68, 68, 0.4)"); + expect(result).toContain("border-radius: 8px"); expect(result).toContain("padding: 8px"); - expect(result).toContain("background-color: #ffebeb"); + expect(result).toContain("background-color: rgba(239, 68, 68, 0.1)"); // Check for header styles (error-specific) - expect(result).toContain("color: #ff2020"); - expect(result).toContain("font-weight: bold"); - expect(result).toContain("font-size: 1.125rem"); + expect(result).toContain("font-size: 1.5em"); + expect(result).toContain("margin-bottom: 0.5em"); // Check for body styles - expect(result).toContain("font-size: 0.875rem"); + expect(result).toContain("font-size: 14px"); + expect(result).toContain("line-height: 1.4"); // Should NOT contain chat-specific styles expect(result).not.toContain("border: 1px solid #ccc"); @@ -285,16 +285,16 @@ describe("messages", () => { const errorResult = createErrorMessage(header, content); // Should have different wrapper styles - expect(chatResult).toContain("border: 1px solid #4dffc7"); - expect(errorResult).toContain("border: 1px solid #ff7474"); + expect(chatResult).toContain("border: 1px solid rgba(59, 130, 246, 0.3)"); + expect(errorResult).toContain("border: 1px solid rgba(239, 68, 68, 0.4)"); - // Should have different header styles - expect(chatResult).not.toContain("color: #ff2020"); - expect(errorResult).toContain("color: #ff2020"); + // Should have same header styles now (both use headerStyleBase) + expect(chatResult).toContain("font-size: 1.5em"); + expect(errorResult).toContain("font-size: 1.5em"); - // 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"); + // Should have same header margin (both use headerStyleBase) + expect(chatResult).toContain("margin-bottom: 0.5em"); + expect(errorResult).toContain("margin-bottom: 0.5em"); }); it("should have the same basic structure but different styles", () => { diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index fcfde5f7ba..89013b98c3 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -1,7 +1,8 @@ import { createDelayMessage } from "../templates/delay"; import { createChatMessage, createErrorMessage } from "../templates/messages"; import { createNotifyMessage } from "../templates/notification"; -import { BUTTON_STYLE } from "../templates/styles"; +import { buttonStyleBase } from "../templates/styles"; +import { s } from "../utils/chat"; export function getPlayerName(playerID: string): string { const player = getObj("player", playerID); @@ -45,6 +46,6 @@ export function sendWelcomeMessage(): void {

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

`; +

Create Journal Handout

`; sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); }; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 61ea0d45de..a7dde8b346 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -1,9 +1,10 @@ +import { error } from "console"; 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 { checkConfigMessage, getConfig, handleConfigCommand, hasFlag } from "./config"; import { checkHelpMessage, handleHelpCommand } from "./help"; import { extractMessageFromRollTemplate, parseMessage, validateMessage } from "./message"; import { processModifications } from "./modifications"; @@ -45,15 +46,34 @@ async function acceptMessage(msg: Roll20ChatMessage) { // Start Timer startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + // Check Config and Permissions + const config = getConfig(); + const isGM = playerIsGM(msg.playerid); + + if (options.evaluate && !isGM && !config.playersCanEvaluate) { + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); + } + + if (targeting.includes("party") && !isGM && !config.playersCanTargetParty) { + return errorOut("You do not have permission to target the party.", msg.playerid, errors); + } + + if((operation === "modattr" || operation === "modbattr") && !isGM && !config.playersCanModify) { + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); + } + // Preprocess const { targets, errors: targetErrors } = generateTargets(msg, targeting); errors.push(...targetErrors); + if (targets.length === 0) { + return errorOut("No valid targets found.", msg.playerid, errors); + } + 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; + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); } // Execute @@ -89,6 +109,13 @@ async function acceptMessage(msg: Roll20ChatMessage) { sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); }; +function errorOut(errorText: string, playerid: string, errors: string[]) { + errors.push("No valid targets found."); + sendErrors(playerid, "Errors", errors); + clearTimer("chatsetattr"); +} + + export function generateRequest( references: string[], changes: Attribute[], diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts index d14b79023c..01a8c56f21 100644 --- a/ChatSetAttr/src/modules/updates.ts +++ b/ChatSetAttr/src/modules/updates.ts @@ -36,6 +36,8 @@ export async function makeUpdate( const value = results[target][name] ?? ""; try { + console.log("Setting attribute", actualName, "on target", target, "to", value, "with type", type + ); await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); } catch (error: unknown) { errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); diff --git a/ChatSetAttr/src/templates/config.tsx b/ChatSetAttr/src/templates/config.tsx index 3eedd71acd..e63153cdd0 100644 --- a/ChatSetAttr/src/templates/config.tsx +++ b/ChatSetAttr/src/templates/config.tsx @@ -1,24 +1,10 @@ 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"; +import { buttonStyleBase, frameStyleBase, headerStyleBase } 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_WRAPPER_STYLE = s(frameStyleBase); -const CONFIG_BODY_STYLE = s({ - fontSize: FONT_SIZE.SM, -}); +const CONFIG_HEADER_STYLE = s(headerStyleBase); const CONFIG_TABLE_STYLE = s({ width: "100%", @@ -28,27 +14,21 @@ const CONFIG_TABLE_STYLE = s({ }); const CONFIG_ROW_STYLE = s({ - marginBottom: PADDING.XS, + marginBottom: "4px", }); -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, + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", }); const CONFIG_BUTTON_STYLE_OFF = s({ - backgroundColor: COLOR_RED["300"], - ...CONFIG_BUTTON_SHARED, + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", }); const CONFIG_CLEAR_FIX_STYLE = s({ @@ -68,7 +48,7 @@ export function createConfigMessage(): string { return (
ChatSetAttr Configuration
-
+
{relevantEntries.map(([key, value]) => ( diff --git a/ChatSetAttr/src/templates/delay.tsx b/ChatSetAttr/src/templates/delay.tsx index e39a452b5d..7b1058a5ce 100644 --- a/ChatSetAttr/src/templates/delay.tsx +++ b/ChatSetAttr/src/templates/delay.tsx @@ -1,30 +1,15 @@ import { s } from "../utils/chat"; -import { BORDER_RADIUS, COLOR_STONE, FONT_SIZE, PADDING } from "./styles"; +import { frameStyleBase, headerStyleBase } 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_WRAPPER_STYLE = s(frameStyleBase); -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, -}); +const DELAY_HEADER_STYLE = s(headerStyleBase); 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.
diff --git a/ChatSetAttr/src/templates/messages.tsx b/ChatSetAttr/src/templates/messages.tsx index d59b042114..48ed8484a3 100644 --- a/ChatSetAttr/src/templates/messages.tsx +++ b/ChatSetAttr/src/templates/messages.tsx @@ -1,40 +1,25 @@ import { s } from "../utils/chat"; -import { BORDER_RADIUS, COLOR_EMERALD, COLOR_RED, FONT_SIZE, PADDING } from "./styles"; +import { frameStyleBase, frameStyleError, headerStyleBase } 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_WRAPPER_STYLE = s(frameStyleBase); -const CHAT_HEADER_STYLE = s({ - fontSize: FONT_SIZE.LG, - fontWeight: "bold", - marginBottom: PADDING.SM, -}); +const CHAT_HEADER_STYLE = s(headerStyleBase); const CHAT_BODY_STYLE = s({ - fontSize: FONT_SIZE.SM, + fontSize: "14px", + lineHeight: "1.4", }); -// #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"], + ...frameStyleBase, + ...frameStyleError, }); -const ERROR_HEADER_STYLE = s({ - color: COLOR_RED["500"], - fontWeight: "bold", - fontSize: FONT_SIZE.LG, -}); +const ERROR_HEADER_STYLE = s(headerStyleBase); const ERROR_BODY_STYLE = s({ - fontSize: FONT_SIZE.SM, + fontSize: "14px", + lineHeight: "1.4", }); // #region Message Styles Type diff --git a/ChatSetAttr/src/templates/notification.tsx b/ChatSetAttr/src/templates/notification.tsx index 82a483be92..89e208ea43 100644 --- a/ChatSetAttr/src/templates/notification.tsx +++ b/ChatSetAttr/src/templates/notification.tsx @@ -1,30 +1,15 @@ import { s } from "../utils/chat"; -import { BORDER_RADIUS, COLOR_BLUE, FONT_SIZE, PADDING } from "./styles"; +import { frameStyleBase, headerStyleBase } 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_WRAPPER_STYLE = s(frameStyleBase); -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, -}); +const NOTIFY_HEADER_STYLE = s(headerStyleBase); export function createNotifyMessage(title: string, content: string): string { return (
{title}
-
+
{content}
diff --git a/ChatSetAttr/src/templates/styles.ts b/ChatSetAttr/src/templates/styles.ts index c096f73491..1320007deb 100644 --- a/ChatSetAttr/src/templates/styles.ts +++ b/ChatSetAttr/src/templates/styles.ts @@ -1,353 +1,41 @@ -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 buttonStyleBase = { + border: "none", + borderRadius: "4px", + padding: "4px 8px", + backgroundColor: "#e91ea2", + color: "#FFFFFF", + cursor: "pointer", + fontWeight: "500", }; -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 buttonStyleSecondary = { + backgroundColor: "rgba(123, 31, 162, 1)", + color: "#FFFFFF", }; -export const MARGIN = { - NONE: "0px", - XS: "2px", - SM: "4px", - MD: "8px", - LG: "12px", - XL: "16px", - XXL: "24px", +export const frameStyleBase = { + border: "1px solid rgba(59, 130, 246, 0.3)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(59, 130, 246, 0.1)", }; -export const BORDER_RADIUS = { - NONE: "0px", - SM: "2px", - MD: "4px", - LG: "8px", - XL: "12px", - FULL: "9999px", +export const frameStyleWarning = { + border: "1px solid rgba(245, 158, 11, 0.4)", + backgroundColor: "rgba(245, 158, 11, 0.1)", }; -export const FONT_SIZE = { - XS: "0.75rem", - SM: "0.875rem", - MD: "1rem", - LG: "1.125rem", - XL: "1.25rem", - XXL: "1.5rem", +export const frameStyleError = { + border: "1px solid rgba(239, 68, 68, 0.4)", + backgroundColor: "rgba(239, 68, 68, 0.1)", }; -export const FONT_WEIGHT = { - LIGHT: "300", - NORMAL: "400", - MEDIUM: "500", - BOLD: "700", - BLACK: "900", +export const headerStyleBase = { + fontSize: "1.5em", + marginBottom: "0.5em", }; -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 +export const headerStyleSecondary = { + fontSize: "1.25em", + marginBottom: "0.5em", +}; \ No newline at end of file diff --git a/ChatSetAttr/src/versions/version2.ts b/ChatSetAttr/src/versions/version2.ts index 444eea250b..7d276b434f 100644 --- a/ChatSetAttr/src/versions/version2.ts +++ b/ChatSetAttr/src/versions/version2.ts @@ -1,7 +1,19 @@ import { sendNotification } from "../modules/chat"; import { getConfig, setConfig } from "../modules/config"; -import { LI_STYLE, PARAGRAPH_SPACING_STYLE, WRAPPER_STYLE } from "../templates/styles"; +import { frameStyleBase } from "../templates/styles"; import type { VersionObject } from "../types"; +import { s } from "../utils/chat"; + +const LI_STYLE = s({ + marginBottom: "4px", +}); + +const WRAPPER_STYLE = s(frameStyleBase); + +const PARAGRAPH_SPACING_STYLE = s({ + marginTop: "8px", + marginBottom: "8px", +}); export const v2_0: VersionObject = { appliesTo: "<=1.10", From 8e965d3c8658d1e77258c923c25615c0627c772e Mon Sep 17 00:00:00 2001 From: Nic Bradley Date: Wed, 3 Dec 2025 20:33:38 +0000 Subject: [PATCH 03/38] park --- ChatSetAttr/2.0/ChatSetAttr.js | 83 ++++++++++++------- ChatSetAttr/ChatSetAttr.js | 83 ++++++++++++------- .../src/__tests__/unit/versioning.test.ts | 2 +- ChatSetAttr/src/modules/chat.ts | 7 +- ChatSetAttr/src/modules/versioning.ts | 2 +- ChatSetAttr/src/templates/styles.ts | 4 +- ChatSetAttr/src/templates/versions/2.0.0.tsx | 39 +++++++++ ChatSetAttr/src/templates/welcome.tsx | 14 ++++ ChatSetAttr/src/versions/2.0.0.ts | 22 +++++ ChatSetAttr/src/versions/version2.ts | 56 ------------- 10 files changed, 187 insertions(+), 125 deletions(-) create mode 100644 ChatSetAttr/src/templates/versions/2.0.0.tsx create mode 100644 ChatSetAttr/src/templates/welcome.tsx create mode 100644 ChatSetAttr/src/versions/2.0.0.ts delete mode 100644 ChatSetAttr/src/versions/version2.ts diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index a139a3ef14..e14f95504e 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -71,8 +71,8 @@ var ChatSetAttr = (function (exports) { border: "none", borderRadius: "4px", padding: "4px 8px", - backgroundColor: "#e91ea2", - color: "#FFFFFF", + backgroundColor: "rgba(233, 30, 162, 1)", + color: "rgba(255, 255, 255, 1)", cursor: "pointer", fontWeight: "500", }; @@ -145,6 +145,22 @@ var ChatSetAttr = (function (exports) { h("div", null, content))); } + function createWelcomeMessage() { + const buttonStyle = s(buttonStyleBase); + return (h("div", null, + h("p", null, "Thank you for installing ChatSetAttr."), + h("p", null, + "To get started, use the command ", + h("code", null, "!setattr-config"), + " to configure the script to your needs."), + h("p", null, + "For detailed documentation and examples, please use the ", + h("code", null, "!setattr-help"), + " command or click the button below:"), + h("p", null, + h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); + } + function getPlayerName(playerID) { const player = getObj("player", playerID); return player?.get("_displayname") ?? "Unknown Player"; @@ -170,11 +186,7 @@ var ChatSetAttr = (function (exports) { 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

`; + const welcomeMessage = createWelcomeMessage(); sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); } @@ -1959,6 +1971,39 @@ var ChatSetAttr = (function (exports) { marginTop: "8px", marginBottom: "8px", }); + function createVersionMessage() { + return (h("div", { style: WRAPPER_STYLE }, + h("p", null, + h("strong", null, "ChatSetAttr has been updated to version 2.0!")), + h("p", null, "This update includes important changes to improve compatibility and performance."), + h("strong", null, "Changelog:"), + h("ul", null, + h("li", { style: LI_STYLE }, "Added compatibility for Beacon sheets, including the new Dungeons and Dragons character sheet."), + h("li", { style: LI_STYLE }, + "Added support for targeting party members with the ", + h("code", null, "--party"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for excluding party members when targeting selected tokens with the ", + h("code", null, "--sel-noparty"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for including only party members when targeting selected tokens with the ", + h("code", null, "--sel-party"), + " flag.")), + h("p", null, "Please review the updated documentation for details on these new features and how to use them."), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you encounter any bugs or issues, please report them via the ", + h("a", { href: "https://help.roll20.net/hc/en-us/requests/new" }, "Roll20 Helpdesk"))), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you want to create a handout with the updated documentation, use the command ", + h("code", null, "!setattrs-help"), + " or click the button below"), + h("a", { href: "!setattrs-help" }, "Create Help Handout")))); + } + const v2_0 = { appliesTo: "<=1.10", version: "2.0", @@ -1970,29 +2015,7 @@ var ChatSetAttr = (function (exports) { 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 -
-
- `; + const content = createVersionMessage(); sendNotification(title, content, false); }, }; diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index a139a3ef14..e14f95504e 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -71,8 +71,8 @@ var ChatSetAttr = (function (exports) { border: "none", borderRadius: "4px", padding: "4px 8px", - backgroundColor: "#e91ea2", - color: "#FFFFFF", + backgroundColor: "rgba(233, 30, 162, 1)", + color: "rgba(255, 255, 255, 1)", cursor: "pointer", fontWeight: "500", }; @@ -145,6 +145,22 @@ var ChatSetAttr = (function (exports) { h("div", null, content))); } + function createWelcomeMessage() { + const buttonStyle = s(buttonStyleBase); + return (h("div", null, + h("p", null, "Thank you for installing ChatSetAttr."), + h("p", null, + "To get started, use the command ", + h("code", null, "!setattr-config"), + " to configure the script to your needs."), + h("p", null, + "For detailed documentation and examples, please use the ", + h("code", null, "!setattr-help"), + " command or click the button below:"), + h("p", null, + h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); + } + function getPlayerName(playerID) { const player = getObj("player", playerID); return player?.get("_displayname") ?? "Unknown Player"; @@ -170,11 +186,7 @@ var ChatSetAttr = (function (exports) { 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

`; + const welcomeMessage = createWelcomeMessage(); sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); } @@ -1959,6 +1971,39 @@ var ChatSetAttr = (function (exports) { marginTop: "8px", marginBottom: "8px", }); + function createVersionMessage() { + return (h("div", { style: WRAPPER_STYLE }, + h("p", null, + h("strong", null, "ChatSetAttr has been updated to version 2.0!")), + h("p", null, "This update includes important changes to improve compatibility and performance."), + h("strong", null, "Changelog:"), + h("ul", null, + h("li", { style: LI_STYLE }, "Added compatibility for Beacon sheets, including the new Dungeons and Dragons character sheet."), + h("li", { style: LI_STYLE }, + "Added support for targeting party members with the ", + h("code", null, "--party"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for excluding party members when targeting selected tokens with the ", + h("code", null, "--sel-noparty"), + " flag."), + h("li", { style: LI_STYLE }, + "Added support for including only party members when targeting selected tokens with the ", + h("code", null, "--sel-party"), + " flag.")), + h("p", null, "Please review the updated documentation for details on these new features and how to use them."), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you encounter any bugs or issues, please report them via the ", + h("a", { href: "https://help.roll20.net/hc/en-us/requests/new" }, "Roll20 Helpdesk"))), + h("div", { style: PARAGRAPH_SPACING_STYLE }, + h("strong", null, + "If you want to create a handout with the updated documentation, use the command ", + h("code", null, "!setattrs-help"), + " or click the button below"), + h("a", { href: "!setattrs-help" }, "Create Help Handout")))); + } + const v2_0 = { appliesTo: "<=1.10", version: "2.0", @@ -1970,29 +2015,7 @@ var ChatSetAttr = (function (exports) { 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 -
-
- `; + const content = createVersionMessage(); sendNotification(title, content, false); }, }; diff --git a/ChatSetAttr/src/__tests__/unit/versioning.test.ts b/ChatSetAttr/src/__tests__/unit/versioning.test.ts index 6de3bc3791..e9c736ffb2 100644 --- a/ChatSetAttr/src/__tests__/unit/versioning.test.ts +++ b/ChatSetAttr/src/__tests__/unit/versioning.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { checkForUpdates } from "../../modules/versioning"; -import { v2_0 } from "../../versions/version2"; +import { v2_0 } from "../../versions/2.0.0"; import { getConfig, setConfig } from "../../modules/config"; vi.mock("../../versions/version2", () => { diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index 89013b98c3..2708206300 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -2,6 +2,7 @@ import { createDelayMessage } from "../templates/delay"; import { createChatMessage, createErrorMessage } from "../templates/messages"; import { createNotifyMessage } from "../templates/notification"; import { buttonStyleBase } from "../templates/styles"; +import { createWelcomeMessage } from "../templates/welcome"; import { s } from "../utils/chat"; export function getPlayerName(playerID: string): string { @@ -42,10 +43,6 @@ export function sendNotification(title: string, content: string, archive?: boole }; 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

`; + const welcomeMessage = createWelcomeMessage(); sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); }; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/versioning.ts b/ChatSetAttr/src/modules/versioning.ts index 35c624df70..7e79a50055 100644 --- a/ChatSetAttr/src/modules/versioning.ts +++ b/ChatSetAttr/src/modules/versioning.ts @@ -1,5 +1,5 @@ import type { VersionObject } from "../types"; -import { v2_0 } from "../versions/version2"; +import { v2_0 } from "../versions/2.0.0"; import { sendWelcomeMessage } from "./chat"; import { getConfig, hasFlag, setConfig, setFlag } from "./config"; diff --git a/ChatSetAttr/src/templates/styles.ts b/ChatSetAttr/src/templates/styles.ts index 1320007deb..e37f6b7b9a 100644 --- a/ChatSetAttr/src/templates/styles.ts +++ b/ChatSetAttr/src/templates/styles.ts @@ -2,8 +2,8 @@ export const buttonStyleBase = { border: "none", borderRadius: "4px", padding: "4px 8px", - backgroundColor: "#e91ea2", - color: "#FFFFFF", + backgroundColor: "rgba(233, 30, 162, 1)", + color: "rgba(255, 255, 255, 1)", cursor: "pointer", fontWeight: "500", }; diff --git a/ChatSetAttr/src/templates/versions/2.0.0.tsx b/ChatSetAttr/src/templates/versions/2.0.0.tsx new file mode 100644 index 0000000000..90eed76b9e --- /dev/null +++ b/ChatSetAttr/src/templates/versions/2.0.0.tsx @@ -0,0 +1,39 @@ +import { s } from "../../utils/chat"; +import { frameStyleBase } from "../styles"; + +const LI_STYLE = s({ + marginBottom: "4px", +}); + +const WRAPPER_STYLE = s(frameStyleBase); + +const PARAGRAPH_SPACING_STYLE = s({ + marginTop: "8px", + marginBottom: "8px", +}); + +export function createVersionMessage() { + return ( +
+

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 +
+
+ ); +} \ No newline at end of file diff --git a/ChatSetAttr/src/templates/welcome.tsx b/ChatSetAttr/src/templates/welcome.tsx new file mode 100644 index 0000000000..1a040da682 --- /dev/null +++ b/ChatSetAttr/src/templates/welcome.tsx @@ -0,0 +1,14 @@ +import { s } from "../utils/chat"; +import { buttonStyleBase } from "./styles"; + +export function createWelcomeMessage(): string { + const buttonStyle = s(buttonStyleBase); + return ( +
+

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

+
+ ); +} \ No newline at end of file diff --git a/ChatSetAttr/src/versions/2.0.0.ts b/ChatSetAttr/src/versions/2.0.0.ts new file mode 100644 index 0000000000..2247c3f47d --- /dev/null +++ b/ChatSetAttr/src/versions/2.0.0.ts @@ -0,0 +1,22 @@ +import { sendNotification } from "../modules/chat"; +import { getConfig, setConfig } from "../modules/config"; +import { createVersionMessage } from "../templates/versions/2.0.0"; +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 = createVersionMessage(); + + sendNotification(title, content, false); + }, +}; diff --git a/ChatSetAttr/src/versions/version2.ts b/ChatSetAttr/src/versions/version2.ts deleted file mode 100644 index 7d276b434f..0000000000 --- a/ChatSetAttr/src/versions/version2.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { sendNotification } from "../modules/chat"; -import { getConfig, setConfig } from "../modules/config"; -import { frameStyleBase } from "../templates/styles"; -import type { VersionObject } from "../types"; -import { s } from "../utils/chat"; - -const LI_STYLE = s({ - marginBottom: "4px", -}); - -const WRAPPER_STYLE = s(frameStyleBase); - -const PARAGRAPH_SPACING_STYLE = s({ - marginTop: "8px", - marginBottom: "8px", -}); - -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); - }, -}; From 0a28878a01298fec0ff0802abfe2b188a18360e8 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 5 Mar 2026 21:27:57 -0600 Subject: [PATCH 04/38] tool-version change --- ChatSetAttr/.tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatSetAttr/.tool-versions b/ChatSetAttr/.tool-versions index acb1cd0680..d7568adf6a 100644 --- a/ChatSetAttr/.tool-versions +++ b/ChatSetAttr/.tool-versions @@ -1 +1 @@ -nodejs 22.12.0 \ No newline at end of file +nodejs 20.11.1 From f7e74602d3ab9dad6bb8de8ec3bf9e39543a2f0a Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Sun, 17 May 2026 18:52:04 -0500 Subject: [PATCH 05/38] Initial changes to ChatSetAttr 2.0 --- ChatSetAttr/2.0/ChatSetAttr.js | 19 ++++++--- ChatSetAttr/ChatSetAttr.js | 19 ++++++--- ChatSetAttr/rollup.config.ts | 11 ++++- ChatSetAttr/script.json | 3 +- ChatSetAttr/src/__tests__/unit/update.test.ts | 10 ++--- .../src/__tests__/unit/versioning.test.ts | 2 +- ChatSetAttr/src/env.d.ts | 42 ++++++++++++------- ChatSetAttr/src/modules/main.ts | 7 +++- ChatSetAttr/src/modules/permissions.ts | 14 ++++++- ChatSetAttr/src/modules/targets.ts | 7 +++- ChatSetAttr/src/modules/updates.ts | 6 +-- ChatSetAttr/src/modules/versioning.ts | 2 +- ChatSetAttr/tsconfig.json | 4 +- ChatSetAttr/tsconfig.script.json | 17 ++++++-- ChatSetAttr/tsconfig.vitest.json | 5 ++- ChatSetAttr/vitest.setup.ts | 5 +-- 16 files changed, 117 insertions(+), 56 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index e14f95504e..3e463943ba 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -1556,7 +1556,7 @@ var ChatSetAttr = (function (exports) { throw new Error(`Player with ID ${playerID} not found.`); } const isGM = playerIsGM(playerID); - const config = state.ChatSetAttr?.config || {}; + const config = getConfig(); const playersCanModify = config.playersCanModify || false; const canModify = isGM || playersCanModify; setPermissions(playerID, isGM, canModify); @@ -1578,6 +1578,9 @@ var ChatSetAttr = (function (exports) { if (isGM) { return true; } + if (getConfig().playersCanModify) { + return true; + } const character = getObj("character", target); if (!character) { return false; @@ -1800,10 +1803,10 @@ var ChatSetAttr = (function (exports) { const errors = []; const messages = []; const { noCreate = false } = {}; - const { setWithWorker = false } = getConfig() || {}; + const { useWorkers = true } = getConfig() || {}; const setOptions = { noCreate, - setWithWorker, + setWithWorker: useWorkers, }; for (const target in results) { for (const name in results[target]) { @@ -1813,7 +1816,6 @@ var ChatSetAttr = (function (exports) { if (isSetting) { const value = results[target][name] ?? ""; try { - console.log("Setting attribute", actualName, "on target", target, "to", value, "with type", type); await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); } catch (error) { @@ -1904,7 +1906,7 @@ var ChatSetAttr = (function (exports) { sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); } function errorOut(errorText, playerid, errors) { - errors.push("No valid targets found."); + errors.push(errorText); sendErrors(playerid, "Errors", errors); clearTimer("chatsetattr"); } @@ -1942,6 +1944,8 @@ var ChatSetAttr = (function (exports) { const debugVersion = msg.content.startsWith("!setattrs-debugversion"); if (debugVersion) { log("ChatSetAttr: Debug - setting version to 1.10."); + if (!state.ChatSetAttr) + state.ChatSetAttr = {}; state.ChatSetAttr.version = "1.10"; return; } @@ -1952,6 +1956,9 @@ var ChatSetAttr = (function (exports) { } const isConfigMessage = checkConfigMessage(msg.content); if (isConfigMessage) { + if (!playerIsGM(msg.playerid)) { + return; + } handleConfigCommand(msg.content); return; } @@ -2040,7 +2047,7 @@ var ChatSetAttr = (function (exports) { currentVersion = "1.10"; } log(`ChatSetAttr: Normalized current version: ${currentVersion}`); - checkForUpdates(currentVersion); + checkForUpdates(String(currentVersion)); } function checkForUpdates(currentVersion) { for (const version of VERSION_HISTORY) { diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index e14f95504e..3e463943ba 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1556,7 +1556,7 @@ var ChatSetAttr = (function (exports) { throw new Error(`Player with ID ${playerID} not found.`); } const isGM = playerIsGM(playerID); - const config = state.ChatSetAttr?.config || {}; + const config = getConfig(); const playersCanModify = config.playersCanModify || false; const canModify = isGM || playersCanModify; setPermissions(playerID, isGM, canModify); @@ -1578,6 +1578,9 @@ var ChatSetAttr = (function (exports) { if (isGM) { return true; } + if (getConfig().playersCanModify) { + return true; + } const character = getObj("character", target); if (!character) { return false; @@ -1800,10 +1803,10 @@ var ChatSetAttr = (function (exports) { const errors = []; const messages = []; const { noCreate = false } = {}; - const { setWithWorker = false } = getConfig() || {}; + const { useWorkers = true } = getConfig() || {}; const setOptions = { noCreate, - setWithWorker, + setWithWorker: useWorkers, }; for (const target in results) { for (const name in results[target]) { @@ -1813,7 +1816,6 @@ var ChatSetAttr = (function (exports) { if (isSetting) { const value = results[target][name] ?? ""; try { - console.log("Setting attribute", actualName, "on target", target, "to", value, "with type", type); await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); } catch (error) { @@ -1904,7 +1906,7 @@ var ChatSetAttr = (function (exports) { sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); } function errorOut(errorText, playerid, errors) { - errors.push("No valid targets found."); + errors.push(errorText); sendErrors(playerid, "Errors", errors); clearTimer("chatsetattr"); } @@ -1942,6 +1944,8 @@ var ChatSetAttr = (function (exports) { const debugVersion = msg.content.startsWith("!setattrs-debugversion"); if (debugVersion) { log("ChatSetAttr: Debug - setting version to 1.10."); + if (!state.ChatSetAttr) + state.ChatSetAttr = {}; state.ChatSetAttr.version = "1.10"; return; } @@ -1952,6 +1956,9 @@ var ChatSetAttr = (function (exports) { } const isConfigMessage = checkConfigMessage(msg.content); if (isConfigMessage) { + if (!playerIsGM(msg.playerid)) { + return; + } handleConfigCommand(msg.content); return; } @@ -2040,7 +2047,7 @@ var ChatSetAttr = (function (exports) { currentVersion = "1.10"; } log(`ChatSetAttr: Normalized current version: ${currentVersion}`); - checkForUpdates(currentVersion); + checkForUpdates(String(currentVersion)); } function checkForUpdates(currentVersion) { for (const version of VERSION_HISTORY) { diff --git a/ChatSetAttr/rollup.config.ts b/ChatSetAttr/rollup.config.ts index 4b8d9e50c2..57c3394b37 100644 --- a/ChatSetAttr/rollup.config.ts +++ b/ChatSetAttr/rollup.config.ts @@ -4,13 +4,20 @@ 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"; +import path from "path"; const authors = Array.isArray(json.authors) ? json.authors.join(", ") : json.authors; export default defineConfig({ input: "src/index.ts", + onwarn(warning, defaultHandler) { + // Known-safe patterns for this Roll20 bundle (see Rollup troubleshooting). + if (warning.code === "CIRCULAR_DEPENDENCY") return; + if (warning.code === "EVAL") return; + defaultHandler(warning); + }, + output: [ { file: `${json.version}/${json.name}.js`, @@ -34,6 +41,6 @@ export default defineConfig({ injectPlugin({ "h": [path.resolve("src/utils/chat.ts"), "h"], }), - typescript(), + typescript({ tsconfig: "tsconfig.script.json" }), ] }); \ No newline at end of file diff --git a/ChatSetAttr/script.json b/ChatSetAttr/script.json index 59ed753c1c..921407cd82 100644 --- a/ChatSetAttr/script.json +++ b/ChatSetAttr/script.json @@ -30,7 +30,8 @@ } ], "dependencies": [ - "APISmartAttributes" + "libSmartAttributes", + "libUUID" ], "modifies": { "state.ChatSetAttr": "read,write", diff --git a/ChatSetAttr/src/__tests__/unit/update.test.ts b/ChatSetAttr/src/__tests__/unit/update.test.ts index dab8b23c6d..a30d2d116a 100644 --- a/ChatSetAttr/src/__tests__/unit/update.test.ts +++ b/ChatSetAttr/src/__tests__/unit/update.test.ts @@ -22,7 +22,7 @@ const mockGetConfig = vi.mocked(getConfig); describe("updates", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetConfig.mockReturnValue({ setWithWorker: false }); + mockGetConfig.mockReturnValue({ useWorkers: false }); }); describe("Setting Attributes", () => { @@ -461,7 +461,7 @@ describe("updates", () => { }); it("should use setWithWorker from config", async () => { - mockGetConfig.mockReturnValue({ setWithWorker: true }); + mockGetConfig.mockReturnValue({ useWorkers: true }); const results: Record = { "char1": { "strength": 15 }, @@ -481,7 +481,7 @@ describe("updates", () => { }); it("should combine options and config", async () => { - mockGetConfig.mockReturnValue({ setWithWorker: true }); + mockGetConfig.mockReturnValue({ useWorkers: true }); const results: Record = { "char1": { "strength": 15 }, @@ -948,7 +948,7 @@ describe("updates", () => { describe("configuration handling", () => { it("should use setWithWorker from config", async () => { - mockGetConfig.mockReturnValue({ setWithWorker: true }); + mockGetConfig.mockReturnValue({ useWorkers: true }); const results: Record = { "char1": { "strength": 15 }, @@ -983,7 +983,7 @@ describe("updates", () => { "strength", 15, "current", - { noCreate: false, setWithWorker: false } + { noCreate: false, setWithWorker: true } ); }); }); diff --git a/ChatSetAttr/src/__tests__/unit/versioning.test.ts b/ChatSetAttr/src/__tests__/unit/versioning.test.ts index e9c736ffb2..fb6c1097f6 100644 --- a/ChatSetAttr/src/__tests__/unit/versioning.test.ts +++ b/ChatSetAttr/src/__tests__/unit/versioning.test.ts @@ -3,7 +3,7 @@ import { checkForUpdates } from "../../modules/versioning"; import { v2_0 } from "../../versions/2.0.0"; import { getConfig, setConfig } from "../../modules/config"; -vi.mock("../../versions/version2", () => { +vi.mock("../../versions/2.0.0", () => { return { v2_0: { appliesTo: "<=1.10", diff --git a/ChatSetAttr/src/env.d.ts b/ChatSetAttr/src/env.d.ts index fbf45c837f..a1da5a1ee1 100644 --- a/ChatSetAttr/src/env.d.ts +++ b/ChatSetAttr/src/env.d.ts @@ -1,18 +1,32 @@ /// -/// -/// -declare function h( - tagName: string, - attributes: Record, - ...children: (string | null | undefined)[] -): string; +/** Module shim so `declare global` augments the shared global scope for real modules. */ +export {}; -declare namespace JSX { - type Element = string; - interface IntrinsicElements { - [elemName: string]: { - [key: string]: string | undefined; - }; +declare global { + /** Roll20 loads this from the libSmartAttributes dependency script (not bundled). */ + var libSmartAttributes: typeof import("lib-smart-attributes").default; + /** Roll20 loads this from the libUUID dependency script (not bundled). */ + var libUUID: { generateRowID(): string; generateUUID(): string }; + /** Roll20 Mod API persistent script state. */ + var state: { + ChatSetAttr?: Record & { version?: string }; + [key: string]: unknown; + }; + + function h( + tagName: string, + attributes: Record, + ...children: (string | null | undefined)[] + ): string; + var s: typeof import("./utils/chat").s; + + namespace JSX { + type Element = string; + interface IntrinsicElements { + [elemName: string]: { + [key: string]: string | undefined; + }; + } } -} \ No newline at end of file +} diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index a7dde8b346..ac86530991 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -1,4 +1,3 @@ -import { error } from "console"; import scriptJson from "../../script.json" assert { type: "json" }; import type { Attribute, AttributeRecord } from "../types"; import { getAttributes } from "./attributes"; @@ -110,7 +109,7 @@ async function acceptMessage(msg: Roll20ChatMessage) { }; function errorOut(errorText: string, playerid: string, errors: string[]) { - errors.push("No valid targets found."); + errors.push(errorText); sendErrors(playerid, "Errors", errors); clearTimer("chatsetattr"); } @@ -154,6 +153,7 @@ export function registerHandlers() { const debugVersion = msg.content.startsWith("!setattrs-debugversion"); if (debugVersion) { log("ChatSetAttr: Debug - setting version to 1.10."); + if (!state.ChatSetAttr) state.ChatSetAttr = {}; state.ChatSetAttr.version = "1.10"; return; } @@ -164,6 +164,9 @@ export function registerHandlers() { } const isConfigMessage = checkConfigMessage(msg.content); if (isConfigMessage) { + if (!playerIsGM(msg.playerid)) { + return; + } handleConfigCommand(msg.content); return; } diff --git a/ChatSetAttr/src/modules/permissions.ts b/ChatSetAttr/src/modules/permissions.ts index 70d443d9d9..46646aba9f 100644 --- a/ChatSetAttr/src/modules/permissions.ts +++ b/ChatSetAttr/src/modules/permissions.ts @@ -1,3 +1,5 @@ +import { getConfig } from "./config"; + const permissions = { playerID: "", isGM: false, @@ -7,10 +9,15 @@ const permissions = { export function checkPermissions(playerID: string) { const player = getObj("player", playerID); if (!player) { + if('API' === playerID) { + // allow API full access + setPermissions(playerID,true,true); + return; + } throw new Error(`Player with ID ${playerID} not found.`); } const isGM = playerIsGM(playerID); - const config = state.ChatSetAttr?.config || {}; + const config = getConfig(); const playersCanModify = config.playersCanModify || false; const canModify = isGM || playersCanModify; @@ -36,10 +43,13 @@ export function checkPermissionForTarget(playerID: string, target: string): bool if (isGM) { return true; } + if (getConfig().playersCanModify) { + 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/targets.ts b/ChatSetAttr/src/modules/targets.ts index e412188ec6..a9329c5f45 100644 --- a/ChatSetAttr/src/modules/targets.ts +++ b/ChatSetAttr/src/modules/targets.ts @@ -26,7 +26,7 @@ function generateSelectedTargets(message: Roll20ChatMessage, type: Target) { continue; } - const inParty = character.get("inParty"); + const inParty = (character.get as (key: string) => unknown)("inParty"); if (type === "sel-noparty" && inParty) { continue; } @@ -132,7 +132,10 @@ function generatePartyTargets() { }; } - const characters = findObjs({ _type: "character", inParty: true }); + const characters = findObjs( + { _type: "character", inParty: true } as + Partial & { _type: "character"; inParty?: boolean }, + ); for (const character of characters) { const characterID = character.id; targets.push(characterID); diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts index 01a8c56f21..e16c70695c 100644 --- a/ChatSetAttr/src/modules/updates.ts +++ b/ChatSetAttr/src/modules/updates.ts @@ -20,10 +20,10 @@ export async function makeUpdate( const messages: string[] = []; const { noCreate = false } = options || {}; - const { setWithWorker = false } = getConfig() || {}; + const { useWorkers = true } = getConfig() || {}; const setOptions = { noCreate, - setWithWorker, + setWithWorker: useWorkers, }; for (const target in results) { @@ -36,8 +36,6 @@ export async function makeUpdate( const value = results[target][name] ?? ""; try { - console.log("Setting attribute", actualName, "on target", target, "to", value, "with type", type - ); await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); } catch (error: unknown) { errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); diff --git a/ChatSetAttr/src/modules/versioning.ts b/ChatSetAttr/src/modules/versioning.ts index 7e79a50055..c46086cf4d 100644 --- a/ChatSetAttr/src/modules/versioning.ts +++ b/ChatSetAttr/src/modules/versioning.ts @@ -26,7 +26,7 @@ export function update() { } log(`ChatSetAttr: Normalized current version: ${currentVersion}`); - checkForUpdates(currentVersion); + checkForUpdates(String(currentVersion)); }; export function checkForUpdates(currentVersion: string): void { diff --git a/ChatSetAttr/tsconfig.json b/ChatSetAttr/tsconfig.json index ff64fadb7a..c7fbe04532 100644 --- a/ChatSetAttr/tsconfig.json +++ b/ChatSetAttr/tsconfig.json @@ -9,6 +9,8 @@ "skipLibCheck": true, "sourceMap": false, "jsx": "react", - "jsxFactory": "h", + "jsxFactory": "h" }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/env.d.ts", "rollup.config.ts", "vitest.setup.ts"], + "exclude": ["node_modules", "src/__tests__/**", "**/*.test.ts", "**/*.spec.ts", "**/*.mock.ts"] } diff --git a/ChatSetAttr/tsconfig.script.json b/ChatSetAttr/tsconfig.script.json index a9e57fb352..810e7c1f9b 100644 --- a/ChatSetAttr/tsconfig.script.json +++ b/ChatSetAttr/tsconfig.script.json @@ -1,10 +1,21 @@ { - "extends": "tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": false, + "jsx": "react", + "jsxFactory": "h" + }, "include": ["src", "rollup.config.ts"], "exclude": [ "node_modules", + "src/__tests__/**", "**/*.test.ts", "**/*.spec.ts", - "**/*.mock.ts", + "**/*.mock.ts" ] -} \ No newline at end of file +} diff --git a/ChatSetAttr/tsconfig.vitest.json b/ChatSetAttr/tsconfig.vitest.json index e2876f1f05..cf58abcfc6 100644 --- a/ChatSetAttr/tsconfig.vitest.json +++ b/ChatSetAttr/tsconfig.vitest.json @@ -1,9 +1,10 @@ { - "extends": "tsconfig.json", + "extends": "./tsconfig.json", "compilerOptions": { - "types": ["vitest/globals", "node", "vitest.setup.ts"] + "types": ["vitest/globals", "node", "@roll20/api-types"] }, "include": [ + "src/env.d.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.mock.ts", diff --git a/ChatSetAttr/vitest.setup.ts b/ChatSetAttr/vitest.setup.ts index 356bb35015..4767ce0ced 100644 --- a/ChatSetAttr/vitest.setup.ts +++ b/ChatSetAttr/vitest.setup.ts @@ -11,12 +11,9 @@ 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 @@ -62,7 +59,7 @@ global.sendChat = vi.fn(); global.libSmartAttributes = SA; global.libUUID = { generateRowID: vi.fn(() => "unique-rowid-1234"), - generatelibUUID: vi.fn(() => "unique-libUUID-5678") + generateUUID: vi.fn(() => "unique-libUUID-5678"), }; // region JSX Helpers From a04568de719df124799f847eac9426906cc3e8f8 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Mon, 1 Jun 2026 10:19:36 -0500 Subject: [PATCH 06/38] interim checkin --- .gitignore | 1 + ChatSetAttr/2.0/ChatSetAttr.js | 62 ++++-- ChatSetAttr/ChatSetAttr.js | 62 ++++-- ChatSetAttr/src/__mocks__/apiObjects.mock.ts | 2 + .../src/__mocks__/beaconAttributes.mock.ts | 79 ++++++-- .../integration/.legacyAttributes.test.ts.swp | Bin 0 -> 147456 bytes ChatSetAttr/src/__tests__/unit/chat.test.ts | 24 +-- .../src/__tests__/unit/message.test.ts | 164 ++++++++-------- .../src/__tests__/unit/permissions.test.ts | 176 ++++++++++++++++++ ChatSetAttr/src/modules/chat.ts | 12 +- ChatSetAttr/src/modules/main.ts | 27 ++- ChatSetAttr/src/modules/message.ts | 15 +- ChatSetAttr/src/modules/permissions.ts | 14 +- 13 files changed, 483 insertions(+), 155 deletions(-) create mode 100644 ChatSetAttr/src/__tests__/integration/.legacyAttributes.test.ts.swp create mode 100644 ChatSetAttr/src/__tests__/unit/permissions.test.ts diff --git a/.gitignore b/.gitignore index 9543c83356..b39de550b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .idea **/node_modules +**/coverage/** diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 3e463943ba..56e97685eb 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -161,19 +161,22 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); } + //import { s } from "../utils/chat"; function getPlayerName(playerID) { const player = getObj("player", playerID); - return player?.get("_displayname") ?? "Unknown Player"; + return player?.get("_displayname") || undefined; } function sendMessages(playerID, header, messages, from = "ChatSetAttr") { const newMessage = createChatMessage(header, messages); - sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + const player = getPlayerName(playerID); + sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } function sendErrors(playerID, header, errors, from = "ChatSetAttr") { if (errors.length === 0) return; const newMessage = createErrorMessage(header, errors); - sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + const player = getPlayerName(playerID); + sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } function sendDelayMessage(silent = false) { if (silent) @@ -1285,12 +1288,16 @@ var ChatSetAttr = (function (exports) { } // #region Message Parsing function extractOperation(parts) { - if (parts.length === 0) - throw new Error("Empty command"); + if (parts.length === 0) { + log("Empty Command."); + return; + } const command = parts.shift().slice(1); // remove the leading '!' const isValidCommand = isCommand(command); - if (!isValidCommand) - throw new Error(`Invalid command: ${command}`); + if (!isValidCommand) { + log("Invalid Command."); + return; + } return command; } function extractReferences(value) { @@ -1317,6 +1324,9 @@ var ChatSetAttr = (function (exports) { function parseMessage(content) { const parts = splitMessage(content); let operation = extractOperation(parts); + if (!operation) { + return; + } const targeting = []; const options = {}; const changes = []; @@ -1553,13 +1563,20 @@ var ChatSetAttr = (function (exports) { function checkPermissions(playerID) { const player = getObj("player", playerID); if (!player) { - throw new Error(`Player with ID ${playerID} not found.`); + if ("API" === playerID) { + // allow API full access + setPermissions(playerID, true, true); + return true; + } + log(`Player with ID ${playerID} not found.`); + return false; } const isGM = playerIsGM(playerID); const config = getConfig(); const playersCanModify = config.playersCanModify || false; const canModify = isGM || playersCanModify; setPermissions(playerID, isGM, canModify); + return true; } function setPermissions(playerID, isGM, canModify) { permissions.playerID = playerID; @@ -1570,6 +1587,10 @@ var ChatSetAttr = (function (exports) { return { ...permissions }; } function checkPermissionForTarget(playerID, target) { + const isAPI = "API" == playerID; + if (isAPI) { + return true; + } const player = getObj("player", playerID); if (!player) { return false; @@ -1839,12 +1860,17 @@ var ChatSetAttr = (function (exports) { log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); } function checkDependencies() { + const errors = []; if (libSmartAttributes === undefined) { - throw new Error("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + errors.push("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."); + errors.push("libUUID is required but not found. Please ensure the libUUID script is installed."); } + if (errors.length > 0) { + sendErrors("gm", "Missing Dependencies", errors); + } + return errors.length === 0; } async function acceptMessage(msg) { // State @@ -1857,14 +1883,15 @@ var ChatSetAttr = (function (exports) { startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); // Check Config and Permissions const config = getConfig(); + const isAPI = "API" === msg.playerid; const isGM = playerIsGM(msg.playerid); - if (options.evaluate && !isGM && !config.playersCanEvaluate) { + if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); } - if (targeting.includes("party") && !isGM && !config.playersCanTargetParty) { + if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { return errorOut("You do not have permission to target the party.", msg.playerid, errors); } - if ((operation === "modattr" || operation === "modbattr") && !isGM && !config.playersCanModify) { + if ((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); } // Preprocess @@ -1927,7 +1954,9 @@ var ChatSetAttr = (function (exports) { } function registerHandlers() { broadcastHeader(); - checkDependencies(); + if (!checkDependencies()) { + return; + } on("chat:message", (msg) => { if (msg.type !== "api") { const inlineMessage = extractMessageFromRollTemplate(msg); @@ -1965,8 +1994,9 @@ var ChatSetAttr = (function (exports) { const validMessage = validateMessage(msg.content); if (!validMessage) return; - checkPermissions(msg.playerid); - acceptMessage(msg); + if (checkPermissions(msg.playerid)) { + acceptMessage(msg); + } }); } diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 3e463943ba..56e97685eb 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -161,19 +161,22 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); } + //import { s } from "../utils/chat"; function getPlayerName(playerID) { const player = getObj("player", playerID); - return player?.get("_displayname") ?? "Unknown Player"; + return player?.get("_displayname") || undefined; } function sendMessages(playerID, header, messages, from = "ChatSetAttr") { const newMessage = createChatMessage(header, messages); - sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + const player = getPlayerName(playerID); + sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } function sendErrors(playerID, header, errors, from = "ChatSetAttr") { if (errors.length === 0) return; const newMessage = createErrorMessage(header, errors); - sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + const player = getPlayerName(playerID); + sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } function sendDelayMessage(silent = false) { if (silent) @@ -1285,12 +1288,16 @@ var ChatSetAttr = (function (exports) { } // #region Message Parsing function extractOperation(parts) { - if (parts.length === 0) - throw new Error("Empty command"); + if (parts.length === 0) { + log("Empty Command."); + return; + } const command = parts.shift().slice(1); // remove the leading '!' const isValidCommand = isCommand(command); - if (!isValidCommand) - throw new Error(`Invalid command: ${command}`); + if (!isValidCommand) { + log("Invalid Command."); + return; + } return command; } function extractReferences(value) { @@ -1317,6 +1324,9 @@ var ChatSetAttr = (function (exports) { function parseMessage(content) { const parts = splitMessage(content); let operation = extractOperation(parts); + if (!operation) { + return; + } const targeting = []; const options = {}; const changes = []; @@ -1553,13 +1563,20 @@ var ChatSetAttr = (function (exports) { function checkPermissions(playerID) { const player = getObj("player", playerID); if (!player) { - throw new Error(`Player with ID ${playerID} not found.`); + if ("API" === playerID) { + // allow API full access + setPermissions(playerID, true, true); + return true; + } + log(`Player with ID ${playerID} not found.`); + return false; } const isGM = playerIsGM(playerID); const config = getConfig(); const playersCanModify = config.playersCanModify || false; const canModify = isGM || playersCanModify; setPermissions(playerID, isGM, canModify); + return true; } function setPermissions(playerID, isGM, canModify) { permissions.playerID = playerID; @@ -1570,6 +1587,10 @@ var ChatSetAttr = (function (exports) { return { ...permissions }; } function checkPermissionForTarget(playerID, target) { + const isAPI = "API" == playerID; + if (isAPI) { + return true; + } const player = getObj("player", playerID); if (!player) { return false; @@ -1839,12 +1860,17 @@ var ChatSetAttr = (function (exports) { log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); } function checkDependencies() { + const errors = []; if (libSmartAttributes === undefined) { - throw new Error("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + errors.push("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."); + errors.push("libUUID is required but not found. Please ensure the libUUID script is installed."); } + if (errors.length > 0) { + sendErrors("gm", "Missing Dependencies", errors); + } + return errors.length === 0; } async function acceptMessage(msg) { // State @@ -1857,14 +1883,15 @@ var ChatSetAttr = (function (exports) { startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); // Check Config and Permissions const config = getConfig(); + const isAPI = "API" === msg.playerid; const isGM = playerIsGM(msg.playerid); - if (options.evaluate && !isGM && !config.playersCanEvaluate) { + if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); } - if (targeting.includes("party") && !isGM && !config.playersCanTargetParty) { + if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { return errorOut("You do not have permission to target the party.", msg.playerid, errors); } - if ((operation === "modattr" || operation === "modbattr") && !isGM && !config.playersCanModify) { + if ((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); } // Preprocess @@ -1927,7 +1954,9 @@ var ChatSetAttr = (function (exports) { } function registerHandlers() { broadcastHeader(); - checkDependencies(); + if (!checkDependencies()) { + return; + } on("chat:message", (msg) => { if (msg.type !== "api") { const inlineMessage = extractMessageFromRollTemplate(msg); @@ -1965,8 +1994,9 @@ var ChatSetAttr = (function (exports) { const validMessage = validateMessage(msg.content); if (!validMessage) return; - checkPermissions(msg.playerid); - acceptMessage(msg); + if (checkPermissions(msg.playerid)) { + acceptMessage(msg); + } }); } diff --git a/ChatSetAttr/src/__mocks__/apiObjects.mock.ts b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts index 349ee17539..8f0cf89745 100644 --- a/ChatSetAttr/src/__mocks__/apiObjects.mock.ts +++ b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts @@ -1,10 +1,12 @@ import { vi } from "vitest"; +import { resetBeaconAttributes } from "./beaconAttributes.mock"; import { debugLog, debugWarn } from "./utility.mock"; const allObjects: AnyRoll20Object[] = []; export function resetAllObjects(): void { allObjects.length = 0; + resetBeaconAttributes(); } function createRandomId(): string { diff --git a/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts index cef967280e..3f7d1a7f6b 100644 --- a/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts +++ b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts @@ -1,3 +1,5 @@ +import { mockFindObjs, mockGetAttrByName } from "./apiObjects.mock"; + type MockBeaconAttribute = { current: string; max: string; @@ -12,21 +14,59 @@ type MockCharacterList = { export const beaconAttributes: MockCharacterList = { }; +export function resetBeaconAttributes(): void { + for (const characterId of Object.keys(beaconAttributes)) { + delete beaconAttributes[characterId]; + } +}; + +function readLegacyAttribute( + characterId: string, + attributeName: string, + type: "current" | "max", +): string | undefined { + const legacyValue = mockGetAttrByName(characterId, attributeName, type); + if (legacyValue === undefined || legacyValue === null) { + return undefined; + } + return `${legacyValue}`; +}; + +function writeLegacyAttribute( + characterId: string, + attributeName: string, + value: string, + type: "current" | "max", +): boolean { + const legacyAttrs = mockFindObjs({ + _type: "attribute", + _characterid: characterId, + name: attributeName, + }); + const legacyAttr = legacyAttrs[0]; + if (!legacyAttr) { + return false; + } + legacyAttr.set({ [type]: value }); + return true; +}; + export async function getSheetItem( characterId: string, attributeName: string, - type: "current" | "max" = "current" + type: "current" | "max" = "current", ) { const character = beaconAttributes[characterId]; - if (!character) { - return undefined; + const attribute = character?.[attributeName]; + if (attribute && attribute[type] !== "") { + return attribute[type]; } - const attribute = character[attributeName]; - if (!attribute) { - return undefined; - } - console.log("Getting attribute", attributeName, "on character", characterId, "with type", type); - return attribute[type]; + + return readLegacyAttribute(characterId, attributeName, type); +}; + +type SetSheetItemOptions = { + allowThrow?: boolean; }; export async function setSheetItem( @@ -34,16 +74,29 @@ export async function setSheetItem( attributeName: string, value: string, type: "current" | "max" = "current", + options?: SetSheetItemOptions, ): Promise { - const character = beaconAttributes[characterId]; - if (!character) { + const hasLegacy = mockFindObjs({ + _type: "attribute", + _characterid: characterId, + name: attributeName, + }).length > 0; + const hasBeaconEntry = Boolean(beaconAttributes[characterId]?.[attributeName]); + const isUserAttribute = attributeName.startsWith("user."); + + if (options?.allowThrow && !hasLegacy && !hasBeaconEntry && !isUserAttribute) { + throw new Error(`Sheet item ${attributeName} not found on character ${characterId}`); + } + + if (!beaconAttributes[characterId]) { beaconAttributes[characterId] = {}; } - const attribute = beaconAttributes[characterId][attributeName]; - if (!attribute) { + if (!beaconAttributes[characterId][attributeName]) { beaconAttributes[characterId][attributeName] = { current: "", max: "" }; } beaconAttributes[characterId][attributeName][type] = value; + + writeLegacyAttribute(characterId, attributeName, value, type); return true; }; diff --git a/ChatSetAttr/src/__tests__/integration/.legacyAttributes.test.ts.swp b/ChatSetAttr/src/__tests__/integration/.legacyAttributes.test.ts.swp new file mode 100644 index 0000000000000000000000000000000000000000..7d8bb4f54462a4a7a9199209152d1860534b1079 GIT binary patch literal 147456 zcmeI531D1B{rH!|13?ryC~2Vude|0~r2a>$_~Ajk~@f*^>BigJ44E&M;r*|%kKjGy<$V%c+cSpMO|H;-*N5R2t|lY`4w$QPA=(y8MHQpsc@ zH_WpqssJY~%%;q4*QAb)f}1boJ31z% zG6lCMmnfvNnMrB4C(*T99!hl%@IZ?^)KbV-PG95OMhP@ZKuX}URVEMd?#n~ z_Z`_NmPQFQN}y2!jS^^-K%)d2CD15=MhP@Z;4M@Fh5mT#B=Wny&U1_Yy0s!;NRxCw}+npaOm&1>AyGCzn6x-e~12iPyIU;`hFw*_j~m3FNeN=r~dmu{rig0 z_Zx@)em?Z~yY$~%>fbMhzTZUu?dMx(+~nWn%j9En==*o;_f7fj6Z+odYX|*YXXyLQ z^yippyq^<8-+jDBeZRT>Tz?$9H}w7c^!KKm?+<;yh5mkq{{2|!`>jIZZ>1ZQ zek^9n)ueOl(D(1x?;oUpH-*06Mt^SFVNdA$twZmpL*H+!zc=}~BJ}Qoe^akV>*sa|eedYc&GUuO_dDwEO*yR$eZQ0beyaX` zX6XA5>d#F(y*2cGTz|hz|9&X+z5X+Hl>UCAt_YJ}{c+=0qXZfy&?tdM2{cNeQ38z; zXp}&s1R5o<7MDOG8H>G&(t3v|E+}V4|GyrE^b>Fp{1c_&oA3?zI^^LPI24-T9q=*= z?jPX~@FTb!&Vu9M7+3;F!Orj=_y-F0GjKDU0w=+Vumsv+57-?3i$eA+JPl95<&b~{ z@Hk5Qk?;sg>k%M%SO^=yP2}lDxB|Wi8JGUMlTfIa|O(TIQ$DG=@GaBPKVRL=+CBUIQqw3 z)$eu{nwDpKGc(;)6Iu${gWaZh>*V-^{bH4#ndx??GH$Y|^kZ`qt7b1Sfwk>jCNS%% z%E1L;wU!IZJYogpr@t$k$rl`*MQ6H`7)+!JPCC`Os4tQ8TtKvUM~`v{@&0Ox%L}dX ziB6n?v2zdv=VhRV0B0|dVEYnff}Opf<}kYkayd6s2$HMvx-!)wc|IhYYf9u-XS$rG z3C{EboHa3BM(!%N3;&k(Y+qj@lWdCbly?gWyk4B<=1ii`bq=E_`1kAuL#=xcwe8J6 zDU%`9N^}riIX6*o=XWk|ig)!Ua*3{jnOCmp;9riTC4vOAcrd%DKzr&b^qS7xIyGopm7leGVn~ok(DO5H z)j>y|wSo?H@=&U)??>{Yw4%64m7Pu1<3q_FS=gQ+iH1E)_f!QpX%e-UcXI{PUHz`% z(3bLew9(n{@zrV)m6Obq<(U4f6mP#;_}nH zmcDpUm66rYL~wZ`+CqeME%ACk;e((mu4dx~gqX-QUrY+MSoz|(|AYnRiFk0kET=5v;I7v6(MOR`%=)1r=IL#JPnI1>a734K* zGGPW1>7#`i>2!M{o$gF@t;m~+qWOY}0#eKWmzJ z`vn1-4&e8UvO&2kscWxsk-M_t=#Er2lth0@KN1RJce=Y{mx!IAzHD+J?dB)-B~lqX z^`|7Lyf0rdX+;dq!2 zyTI?z^B;nrz@2a&48Zf~^FM?y!l&W?;6yk97Q=?{AiDema4lQ|XTer*33_}R{0Ke% z7Wf1l3WvaJ==2xDsc;G$1lz&Y==X=h)9CL{!8LFuBtdlhE#Vb(`QO2B;oBg3{&6rB zM8|&-ZiO4+23QUYU@Q0v`u!mM4}Jbs_%e zhb!O`xB#S#@8Fs0ew64o0^Ix#1g67}HSv@$=yAbMCY#80^`=%9$rkmsQYl)pp3Fx` z6lhhn>83TY;Prt_QkfK#;3;}hb?~`~0^#%8q;!@6sK9_ABbuJIPU@LpzZth$wm-r3 z6&5CtNqIoDn%Rk!?!m5`kq+r5m!t~4O))dl-p?3MA$mV1saij>gE-h#FN?g-H?>hp zCV&Sb0Yql}!4M`fFnHt!G7N*oV~01sEfKSYsCY6cWU_@+cdEky5UD3pZD`LnQFcs{AB-fKp>>ur5I70Mzh@&L4T*xNpLxfh{N|l(#_1bK z7gA`o*32TNitYscyJ+UzWhQ>X+~`>OZ09wp6OJ{r@nfkgHCuP|=LqDoRs)lfhawq~ zvTI5+u%;O0TleGF{?nbQlljLUdrV-^Qt2dYCJkzIs^cu~r;+4Q_cB^wRnwCbm4-R_ zL+4KLzFFuN26CCB7{c8q8KeE~SZ>N;O$5F6vf4>>W(NvP>@X?hoxxr=FcoX~IXj zIVLw=-^pY1l+NSy)2#Yjo}=k@TVZ(#V3(K5f#2h;sG^cXl96wkLU#3QL23DNM_Tq2 z>2t=MqPm`nrrcAOwWGv?ZB+$lJykil)~bTDp0a||`8HETKeJR*l_pp37200h$R=Ab zJsjyUc1&IlM;4Q3?!M96d51FN}03)(f8;|h2ETN3tC58 z1-+T$08gxz`KvVfRL&C9rh%3nYbp#KQbSm0f7bRye zF{$y=YJJT6M!Mt7Y-YEDGnha-FZ8DJ(u8^w{r!xusf;(~WZXeVtSS8k@1<0sCBGuo z?;G;37P%^)RPb4dP zq$92A)v832W~3@*_m4hjjRq2){HRW9KU#zA#O%xR5_6P}K{wG)JLzany4`zD-K)b3 zSoMyiJz6{&spV=VYtb}YjM@Gio4GGD+k$K4k2SRk?C?%7&$Z{=%IqG^$Ih)qSr{sB zjyLn#Uqo_d!zzLeRWri!$3noWaGU>X|?#s4Q-w^091an>b1~Rnq}-0{r`D%;>*yPMgQMjYgZ?u@2?LxqTk;D zm&3{MVVDBD!vxp{{)T=pI{y~1Is6vA{s#CYEQ3XGI2;BWz!~WKJHoYE&!5HbSJ3NU zhPz=I{005~Ecguc!YtSx{!Tg{gv-H8vrcd0SEB?PCD15=MhP@Z;7wWrYWn6+3dOMo z9}_blY`BQAEGlK<>Gl+iJ^6f@xmwt6tY%5_#+bN`nc3hg6{8v(S27dVpCWM$GpCNJ zHdMi$Abw62-OEhef41V*!B(LYi^8p4sj721GtvFUicV!>nAoIZEe%}$tWw$kYb{Y( zs`p{lNA-$NB35eD&D z9(E&(&l7&7;`>J=zF|a#>SUsxz7ei&r1m5c*#C?qTPe)Ns;=>#S*#rGjK2MbxW6f{ zwT}Eir>v^rsNreh#ok)~isVXFz4vIuS*ncpom6$$`%ZE!u49#>|F;#r=>L16lb;Hr z|D(=(KWCu(FNMjlD|`TUfJb>?DeMH_LD!!S_ww#NI*g8Iy z{hQ&_a4Z}J^I$H>djFSTKlm-W{1Z;GoShgriP zf;BJ?=0X$vhc*3w!yPaSZf70;KoFn(R=A87cPZpy9yEdZQQg6xeBnOPx5Q2L^cKwg zYf50sE_$jxJCN}oYAy4S_^_CO+SA#@3R(V|wW`Zy2|Ux~%3Cp$0@YL`0qrchiu=)s zmUBD_fmc!ve<@?bOJ&5hC7WB_(L9jhFh{pJmmN$cn_Jta?AakP5vRmtr#?uA=M0pR zHMhQZ71NAQM&R)!Ao3>(O-$YcjI@=tYs>Y>Uh zducrI@QyDFxpN!m5G=sTO=JNZZ1OG3v#Cr|+zGm~|C&Q#HTwnFVF`xzg|lWXo@Gme zp{9k>6>y+)Sfn2CTp=gSaQX*wp1Vn+-^Jrxyi&SdH=TES-NZ_k`&r5p7s~8_IHz%l zEBk(sqVjncF0-p!oH+$nYVuA`Cfk?Fi^u4yg5&me=CUc=fv}Dzl)p_EdsGs+mbhPy zRV3UOA47BKLC#`tfL@Z#By-t;JRWpz-$Z9dyR%2zL}yWArFdX0?nM&jojs76)kb8* z!_Wxc2|Dx;8d9|}$y;6@9BEY2Z-z>ZGeuE%cQ$LePcgW7^G3!c<#Q?Z+0`qKy`A{8 z1S@7O#W^83nn2RcIWwfyNa@Z^^x*U%brE^Af1oohI}Yr!ppV!em`zdsXR0UJEuGH4 z)O2)9U#xOQQ%tqe$C{I#B_PI>Yf#ujJtpB*T*;IonzESPFsxV2O!;%3kK@KU$n|f^#A4P^{0a9|3>ANza7{J zeu=IxdjEZJFMJ$!fM?PDPl4UwZqfJQTsQ}Eusz&~&fgDNI0&|Y-=XW@2xq`YVKE#5 zhr@r-{m+4Y;Wy~{zlI;fRd6Ak4$ELuxC_1ibFd61z>m=V-vO_o>puj?!)iDJ4u@Ug zU+De+6x|=%LC!&&4mYCzPl9hz0N;e;;0V|i%nZV>N*Ns++COgFltV-NH-m1_Dk_YO zEyZUVi0~0nJyJtRL;H988rpxVtD*g?IaoDji-z{E%r_0~zvO8KB8E|RWk0BQnn6SR zZ)pGCSxnv--O&C+#W3jLW76ko5y|jNd%2W}0O`3kyq1U9< ztI*e44PKJB_G;^@@XQpyNQWP>W-dNRR{O9t^YMwrn0HHo*0_jaNKi~hf-sJr~U zj^6(y^uycX%jo;(LmCc(&ER+F{nx`8@G)2ggU|%uLHC~y52Nq@6t0Df;4D}LbKy1g z{To2`{LA|PHgFYs|3A>}UxpvSQE((|0h`0i==oQIoa;9T&!X?oh3nAmcYu@7>3>UJ zo`oynbnx<~veo!+qXfpe1We!7%M^i?z&L!oRXj(G`1!ddeoWygqvsoGPco@{qGIaC z-^0YD>&c4g8h@`7ldfM>OxJjOqL@@YRWVib#??E;r0MChY4X=tJy$2@6=UJCVxs+J zB-)zh-sQYlOf=6}(Zp1@R-Jn6JX=guzp|pTV`_N2bTnITIt_bQ!`>yw|Bj8fi|Ks7 zj&{D`Pa3XCZ#6OgCK_XXy<$wn&xRtlwA6;DOzFeMLq;Mq#kIXW{8iMF&(_~l#?Wlqh9dX9fEHXpH02RUZgNY;{#Oeg>Y`&YYQfC&^B-t^!A7;+`!XIq1JN zbX2ZQO3bj%buD>1O7TPGdzE=^WPQqO>vKtm-QFGyl}L?S3gdIwEysqdgu~If_^``; zLuNY^gOWXl@uwt$%b|AKX(vjOW8u`k^d2|EQF`g(o#_u*>5F-dv+k|)r&g3Y#_?&L z4~HVQTBkYvF(JoArny)Rxu=fVmUACDLDJji#$dxi&50hjh6c*eBpdd6y= zs+4>?3a7E#L6y7_s^}RjDDV9Lm(jrwMlTorAGO~55#9eJ_!+vr`261vx4~(!DSR3I z{(RUM_5nHf?{dPBa9$$37Jk1AKR zzSirC9AHga=g3(sBRZ?7bil@U)r9LEMknk4Gqi3l`u~<%|Gxm;zYSVJ*7|>e&VMz0 z3X&ju0Jefx(EDW{z|-(JJO<(iun^t_pT!2SBZ%%lA2x#Tpy$trKcdfn0(OAy;av3k zb07y>!w=Et0Lb2|@78`|s)3v|J)SM6HmnTIY zTy!mm(@_3$?W?}{SyjX6VkwGZsxkye!u6BO)8=40&&9I>J2-z=#k%jD;I4XziXK-X8duGFE$TbROY*<<-v#mh)X=`rHWlh#8 zo742YORd($OB_ilheWqUKbm5Fv@Ci+r}x?v@@~o8GM?2^O_7rXR2zs_amAI;6 zkt&PCA|F;6OKVlJNcBZxkq;}z5-e!@vXxgDKK#)nm1HpUdN^O`al~&W_R!}G2_$-&z7_yW?IAkT2tn~RAR{8g{Z6&MSA3o*7Du&OXbjLJwqY&N{(#sfG zhL6!Ij37VqVWs?-7;lcoSYOMyiTL}Gh&f(!x+fDXCMv8Hm3#%CE-g1jN2eaE6&k zI&-~qu=Qor?P_US-W}=1f)a6Cb?b=sW7gnSY8W=B;Z<0bB($>gC3~dyjMrwaFnV7r zwGwN>tLz18KWk;U#AvRHW@N*nboF>iFVG)E+N7D<>DsC-B4#b7)&J0nLG=G2^!sx` z^nat~%HK0V_Wr*OMF0N|oB|!N4{QO?qvPKWH^S9$Ia~$>*cE<>?!OwggCC&#cfi}= za`b;W@9%f${U^aTur+)hz5jEN0on6^6FR^6_RBf{vcCT=koEoR;B+_|mVm75{|P<+ z7U+Z}&;mQd_OKm1i0;1}Hi2u=`RBo0cpaVpHFyF}g=V;ig18&L3Ma!7m;(HSo4l{G z>2}r7{k?1KJ(V|KZr@6vIG32msuCSpJD0I0u0suitZ|yz(ET}Yw8@*FmAXs0romoy z8>Ok#&9*F&Sa#%w?%&Y;X%Y5}!c4=enSS^xua@i$-M3vm?%CgPrVjhZ^)8)fT`m-KVhu6;QYi(G& zrvr}D(CGu&TRa$As=uY;s%8~_(rC?PJ&oV8I=v|`HMjM&^g4JMj^1<8=>NBb^#6aO z?>>wkEc*XsPsL?#KRUmh|NmWd`~%>}=<s*>zJpEeEYg3-%M#}+3Xia4T?;$ z$;w3bjo~7zJti5oO*G$(L^HmoEt8IKS?P$W!nIb1hFy)br;2`E$Sijtm*d!V=HxQx zEp~M=QD16BJr*$+MXNOd6W3*x;wm?Xw=?Ij>B)zc=I|!Q%cC)l&l=bjp@zQ&4LH3}={HfqNj8_PHgNfQ`>DbK`wZKas&V%{?`Ul)xzFhC7E z0kG^`e!I7ae_vR6PB|vd>qix*W&J7{r!fPX*uFQa*sNJV>DaUfuZiu3O0kujYFNeC z?oTy_GZ&f|Z?s~R_5Z2p)~|x-|GR5tN%a5i;9Kba--Jc55Srm3>;QSl!2*~9o5A0( z1w0SGfnUQHK<)s%4BNmya0j-4c_3%~zkm(kdAI|5VPm)iz5i=)G9=+(*acpbHGhz^ z{_cf)AOn)-vtcg0f<546xEwBnQ(%AiD>i|@z(a5id=>^^1$2Sz`9B84cJK(cgAD8m zk6{y81v_b9fE;W8awp)y$_KzwDOJCZoG6ua)6yC6Q53J+=H{Mk+PkRLNT?C?8LLI* zQ|XnQTW0r|sWp3qTm8aIogHf16YpUyxrC9-O@V!(#eza;xn;I`=8B}FvXEx4xU-ajada}6kce3{dzW8!e85b zSAJUBYu;`x@w)ladR=!w<5bnxU&AD7Zi%xRdTR;DKJ=l+ITyYvom$dJJ({!Y5ILMp z*E*Tsa~BLXs{@cmCw$1YEh6^euVPI-((Q(w4p{xqbB62ELDX0E9hXSVX#06iD&PAntIyfcvZ+)C-UJ4qcQlGG7I`PG>Md$GbjH70>qysS81Nc5#L ziSnJT=mD$CNXYV|^;FX*n@agZm@mt}Zu(@&*UgvK>oG5G{-flkPGk+ec9F`zt!I59 zR5=orie?mY!PNX0%LYCQSz!V{ClYu)<%jH9*2Oo}Ty%ooxmG%2QhXF(Fz${-0GR5r zWsJy&Vi{xd|0OHlQAHb)YA6GKlp&+wd1Do&5_P`GGl-GR;6-}R`ITbyM{N>pyAoJ3 zwfZ@YDb_DXW2~<%HW7a%6!8efq!|Tk5Q|1)HMO?+Q?!Vzv*eL#ZT=HRV$~?l#z4+o z5Gp#a4|=sW26E=YNZ_^0nX=Oq5ic@J@vg!mLhtN{i>#Pqb?V|cGZb~Wi>)|E6=kT4 zGyO7BCEUlqS}Dr9^zpA*F^c{#cl$jAqW_~-dq0n$`(F+h!@LBi;JP9YlHgGd`fo1SIc7WI5X1EEy07t+Eus(<%!R>G{ z$h`n-AP;?zhRGoJ37!Zu;rG}CP6s(F;7RNOABU4c&i^yp0~+=Kf2*4{9yRO%4SRqm z$}8$A{(xLUp*P=EGai?C-3->&>t1?9){lDLFM-I93TPB#uL`F2arRAQ4=6E79T^&9d=CY0jjo5*B!;2yf~n>S_VXQ`j~F?_0-=-6~*wn z`lx~rP1>V+c_j2PouBK_>#J`|Bn{oV;FjH)x|Z4YtJ9fdy6x1~bIW%tqaTkdc-^gZ z81--7MvT{O*hYW$EEJbminQdeV!tXj=QBwhr8U3H_n(~WU*c8Df>q)}@snZW)x zoC~3e@l&b(t15+qF1Bas#+ZGkM=zG zP5GxoiR#BE6`@_C}xk@qCMbzb^F^-R@D}*ApM4i_J z?OhPoQi=L*)QcrXmNkuD){S!0!y6{+20|4urm}8j*}%s@)(u7i9~W7-%8GW(Wu5T` zE_Od&ZACejLND-S2=8@1eym~)$vjKUhK1gmN>SED=nX|<93P=~LMY-&LXZ0c*rS=s z$$pnq-$0+^UGSr~g!!X%4f`7seSKMTcpr5U-Qy5EU`@7E^E1;Sd8CFNS2K9co<5uy zDi6o0u_p&}7IhDdL^QOE{I`SR+l5kEVS>mT_AV=T>+=TG}sDW#SZWSJPW^pPrhb!T0a6X&|hr#c#3!DZM;2!J%at1&F z_J+M+Gq?*oz)7$dOoh$i@7P5ih3~-^U;tJ?7j(kr@NMiT3&e&3kvbexfu=*Rukeyc zEJ39WxD7?r%0?_0>4)j*T)D z(@khh6`yfwmcat;m^ON}Bu_j1xTP-HWmI?f@=t^!wkG9jbQPUX?j1}O^2JsIL`v~7 zPG03!+;k~L*Sg3%Ud$K^I9V)eEQma3jc=-S{N*8ewCzyNoKgWEnMnpy=n{{%mU+}m zyF9OE+g1WDgMbL=>S3gMUM@Hw+0?w2^{sCK&?Bi`j!dybuVIG?j1m*o{X zZHqoN(koPZ`r4_E#oXmyLdyA~OP`#j@+~j41#dLP7qpiUv?ZUlgl+k-TSpMK^=zrI zmB62zELduknoktaJD2vqxu3nS~JYic+3|>ZSiNO=J(HMpktTK$qNa%JA z6kL5cVpDvlV6Mi&iz*v=3?nA|A=MOO-pj-FdB2GD#ZiaObtD`k8YpAS*EC)vSt?JN zii=l=z95}g?dF0QoZ&oZtI9(Vm8GghqVj}fQFhdO2vh6GyS3I$R!YK>WwZf%wYxi; zb5wSkgDSJm(8|hq6#U^cqGuH|O`6?ur5&>=Y^G)%2 zB?VWBM@xwoEfUq!oM;{-q;kMTkCqBJEL&92Rd@ZMaJ-?K1JfmCQQ=jT&8kCJZ!I3o zRuj0+>Ieg$pK(hEJ{BUYrw?hRieyMve@u6DYgIy;7O5U)&Z0`Xi)U%WWuy$>1f8OD z^)%g6iaWllaldGXk6B4h5nv@mX9>YpEIhr8s@N#&YGbqHn=PoFsB9~WZ5QY*o!hMImRGBqr7%S6uuzU-XYDd+(Mu_emd2;s4tPL;&SfAS^ci0-9(ifHY!;t zuQjSxJ1RF4RgI#HwM)?sqN`CRhpl{N{@rFLq1#G=5pcnz$yYh3W{%zz5$4UhN}YM8 zTS3v%eCX7{9xE~}OlytHRGb~1S!5y>&!E+7MJM|I06MLIUJI zfG5%WSAm@Oza#t!-TudLJA58`p$GPZR+s=EggE>ez5lC_g?E9h_s@aZQfMQpH)$~5 z7_En&h9RKxnfcb_xnT%sDU{h!7BMnd%KflL2J6}KlWIN1!q`#0sQ0zhh9RI~2yo|j zE^qQ?NXANGPOc7=0c)bXAZ&ApPL|bs#rnWHJZDzfX&3@LA=fYjI7;F+3;|*yXcz*r zT`TZFT)XcZDq-DRXAJ?x6D>8x_m{T|MoTVXJ}vWTYl%m-<`C%ZVyCEndL^pFFxG}K zG6a}BDQla_{iv0G73{bv_`0|)(_{_Gyh?M;VW7f{~1dCkBR^Pqi`vF0*-~FU>?kdLt#hw5w?Is;oa~l^#6_Ecy#^M&;rfyPB;gh ze^E|2%a3W|#=SK+k^?u7d;N zZuI+Qa11oVdJqFy_y0Ov0;2n8pc{^b4v@0}oP6{8vN~2|vN5qPt|Hd4^R_Ut9vO+X z#wkN}9vCLtqpWCSS{Mbkc`2u`{PFEwC6*Q=N{n9WGqEkPVyh~$;_A{dE(lvqq$(>Z zPbT`KYma_3MjI3Tha=Gk@^3BEc}$W&5=ycqYfI=ssM%T z&;PxjQ_<~Df&ZY_{~LY=Igq{o&G13k4z`77(fQ^4zkd?OKXh1+qw60ofAjkhSOppA z1{b!4>$Lv=FVXSgryzF$48lTq6@CA1_$pis{m=~`g`;5!>;W%h3-~cy0w0GI91FX` z-Pi^0f*y#&pRotX{(v*#5ZDs7fSa%Z$i07u!ND*C-T~KR4>$(?flWZp0=O3PFar*P zP2iWtREOs2X$ z$M=Go`RzG%FQr(YE~xLl+UqD+aS?ODj7*ku zVRA(l168k9@*^P!Nr?ow(CryWV@X+HnII%UON_F^qTlT*=r-o`yScuU+H*nTD|#Di zH9GHRl2QR;@1C7l=^pI5nRZ;5-Q?m_pPO%*;%_W7ZouhmXCmDat^R?$yCj=i;pViL z@*y7 zB-5^FHFTAMTta+n`OCYQsPYynB}&aJwR`0ToeL=!krhQ}t?1t6u1e))@52oII#nZU z+Ox#zqtVAKqo1h@GL9+Mew2|!jo8&Y%g(YA6!`i^#{Cjfb0nib`&Qjd0dZ#fJO8v} z9%`GsyXU~FTT9SZgCg!@`M5ppCUSxJv^vU2mci_p>7ZT1uVTq$CTuB_&n&wUmt$p6^E7=VDbovI;g%tLE*?E)dPBAQZrxQK06VXkw z6Ps;04An$jqP8pUzD~ONF5Q~ri+fXfr{JzCIK50{mdZ$+N~D)rZuAE_bX+Ijn;l3e zole&|DCd&pE;o5l&;X^3$_-Dfto1iSW%8kqcss97K+$z$V%u3O(5%4b`6bIB*qlo zYFmS;g6|+iv;fLBkqgi^YaDAZ*5os3r|-K*Jh;c@v$)%^26$U^M8u>zIl4q?(Y|(6YKLpX8n6yn1GM=pFf^1^$>fn5 zG&0L@5a|Aq32zC*$Qy5`F#1=pQscLG%ZMbOtvlwbHHjJ8*OSp}MgKoYE3D@H{~Y?i`27DB z9siH;Yxos>0k((B(DnBLIsfm!a29+9Qt(go`}^SQAp8D1;7FJYhr%HsegIFS`(Fc} zgX2K<1{#v*OX2ILxR`mND;RZMnwt>sh?c1Og zUO}J#HVlER_a6$|z}9dvWpELAWnmQe#_vW6)UyQ2NVf4b3>mqwmn9csDqUKGQYxnQ z0%^F8P&Ol5Iam<#$Yxf79%i4;r%Kp&9Whn1w^b#oMy!a{JJt-Pgdb2<1|$>PJ|l`P zNOB~SQZ6PYl6^ywSW6S*;&x)vF)fr1f7zn!LVB=tU!!-!$X!uPV)wHWtGlM2&tk! zt3EEXcg!eR8MsVk9~7-@UqIO=D!u%=cDcZqOw6z{q59gW?E#`f zS>6g&yTzC!9c(2@ccNikWz4(n*1IY@)?i#o@AHoY)Oz2r1~U`m%t|qisg1(KI4c^X zwL_y0ZZ;<3LqZW-(}Lc0&h=)zjcuk74-FOK@J;%&bKr6G|Hoh@48Q_-9$o)VF!q3>`1cO*Z}j||;RKin zd%%;b=_9HcBZgb%>?C>z-W@L8A%4*V5+$zR}7koy1+f=yuqSRWo^-TyxL zG29NTAp9fp!)oHp4Dq7N7qWexVj!-5gQ-Fb@A-d)@3cJOjmjUlWEy_$~JLC%j$Bt{N4h2&uAo~BDA=xF+bI`^}{5md?~ z=N1NX87`~kcAVUt{F2o1iCj|p1KT9>i5_=Oo{c|p*1PqQ?vjV-ZXGz6n>J|P4LwM; z>Tim>b#NNJv)iJ6H=W)sXo-?W)v!&VCPC9pm(^bsb=k6MlBT63Mf8zSA?PP3FH<3_ zqKOBujaC$}1a2D41gpWsB+K#>>GMtTLl9!@Q%T$96M1Z`MT1u*5~E4&?pA75QtKcu zx^oOhx;b*>yEKa-JNqUvdsvBysnl50;<`*yCt6YJ7{_Ol+8m0wv|Lfm*c!mSq8t;c zl5b%f0Gj~SfOV+1?37!~K8_~Y1EGuGvF^l>;?W`|+82ut2C% z$`nr?>>d;j&`(Yvuh8>z1*Bu4w<$hp(5d*g7MNovpQ3^$FQQboQ|S3cy5Q!_3`2X? zNXcu#X&&UI$}^Rqi?>)U%`f23Ouxa24Dz*vhSf9_|Cqe)g;RXXY*j`{~W z)66EDoBbpVsk{@px2ieWN<~a1quz3;)rwNbI6iWyEfR6it|Ch|6I!IDF7)OZ-i239 zvhPgKZ7m5VV$0FRZ06UN5Yyk35o7js4Q5{A%&+dYhDeI(C&m?cfwyuZD^F(Jk6Qe+ zWwqVfYHVI__qMkAlF^=e>AvFTa@m}8A8yt%E}6hndMkBtp)xzTE~mk zQlFHd@Y6y8`2P;6FD6x?H$)%>Z9uBB-P?XERj;(5K({NuCc$p7D?Eb@K<)?F9=3zK(EEP^Uxmdm z8RTw&cft>_0h|h5uroY}&i^qeKpqZ-=`a<$%%x`CINLch^Uzv_8) zm{AX>;J>;1d_zeuX$ov8>BG( zR^7wzwSkfx_M8jON2&5*6l%`~wWp<7>#h-mpwDkpc7`Ka?qOMy+dQmAMy1lQ=;_&b z5k#_Z$#$%K6SpMk{rs}LXf|e5zIYHbs27fGDOfsrS)l(g~>!;^>DS@j1a$ z_>Fg?HA&vZO0qPd;XUHjQxy)a6qhz@3rdYEY9@iZS_zD)xa;kMIpMa#YR zqW>SJJK_(}`BN|rTA&%$gMXm+zXC7Ar7#)3g1-NL_!7E)FZ94%*cX~$GkA&h{U3nb z10Z(*EQ4d15nodA7E|&7}yCMxQ2E8@51L`UwDD?`4wCT zm&4b3U#Ozz zPGvWoVjlz}1>S9SNhJg#3w|}92++qVPWa8S=am1>%Vy>c^triI*HEHU-03`zu%W58 zKi{TSVqz*aA)jBtSB_k@VB~} z=tKUmTUaU31y^;i-O_qjrM}+1cB^QN6myt zxEO?B11;p3czd?5lZr(PE99Lc`;%OKlq~VQ=l?TvAk*V!GaYjidA2Y_!c+_Y#UE1+ z_6Iw0(WO+T|Cv%Z{X(RYmzox&7dUl6>f+;j?~wAA{#=$lD20@p_lr!u;C)RvmQd=+ zCHi|)UDn|@-a~ReA6F1C_R?_k(}J4{syG&XJFDtcg<0`Q4$ZPFi?QB$9lia2TWnS( z;1sXOiUjvAz3^h!oBwIDrEbh1am}W==^f- z|EX{ytOB_MK+Xkx5#9eZm;md;dT=8;{|)dtkTU?bfze8^GEC4kxE&nBh^WLZ1kRB{-8agOW0}FPIXVU zlsC0%H;T^SYak;~<8*KE^$*8ssBCgaqS1ZV5|vH03Tr5@yLzT$e4|t`JNmk*T@uM# zQN>b)JNg}^k%J#>WfJ|a+zhqEDOJ|dG&@>KaLgpNgduL+>~JzYjvpavsBL?5tYoTG zMpS`O&R}d4mlr*@tz+UWqLeY96>H4C-HaE!w~N=jcCwPMOW6`HW2z|-lV|CPTT!YK zQ`WA!^=}jL2d#)j|33oVRCHO<|Bv>xf7aB|`yYcLm-Ln9#TK%wAz^YfCo7M zlGExrvD*Jok3ik2T)xn*jiqw%QdeQtaRZ5T(^CIQCDr@~wV^ZbZAmgwsApuxZcbL3 z-y*(Ig`+)pfJ57q|E1psNYtFVP{TF z9=bEZW-)7U@>`d+1R$TuI9l7z0In4U2N?YM`Eu$$EFL{r3<~?%8=K5^r=^MR}tS4 zinwAIq7+jj9SLm@DKi6dW~~&jjgd@xbr%RNo0f1oDw-V2JhOPAJ(DeHdB_pvR*#E5 zIX9K}{5OMH*BBjX?&o1-mLh)|`cA9L{2b`=$tWSC7P*R!9uWQi|DmVf1ET+DeMLEY!0r43`~P}z|-jT55uK!A)EuoA7DBE-V&}tmlyrM1)AY+ z==6VutKd6uHgv#b*aTz`fSdvNL%0OwE`X0g0hU82Bw$mxl*Atn&!EfS3^#%J0-ONT zU|-l4{)mo$6MPZUa5T(>MH-vy%k-;Z8@ zA6yATV9o}79lOf&@HpHGSHPFyY&aF3UejA0dVQy|5{cCpqtn)g_NnT@ ztlF$KrlYd+R$Eik+GkAVp>m&NV!3W;nkqAI?rx_lqoFNoJ!I|gbtSaLs(Rhpsu}7| z_6CnW4UKPfmsm+HDl5O%%+UGNB1QGP(Cn6NJA;pPxTuxha`i@*eyLhvWLagD3;da^ z);ZXwYFWjGrWToM-({sErV>)GF~?+mV=GD>V|_cIiFlJx#MYEBuvi3Z5+xiL{W2}H zQ%&@L@xlKLi2nb6rT)iWNB8f91ndw0LGPD!{^Q^Pm=4#W^G|{nkUao$-`{2MF_3%z zGSCM*!Z*D3J&ZD>%+_F?vH}p@hA8DErf$$0_+GMfPbOae+s(bK$r^6FcDrw zrx)LU2SlfDg}1}o;COWQUic_1gMHw3^z|=69^|gSUU(Xv{a&~gl3ixzQ9FRE$t7yE&F(yo}8h! zWztahwcme)BDN$&BDtJPs-4V0UniGEa!)e7DbEs7Um}$ehsSOtM*@FG)$M8!AcJnA zKbx7E=;P*FGgoU(?h|xaU(`gOEFntt6hl2bFVW|Rc|gY>?Zh9!uH&Aq=>l(T`5j} z0M!j46(->lp59xbN=4RzyF4g|0J9Zr@_&12*^EuyTH!yI=cR6AqBHwKbQh-@E_6r zVORJvdjH4J$yZWiXK{9Fom7m9TphOV>BG6~H!DNdGUM#*B+( z@D**Wc-G;+md>3pvvQ^pBl8^##CsX*N(IY=x9sO9j>550+{uV_d3F&7)8w-+xP;-S2TG∋jEp|zJ6`O!(Uo(TjN(t7mz zI)!cw0ROQJ0HXhY7v1*)bYju}r)u5x3+VjILGA!J0``Fqz@zB?_rmG05;{Q61r)o$ zB)Aegz$uV`B_QVm&I7q0-~#Lb+rkhwfDPd1=>3nwKH3(rJ-_8lz`vvG_dz@C0J6XT zeUL}bPrwp53R>Y?==BX4LqN{_6F>ip(fxOT3(1?8zd$c<{Hsv{jS^^-z}j8{ zdbshXo%OXJn!({YYjCh*_s6+9PI^rm{!k_jbzl1WV<_Ucxb4x@$Md0LsrC{j+e5&p zjcb)X(q!-jD}#D^Y^Qyl_Ea)?e9_9I>e$t-T-gP&R#)OnRt9w?u06I)QwuL!iPYjX znC(|@)9|J7*myaa)V&f--MCpiOd9@FCJl8L{(lZdY*|L$lD1J(A%6*#OD)oWtnH;H zZGVlVZ7uPnS{rSsrqKRo6`JV(=d*6T8*AL6|1Z-s;^)!z?}W2qC8S|n*bw9lz;DAj za3*{jWIurT1#AHKqw8M}SHM!31v|kj=>9*42jG7AB;;W}>;QkkCh!M%PHY2k7hDJ8 zH?SI(!hY}pkb47Pf@k0{cn}_d`=Jkxf#_J*rqS2!Qr%6TCB45q<5 z;pddKoDnEK26sR=c;&ygYa?&n^r@kmE0hwsoNUK+nw@rU+~%ea&OS+`16QOHr~Y}f z5oe2q;F@&=-x1ES?#OaZ z(DC7gYl2VzQO{&YzTvI0a zt4eOX>NCaFwDV-TaME2h!Q{s@H+9(;RXd#j!x~D3d%&C%e9K7KO({6GF_0i$`9x&` zw{((f5eoh?WeI`8m)&m8&2(|vVCQO;ff}Sjp*NM&LO~Xt#L2fb%V(pCQ5tr2aDj-5{+eSn!Gy7WK3GT*Qri0_wD)ra8wWVMG_T( z>$jUT(uMDXo}dpAT;_(G4?w(EYBI}4h0{qJA8#-uv{uAV~HMv(LN6{Xs^KM3AE46+m z?W>yX{o2Z&Hp~FFM#O( zsPEp-)#&->!ywFnok8^fyLsRmI184+A+SH}2NK42;0`zo24Dp=gRKAm6&?RpkTU`< z0y!g4?g^X+yTjkm>z{^4;EV7vI0kly&0!;WCy3v`ZEyjkVFv6Cd%(6#1>C&c-TbNcNoT2OM+VPzh^G@kFy$bv?LJLzPTqRxH-M kN=0L`_d6?lm2&5AzaK#!$3S5ZZUc$rb0jvG-&?u-{~U>-+5i9m literal 0 HcmV?d00001 diff --git a/ChatSetAttr/src/__tests__/unit/chat.test.ts b/ChatSetAttr/src/__tests__/unit/chat.test.ts index acf42955c0..abc0989e85 100644 --- a/ChatSetAttr/src/__tests__/unit/chat.test.ts +++ b/ChatSetAttr/src/__tests__/unit/chat.test.ts @@ -39,16 +39,16 @@ describe("chat", () => { expect(result).toBe("John Doe"); }); - it("should return 'Unknown Player' when player does not exist", () => { + it("should return undefined when player does not exist", () => { mockGetObj.mockReturnValue(null); const result = getPlayerName("nonexistent"); expect(mockGetObj).toHaveBeenCalledWith("player", "nonexistent"); - expect(result).toBe("Unknown Player"); + expect(result).toBeUndefined(); }); - it("should return 'Unknown Player' when player exists but has no display name", () => { + it("should return undefined when player exists but has no display name", () => { mockPlayer.get.mockReturnValue(null); mockGetObj.mockReturnValue(mockPlayer); @@ -56,25 +56,25 @@ describe("chat", () => { expect(mockGetObj).toHaveBeenCalledWith("player", "player456"); expect(mockPlayer.get).toHaveBeenCalledWith("_displayname"); - expect(result).toBe("Unknown Player"); + expect(result).toBeUndefined(); }); - it("should return 'Unknown Player' when player exists but display name is undefined", () => { + it("should return undefined when player exists but display name is undefined", () => { mockPlayer.get.mockReturnValue(undefined); mockGetObj.mockReturnValue(mockPlayer); const result = getPlayerName("player789"); - expect(result).toBe("Unknown Player"); + expect(result).toBeUndefined(); }); - it("should return empty string when player has empty display name", () => { + it("should return undefined when player has empty display name", () => { mockPlayer.get.mockReturnValue(""); mockGetObj.mockReturnValue(mockPlayer); const result = getPlayerName("player101"); - expect(result).toBe(""); + expect(result).toBeUndefined(); }); it("should handle display names with special characters", () => { @@ -139,14 +139,14 @@ describe("chat", () => { expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); }); - it("should handle unknown player correctly", () => { + it("should whisper to GM when player is unknown", () => { 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"); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"GM\" formatted-chat-message"); }); it("should handle player names with quotes", () => { @@ -220,14 +220,14 @@ describe("chat", () => { expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); }); - it("should handle unknown player correctly", () => { + it("should whisper errors to GM when player is unknown", () => { 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"); + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"GM\" formatted-error-message"); }); it("should handle empty header", () => { diff --git a/ChatSetAttr/src/__tests__/unit/message.test.ts b/ChatSetAttr/src/__tests__/unit/message.test.ts index 7394568291..fe985dbba4 100644 --- a/ChatSetAttr/src/__tests__/unit/message.test.ts +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -4,6 +4,16 @@ import { parseMessage, } from "../../modules/message"; +// parseMessage returns undefined for empty/invalid commands; these tests pass +// valid commands, so unwrap the result for ergonomic property access. +function parse(content: string) { + const result = parseMessage(content); + if (!result) { + throw new Error(`parseMessage unexpectedly returned undefined for: ${content}`); + } + return result; +} + describe("message", () => { describe("extractMessageFromRollTemplate", () => { const createMockMessage = (content: string): Roll20ChatMessage => ({ @@ -126,89 +136,89 @@ describe("message", () => { describe("parseMessage", () => { describe("operation extraction", () => { it("should extract setattr operation", () => { - const result = parseMessage("!setattr --strength 18"); + const result = parse("!setattr --strength 18"); expect(result.operation).toBe("setattr"); }); it("should extract modattr operation", () => { - const result = parseMessage("!modattr --strength +2"); + const result = parse("!modattr --strength +2"); expect(result.operation).toBe("modattr"); }); it("should extract modbattr operation", () => { - const result = parseMessage("!modbattr --hitpoints +5"); + const result = parse("!modbattr --hitpoints +5"); expect(result.operation).toBe("modbattr"); }); it("should extract resetattr operation", () => { - const result = parseMessage("!resetattr --strength"); + const result = parse("!resetattr --strength"); expect(result.operation).toBe("resetattr"); }); it("should extract delattr operation", () => { - const result = parseMessage("!delattr --skill_athletics"); + const result = parse("!delattr --skill_athletics"); expect(result.operation).toBe("delattr"); }); - it("should throw error for empty command", () => { - expect(() => parseMessage("")).toThrow("Invalid command: "); + it("should return undefined for empty command", () => { + expect(parseMessage("")).toBeUndefined(); }); - it("should throw error for invalid command", () => { - expect(() => parseMessage("!invalidcmd --test")).toThrow("Invalid command: invalidcmd"); + it("should return undefined for invalid command", () => { + expect(parseMessage("!invalidcmd --test")).toBeUndefined(); }); }); describe("command option overrides", () => { it("should override setattr with mod option", () => { - const result = parseMessage("!setattr --mod --strength +2"); + const result = parse("!setattr --mod --strength +2"); expect(result.operation).toBe("modattr"); }); it("should override setattr with modb option", () => { - const result = parseMessage("!setattr --modb --hitpoints +5"); + const result = parse("!setattr --modb --hitpoints +5"); expect(result.operation).toBe("modbattr"); }); it("should override setattr with reset option", () => { - const result = parseMessage("!setattr --reset --strength"); + const result = parse("!setattr --reset --strength"); expect(result.operation).toBe("resetattr"); }); it("should handle multiple command options (last one wins)", () => { - const result = parseMessage("!setattr --mod --reset --strength"); + const result = parse("!setattr --mod --reset --strength"); expect(result.operation).toBe("resetattr"); }); }); describe("options parsing", () => { it("should parse silent option", () => { - const result = parseMessage("!setattr --silent --strength 18"); + const result = parse("!setattr --silent --strength 18"); expect(result.options.silent).toBe(true); }); it("should parse replace option", () => { - const result = parseMessage("!setattr --replace --strength 18"); + const result = parse("!setattr --replace --strength 18"); expect(result.options.replace).toBe(true); }); it("should parse nocreate option", () => { - const result = parseMessage("!setattr --nocreate --strength 18"); + const result = parse("!setattr --nocreate --strength 18"); expect(result.options.nocreate).toBe(true); }); it("should parse mute option", () => { - const result = parseMessage("!setattr --mute --strength 18"); + const result = parse("!setattr --mute --strength 18"); expect(result.options.mute).toBe(true); }); it("should parse evaluate option", () => { - const result = parseMessage("!setattr --evaluate --strength 18"); + const result = parse("!setattr --evaluate --strength 18"); expect(result.options.evaluate).toBe(true); }); it("should parse multiple options", () => { - const result = parseMessage("!setattr --silent --replace --evaluate --strength 18"); + const result = parse("!setattr --silent --replace --evaluate --strength 18"); expect(result.options.silent).toBe(true); expect(result.options.replace).toBe(true); expect(result.options.evaluate).toBe(true); @@ -218,37 +228,37 @@ describe("message", () => { describe("target parsing", () => { it("should parse all target", () => { - const result = parseMessage("!setattr --all --strength 18"); + const result = parse("!setattr --all --strength 18"); expect(result.targeting).toContain("all"); }); it("should parse allgm target", () => { - const result = parseMessage("!setattr --allgm --strength 18"); + const result = parse("!setattr --allgm --strength 18"); expect(result.targeting).toContain("allgm"); }); it("should parse allplayers target", () => { - const result = parseMessage("!setattr --allplayers --strength 18"); + const result = parse("!setattr --allplayers --strength 18"); expect(result.targeting).toContain("allplayers"); }); it("should parse charid target", () => { - const result = parseMessage("!setattr --charid -Abc123 --strength 18"); + const result = parse("!setattr --charid -Abc123 --strength 18"); expect(result.targeting).toContain("charid -Abc123"); }); it("should parse name target", () => { - const result = parseMessage("!setattr --name Gandalf --strength 18"); + const result = parse("!setattr --name Gandalf --strength 18"); expect(result.targeting).toContain("name Gandalf"); }); it("should parse sel target", () => { - const result = parseMessage("!setattr --sel --strength 18"); + const result = parse("!setattr --sel --strength 18"); expect(result.targeting).toContain("sel"); }); it("should parse multiple targets", () => { - const result = parseMessage("!setattr --sel --name Gandalf --strength 18"); + const result = parse("!setattr --sel --name Gandalf --strength 18"); expect(result.targeting).toContain("sel"); expect(result.targeting).toContain("name Gandalf"); }); @@ -256,13 +266,13 @@ describe("message", () => { describe("attribute changes parsing", () => { it("should parse simple attribute name", () => { - const result = parseMessage("!setattr --sel --strength"); + const result = parse("!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"); + const result = parse("!setattr --sel --strength|18"); expect(result.changes).toHaveLength(1); expect(result.changes[0]).toEqual({ name: "strength", @@ -271,7 +281,7 @@ describe("message", () => { }); it("should parse attribute with current and max values", () => { - const result = parseMessage("!setattr --sel --strength|18|20"); + const result = parse("!setattr --sel --strength|18|20"); expect(result.changes).toHaveLength(1); expect(result.changes[0]).toEqual({ name: "strength", @@ -281,7 +291,7 @@ describe("message", () => { }); it("should parse attribute with empty current but max value", () => { - const result = parseMessage("!setattr --sel --strength||20"); + const result = parse("!setattr --sel --strength||20"); expect(result.changes).toHaveLength(1); expect(result.changes[0]).toEqual({ name: "strength", @@ -290,7 +300,7 @@ describe("message", () => { }); it("should parse multiple attributes", () => { - const result = parseMessage("!setattr --sel --strength|18 --dexterity|14|16 --constitution"); + const result = parse("!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" }); @@ -298,7 +308,7 @@ describe("message", () => { }); it("should handle attributes with numbers and underscores", () => { - const result = parseMessage("!setattr --sel --skill_1|5 --attr_test_2|value"); + const result = parse("!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" }); @@ -307,41 +317,41 @@ describe("message", () => { describe("referenced attributes parsing", () => { it("should extract references from current values", () => { - const result = parseMessage("!setattr --sel --hitpoints|%constitution%"); + const result = parse("!setattr --sel --hitpoints|%constitution%"); expect(result.references).toContain("%constitution%"); }); it("should extract references from max values", () => { - const result = parseMessage("!setattr --sel --hitpoints|10|%constitution%"); + const result = parse("!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%"); + const result = parse("!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%"); + const result = parse("!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%"); + const result = parse("!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"); + const result = parse("!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%"); + const result = parse("!setattr --sel --formula|%base% * 2 + %bonus%|%max_formula%"); expect(result.references).toContain("%base%"); expect(result.references).toContain("%bonus%"); expect(result.references).toContain("%max_formula%"); @@ -350,7 +360,7 @@ describe("message", () => { 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"); + const result = parse("!setattr --silent --replace --sel --name Gandalf --strength|%base_str%|20 --dexterity|14"); expect(result.operation).toBe("setattr"); expect(result.options.silent).toBe(true); @@ -364,7 +374,7 @@ describe("message", () => { }); it("should handle mixed command options and regular options", () => { - const result = parseMessage("!setattr --mod --silent --evaluate --sel --strength|%base% + 2"); + const result = parse("!setattr --mod --silent --evaluate --sel --strength|%base% + 2"); expect(result.operation).toBe("modattr"); expect(result.options.silent).toBe(true); @@ -377,14 +387,14 @@ describe("message", () => { describe("edge cases", () => { it("should handle extra whitespace", () => { - const result = parseMessage(" !setattr --sel --strength | 18 "); + const result = parse(" !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"); + const result = parse("!setattr --sel ---- --strength|18"); expect(result.operation).toBe("setattr"); expect(result.targeting).toContain("sel"); expect(result.changes).toHaveLength(1); @@ -392,12 +402,12 @@ describe("message", () => { }); it("should handle attribute names with pipes but no values", () => { - const result = parseMessage("!setattr --sel --test|"); + const result = parse("!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"); + const result = parse("!setattr --sel --test|val1|val2|val3"); expect(result.changes[0]).toEqual({ name: "test", current: "val1", @@ -408,30 +418,30 @@ describe("message", () => { describe("feedback parsing", () => { it("should parse fb-public option", () => { - const result = parseMessage("!setattr --sel --fb-public --strength|18"); + const result = parse("!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"); + const result = parse("!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"); + const result = parse("!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"); + const result = parse("!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"); + const result = parse("!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"); @@ -439,7 +449,7 @@ describe("message", () => { }); 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"); + const result = parse("!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"); @@ -447,13 +457,13 @@ describe("message", () => { }); it("should handle feedback options with no value gracefully", () => { - const result = parseMessage("!setattr --sel --fb-from --strength|18"); + const result = parse("!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"); + const result = parse("!setattr --sel --strength|18"); expect(result.feedback.public).toBe(false); expect(result.feedback.from).toBeUndefined(); expect(result.feedback.header).toBeUndefined(); @@ -461,7 +471,7 @@ describe("message", () => { }); it("should handle mixed feedback and regular options", () => { - const result = parseMessage("!setattr --silent --fb-public --fb-from TestGM --sel --strength|18"); + const result = parse("!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"); @@ -471,7 +481,7 @@ describe("message", () => { describe("return value structure", () => { it("should return all expected properties", () => { - const result = parseMessage("!setattr --sel --strength|18"); + const result = parse("!setattr --sel --strength|18"); expect(result).toHaveProperty("operation"); expect(result).toHaveProperty("options"); @@ -489,7 +499,7 @@ describe("message", () => { }); it("should return empty arrays when no matches found", () => { - const result = parseMessage("!setattr"); + const result = parse("!setattr"); expect(result.targeting).toEqual([]); expect(result.changes).toEqual([]); @@ -503,43 +513,43 @@ describe("message", () => { describe("feedback option quote handling", () => { it("strips single quotes from fb-header value", () => { const message = "!setattr --fb-header 'Terrible Wounds'"; - const result = parseMessage(message); + const result = parse(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); + const result = parse(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); + const result = parse(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); + const result = parse(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); + const result = parse(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); + const result = parse(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); + const result = parse(message); expect(result.feedback.from).toBe("Player"); }); }); @@ -547,50 +557,50 @@ describe("message", () => { describe("attribute value quote handling", () => { it("strips single quotes from attribute current value", () => { const message = "!setattr --sel --hp|'25'"; - const result = parseMessage(message); + const result = parse(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); + const result = parse(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); + const result = parse(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); + const result = parse(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); + const result = parse(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); + const result = parse(message); expect(result.changes[0].current).toBe("Character Name"); }); it("handles empty values with quotes", () => { const message = "!setattr --sel --empty|''"; - const result = parseMessage(message); + const result = parse(message); expect(result.changes[0].current).toBe(""); }); it("handles values with only spaces inside quotes", () => { const message = "!setattr --sel --spaces|' '"; - const result = parseMessage(message); + const result = parse(message); expect(result.changes[0].current).toBe(" "); }); }); @@ -598,7 +608,7 @@ describe("message", () => { 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); + const result = parse(message); expect(result.changes).toHaveLength(4); expect(result.changes[0].current).toBe("John Doe"); expect(result.changes[1].current).toBe("25"); @@ -608,13 +618,13 @@ describe("message", () => { it("handles nested quotes correctly", () => { const message = "!setattr --sel --speech|'He said \"Hello there!\" loudly'"; - const result = parseMessage(message); + const result = parse(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); + const result = parse(message); expect(result.changes[0].current).toBe("@^$% special chars "); }); }); @@ -622,19 +632,19 @@ describe("message", () => { describe("edge cases", () => { it("handles single quote character as value", () => { const message = "!setattr --sel --apostrophe|\"'\""; - const result = parseMessage(message); + const result = parse(message); expect(result.changes[0].current).toBe("'"); }); it("handles double quote character as value", () => { const message = "!setattr --sel --quote|'\"'"; - const result = parseMessage(message); + const result = parse(message); expect(result.changes[0].current).toBe("\""); }); it("ignores unmatched quotes", () => { const message = "!setattr --sel --unmatched|'missing end quote"; - const result = parseMessage(message); + const result = parse(message); expect(result.changes[0].current).toBe("'missing end quote"); }); }); diff --git a/ChatSetAttr/src/__tests__/unit/permissions.test.ts b/ChatSetAttr/src/__tests__/unit/permissions.test.ts new file mode 100644 index 0000000000..149fe22ece --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/permissions.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { checkPermissionForTarget, checkPermissions, getPermissions } from "../../modules/permissions"; + +const mockGetObj = vi.fn(); +const mockPlayerIsGM = vi.fn(); + +global.getObj = mockGetObj; +global.playerIsGM = mockPlayerIsGM; + +describe("permissions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetObj.mockReturnValue(undefined); + mockPlayerIsGM.mockReturnValue(false); + global.state.ChatSetAttr = { + ...global.state.ChatSetAttr, + playersCanModify: false, + }; + }); + + describe("checkPermissions", () => { + describe("when player object is not found", () => { + it("should grant full access and return true when playerID is API", () => { + expect(checkPermissions("API")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "API", + isGM: true, + canModify: true, + }); + expect(mockGetObj).toHaveBeenCalledWith("player", "API"); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + + it("should return false (and not throw) when playerID is not API", () => { + expect(checkPermissions("missing-player-id")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("player", "missing-player-id"); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + }); + + describe("when player object is found", () => { + it("should return true and grant modify access for a GM", () => { + mockGetObj.mockReturnValue({ id: "gm-1" }); + mockPlayerIsGM.mockReturnValue(true); + + expect(checkPermissions("gm-1")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "gm-1", + isGM: true, + canModify: true, + }); + }); + + it("should return true but withhold modify access for a non-GM when playersCanModify is off", () => { + mockGetObj.mockReturnValue({ id: "player-1" }); + + expect(checkPermissions("player-1")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "player-1", + isGM: false, + canModify: false, + }); + }); + + it("should grant modify access for a non-GM when playersCanModify is on", () => { + mockGetObj.mockReturnValue({ id: "player-1" }); + global.state.ChatSetAttr = { + ...global.state.ChatSetAttr, + playersCanModify: true, + }; + + expect(checkPermissions("player-1")).toBe(true); + + expect(getPermissions()).toEqual({ + playerID: "player-1", + isGM: false, + canModify: true, + }); + }); + }); + }); + + describe("checkPermissionForTarget", () => { + it("should return true for API before any lookups", () => { + expect(checkPermissionForTarget("API", "char-1")).toBe(true); + + expect(mockGetObj).not.toHaveBeenCalled(); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + + it("should return false when the player object is not found", () => { + expect(checkPermissionForTarget("missing-player", "char-1")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("player", "missing-player"); + expect(mockPlayerIsGM).not.toHaveBeenCalled(); + }); + + it("should return true for a GM", () => { + mockGetObj.mockImplementation((type, id) => + type === "player" && id === "gm-1" ? { id: "gm-1" } : undefined, + ); + mockPlayerIsGM.mockReturnValue(true); + + expect(checkPermissionForTarget("gm-1", "char-1")).toBe(true); + + expect(mockGetObj).toHaveBeenCalledWith("player", "gm-1"); + expect(mockPlayerIsGM).toHaveBeenCalledWith("gm-1"); + expect(mockGetObj).not.toHaveBeenCalledWith("character", "char-1"); + }); + + it("should return true when playersCanModify is enabled", () => { + mockGetObj.mockImplementation((type, id) => + type === "player" && id === "player-1" ? { id: "player-1" } : undefined, + ); + global.state.ChatSetAttr = { + ...global.state.ChatSetAttr, + playersCanModify: true, + }; + + expect(checkPermissionForTarget("player-1", "char-1")).toBe(true); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player-1"); + expect(mockPlayerIsGM).toHaveBeenCalledWith("player-1"); + expect(mockGetObj).not.toHaveBeenCalledWith("character", "char-1"); + }); + + it("should return false when the target character is not found", () => { + mockGetObj.mockImplementation((type, id) => + type === "player" && id === "player-1" ? { id: "player-1" } : undefined, + ); + + expect(checkPermissionForTarget("player-1", "char-missing")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("player", "player-1"); + expect(mockGetObj).toHaveBeenCalledWith("character", "char-missing"); + }); + + it("should return true when the player controls the target character", () => { + const character = { + id: "char-1", + get: vi.fn((key: string) => key === "controlledby" ? "player-1,other-player" : undefined), + }; + mockGetObj.mockImplementation((type, id) => { + if (type === "player" && id === "player-1") return { id: "player-1" }; + if (type === "character" && id === "char-1") return character; + return undefined; + }); + + expect(checkPermissionForTarget("player-1", "char-1")).toBe(true); + + expect(mockGetObj).toHaveBeenCalledWith("character", "char-1"); + expect(character.get).toHaveBeenCalledWith("controlledby"); + }); + + it("should return false when the player does not control the target character", () => { + const character = { + id: "char-1", + get: vi.fn((key: string) => key === "controlledby" ? "other-player,third-player" : undefined), + }; + mockGetObj.mockImplementation((type, id) => { + if (type === "player" && id === "player-1") return { id: "player-1" }; + if (type === "character" && id === "char-1") return character; + return undefined; + }); + + expect(checkPermissionForTarget("player-1", "char-1")).toBe(false); + + expect(mockGetObj).toHaveBeenCalledWith("character", "char-1"); + expect(character.get).toHaveBeenCalledWith("controlledby"); + }); + }); +}); diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index 2708206300..1a95258623 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -3,11 +3,11 @@ import { createChatMessage, createErrorMessage } from "../templates/messages"; import { createNotifyMessage } from "../templates/notification"; import { buttonStyleBase } from "../templates/styles"; import { createWelcomeMessage } from "../templates/welcome"; -import { s } from "../utils/chat"; +//import { s } from "../utils/chat"; -export function getPlayerName(playerID: string): string { +export function getPlayerName(playerID: string): string | undefined { const player = getObj("player", playerID); - return player?.get("_displayname") ?? "Unknown Player"; + return player?.get("_displayname") || undefined; }; export function sendMessages( @@ -17,7 +17,8 @@ export function sendMessages( from: string = "ChatSetAttr", ): void { const newMessage = createChatMessage(header, messages); - sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + const player = getPlayerName(playerID); + sendChat(from, `/w "${player || "GM"}" ${newMessage}`); }; export function sendErrors( @@ -28,7 +29,8 @@ export function sendErrors( ): void { if (errors.length === 0) return; const newMessage = createErrorMessage(header, errors); - sendChat(from, `/w "${getPlayerName(playerID)}" ${newMessage}`); + const player = getPlayerName(playerID); + sendChat(from, `/w "${player || "GM"}" ${newMessage}`); }; export function sendDelayMessage(silent: boolean = false): void { diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index ac86530991..a8c9d91b50 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -17,13 +17,18 @@ function broadcastHeader() { log(`${scriptJson.name} v${scriptJson.version} by ${scriptJson.authors.join(", ")} loaded.`); }; -function checkDependencies() { +function checkDependencies(): boolean { + const errors: string[] = []; if (libSmartAttributes === undefined) { - throw new Error("libSmartAttributes is required but not found. Please ensure the libSmartAttributes script is installed."); + errors.push("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."); + errors.push("libUUID is required but not found. Please ensure the libUUID script is installed."); + } + if (errors.length > 0) { + sendErrors("gm", "Missing Dependencies", errors); } + return errors.length === 0; }; async function acceptMessage(msg: Roll20ChatMessage) { @@ -47,17 +52,18 @@ async function acceptMessage(msg: Roll20ChatMessage) { // Check Config and Permissions const config = getConfig(); + const isAPI = "API" === msg.playerid; const isGM = playerIsGM(msg.playerid); - if (options.evaluate && !isGM && !config.playersCanEvaluate) { + if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); } - if (targeting.includes("party") && !isGM && !config.playersCanTargetParty) { + if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { return errorOut("You do not have permission to target the party.", msg.playerid, errors); } - if((operation === "modattr" || operation === "modbattr") && !isGM && !config.playersCanModify) { + if((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); } @@ -136,7 +142,9 @@ export function generateRequest( export function registerHandlers() { broadcastHeader(); - checkDependencies(); + if (!checkDependencies()) { + return; + } on("chat:message", (msg) => { if (msg.type !== "api") { @@ -172,8 +180,9 @@ export function registerHandlers() { } const validMessage = validateMessage(msg.content); if (!validMessage) return; - checkPermissions(msg.playerid); - acceptMessage(msg); + if (checkPermissions(msg.playerid)) { + acceptMessage(msg); + } }); }; diff --git a/ChatSetAttr/src/modules/message.ts b/ChatSetAttr/src/modules/message.ts index 8f3f70c310..49cdcf8b2a 100644 --- a/ChatSetAttr/src/modules/message.ts +++ b/ChatSetAttr/src/modules/message.ts @@ -37,11 +37,17 @@ export function extractMessageFromRollTemplate(msg: Roll20ChatMessage): string | }; // #region Message Parsing -function extractOperation(parts: string[]): Command { - if (parts.length === 0) throw new Error("Empty command"); +function extractOperation(parts: string[]): Command | undefined { + if (parts.length === 0) { + log("Empty Command."); + return; + } const command = parts.shift()!.slice(1); // remove the leading '!' const isValidCommand = isCommand(command); - if (!isValidCommand) throw new Error(`Invalid command: ${command}`); + if (!isValidCommand) { + log("Invalid Command."); + return; + } return command; }; @@ -69,6 +75,9 @@ function includesATarget(part: string): boolean { export function parseMessage(content: string) { const parts = splitMessage(content); let operation = extractOperation(parts); + if (!operation) { + return; + } const targeting: string[] = []; const options: OptionsRecord = {} as OptionsRecord; diff --git a/ChatSetAttr/src/modules/permissions.ts b/ChatSetAttr/src/modules/permissions.ts index 46646aba9f..c4518278b8 100644 --- a/ChatSetAttr/src/modules/permissions.ts +++ b/ChatSetAttr/src/modules/permissions.ts @@ -6,15 +6,16 @@ const permissions = { canModify: false, }; -export function checkPermissions(playerID: string) { +export function checkPermissions(playerID: string): boolean { const player = getObj("player", playerID); if (!player) { - if('API' === playerID) { + if("API" === playerID) { // allow API full access setPermissions(playerID,true,true); - return; + return true; } - throw new Error(`Player with ID ${playerID} not found.`); + log(`Player with ID ${playerID} not found.`); + return false; } const isGM = playerIsGM(playerID); const config = getConfig(); @@ -22,6 +23,7 @@ export function checkPermissions(playerID: string) { const canModify = isGM || playersCanModify; setPermissions(playerID, isGM, canModify); + return true; }; export function setPermissions(playerID: string, isGM: boolean, canModify: boolean) { @@ -35,6 +37,10 @@ export function getPermissions() { }; export function checkPermissionForTarget(playerID: string, target: string): boolean { + const isAPI = "API" == playerID; + if (isAPI) { + return true; + } const player = getObj("player", playerID); if (!player) { return false; From 6c6ef282823d1313384a24925af692cbd899593b Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Mon, 1 Jun 2026 10:26:57 -0500 Subject: [PATCH 07/38] cleanup --- .gitignore | 3 +++ .../integration/.legacyAttributes.test.ts.swp | Bin 147456 -> 0 bytes 2 files changed, 3 insertions(+) delete mode 100644 ChatSetAttr/src/__tests__/integration/.legacyAttributes.test.ts.swp diff --git a/.gitignore b/.gitignore index b39de550b6..cc2acf4c14 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ .idea **/node_modules **/coverage/** +*.swp +*.swo +*.swn diff --git a/ChatSetAttr/src/__tests__/integration/.legacyAttributes.test.ts.swp b/ChatSetAttr/src/__tests__/integration/.legacyAttributes.test.ts.swp deleted file mode 100644 index 7d8bb4f54462a4a7a9199209152d1860534b1079..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 147456 zcmeI531D1B{rH!|13?ryC~2Vude|0~r2a>$_~Ajk~@f*^>BigJ44E&M;r*|%kKjGy<$V%c+cSpMO|H;-*N5R2t|lY`4w$QPA=(y8MHQpsc@ zH_WpqssJY~%%;q4*QAb)f}1boJ31z% zG6lCMmnfvNnMrB4C(*T99!hl%@IZ?^)KbV-PG95OMhP@ZKuX}URVEMd?#n~ z_Z`_NmPQFQN}y2!jS^^-K%)d2CD15=MhP@Z;4M@Fh5mT#B=Wny&U1_Yy0s!;NRxCw}+npaOm&1>AyGCzn6x-e~12iPyIU;`hFw*_j~m3FNeN=r~dmu{rig0 z_Zx@)em?Z~yY$~%>fbMhzTZUu?dMx(+~nWn%j9En==*o;_f7fj6Z+odYX|*YXXyLQ z^yippyq^<8-+jDBeZRT>Tz?$9H}w7c^!KKm?+<;yh5mkq{{2|!`>jIZZ>1ZQ zek^9n)ueOl(D(1x?;oUpH-*06Mt^SFVNdA$twZmpL*H+!zc=}~BJ}Qoe^akV>*sa|eedYc&GUuO_dDwEO*yR$eZQ0beyaX` zX6XA5>d#F(y*2cGTz|hz|9&X+z5X+Hl>UCAt_YJ}{c+=0qXZfy&?tdM2{cNeQ38z; zXp}&s1R5o<7MDOG8H>G&(t3v|E+}V4|GyrE^b>Fp{1c_&oA3?zI^^LPI24-T9q=*= z?jPX~@FTb!&Vu9M7+3;F!Orj=_y-F0GjKDU0w=+Vumsv+57-?3i$eA+JPl95<&b~{ z@Hk5Qk?;sg>k%M%SO^=yP2}lDxB|Wi8JGUMlTfIa|O(TIQ$DG=@GaBPKVRL=+CBUIQqw3 z)$eu{nwDpKGc(;)6Iu${gWaZh>*V-^{bH4#ndx??GH$Y|^kZ`qt7b1Sfwk>jCNS%% z%E1L;wU!IZJYogpr@t$k$rl`*MQ6H`7)+!JPCC`Os4tQ8TtKvUM~`v{@&0Ox%L}dX ziB6n?v2zdv=VhRV0B0|dVEYnff}Opf<}kYkayd6s2$HMvx-!)wc|IhYYf9u-XS$rG z3C{EboHa3BM(!%N3;&k(Y+qj@lWdCbly?gWyk4B<=1ii`bq=E_`1kAuL#=xcwe8J6 zDU%`9N^}riIX6*o=XWk|ig)!Ua*3{jnOCmp;9riTC4vOAcrd%DKzr&b^qS7xIyGopm7leGVn~ok(DO5H z)j>y|wSo?H@=&U)??>{Yw4%64m7Pu1<3q_FS=gQ+iH1E)_f!QpX%e-UcXI{PUHz`% z(3bLew9(n{@zrV)m6Obq<(U4f6mP#;_}nH zmcDpUm66rYL~wZ`+CqeME%ACk;e((mu4dx~gqX-QUrY+MSoz|(|AYnRiFk0kET=5v;I7v6(MOR`%=)1r=IL#JPnI1>a734K* zGGPW1>7#`i>2!M{o$gF@t;m~+qWOY}0#eKWmzJ z`vn1-4&e8UvO&2kscWxsk-M_t=#Er2lth0@KN1RJce=Y{mx!IAzHD+J?dB)-B~lqX z^`|7Lyf0rdX+;dq!2 zyTI?z^B;nrz@2a&48Zf~^FM?y!l&W?;6yk97Q=?{AiDema4lQ|XTer*33_}R{0Ke% z7Wf1l3WvaJ==2xDsc;G$1lz&Y==X=h)9CL{!8LFuBtdlhE#Vb(`QO2B;oBg3{&6rB zM8|&-ZiO4+23QUYU@Q0v`u!mM4}Jbs_%e zhb!O`xB#S#@8Fs0ew64o0^Ix#1g67}HSv@$=yAbMCY#80^`=%9$rkmsQYl)pp3Fx` z6lhhn>83TY;Prt_QkfK#;3;}hb?~`~0^#%8q;!@6sK9_ABbuJIPU@LpzZth$wm-r3 z6&5CtNqIoDn%Rk!?!m5`kq+r5m!t~4O))dl-p?3MA$mV1saij>gE-h#FN?g-H?>hp zCV&Sb0Yql}!4M`fFnHt!G7N*oV~01sEfKSYsCY6cWU_@+cdEky5UD3pZD`LnQFcs{AB-fKp>>ur5I70Mzh@&L4T*xNpLxfh{N|l(#_1bK z7gA`o*32TNitYscyJ+UzWhQ>X+~`>OZ09wp6OJ{r@nfkgHCuP|=LqDoRs)lfhawq~ zvTI5+u%;O0TleGF{?nbQlljLUdrV-^Qt2dYCJkzIs^cu~r;+4Q_cB^wRnwCbm4-R_ zL+4KLzFFuN26CCB7{c8q8KeE~SZ>N;O$5F6vf4>>W(NvP>@X?hoxxr=FcoX~IXj zIVLw=-^pY1l+NSy)2#Yjo}=k@TVZ(#V3(K5f#2h;sG^cXl96wkLU#3QL23DNM_Tq2 z>2t=MqPm`nrrcAOwWGv?ZB+$lJykil)~bTDp0a||`8HETKeJR*l_pp37200h$R=Ab zJsjyUc1&IlM;4Q3?!M96d51FN}03)(f8;|h2ETN3tC58 z1-+T$08gxz`KvVfRL&C9rh%3nYbp#KQbSm0f7bRye zF{$y=YJJT6M!Mt7Y-YEDGnha-FZ8DJ(u8^w{r!xusf;(~WZXeVtSS8k@1<0sCBGuo z?;G;37P%^)RPb4dP zq$92A)v832W~3@*_m4hjjRq2){HRW9KU#zA#O%xR5_6P}K{wG)JLzany4`zD-K)b3 zSoMyiJz6{&spV=VYtb}YjM@Gio4GGD+k$K4k2SRk?C?%7&$Z{=%IqG^$Ih)qSr{sB zjyLn#Uqo_d!zzLeRWri!$3noWaGU>X|?#s4Q-w^091an>b1~Rnq}-0{r`D%;>*yPMgQMjYgZ?u@2?LxqTk;D zm&3{MVVDBD!vxp{{)T=pI{y~1Is6vA{s#CYEQ3XGI2;BWz!~WKJHoYE&!5HbSJ3NU zhPz=I{005~Ecguc!YtSx{!Tg{gv-H8vrcd0SEB?PCD15=MhP@Z;7wWrYWn6+3dOMo z9}_blY`BQAEGlK<>Gl+iJ^6f@xmwt6tY%5_#+bN`nc3hg6{8v(S27dVpCWM$GpCNJ zHdMi$Abw62-OEhef41V*!B(LYi^8p4sj721GtvFUicV!>nAoIZEe%}$tWw$kYb{Y( zs`p{lNA-$NB35eD&D z9(E&(&l7&7;`>J=zF|a#>SUsxz7ei&r1m5c*#C?qTPe)Ns;=>#S*#rGjK2MbxW6f{ zwT}Eir>v^rsNreh#ok)~isVXFz4vIuS*ncpom6$$`%ZE!u49#>|F;#r=>L16lb;Hr z|D(=(KWCu(FNMjlD|`TUfJb>?DeMH_LD!!S_ww#NI*g8Iy z{hQ&_a4Z}J^I$H>djFSTKlm-W{1Z;GoShgriP zf;BJ?=0X$vhc*3w!yPaSZf70;KoFn(R=A87cPZpy9yEdZQQg6xeBnOPx5Q2L^cKwg zYf50sE_$jxJCN}oYAy4S_^_CO+SA#@3R(V|wW`Zy2|Ux~%3Cp$0@YL`0qrchiu=)s zmUBD_fmc!ve<@?bOJ&5hC7WB_(L9jhFh{pJmmN$cn_Jta?AakP5vRmtr#?uA=M0pR zHMhQZ71NAQM&R)!Ao3>(O-$YcjI@=tYs>Y>Uh zducrI@QyDFxpN!m5G=sTO=JNZZ1OG3v#Cr|+zGm~|C&Q#HTwnFVF`xzg|lWXo@Gme zp{9k>6>y+)Sfn2CTp=gSaQX*wp1Vn+-^Jrxyi&SdH=TES-NZ_k`&r5p7s~8_IHz%l zEBk(sqVjncF0-p!oH+$nYVuA`Cfk?Fi^u4yg5&me=CUc=fv}Dzl)p_EdsGs+mbhPy zRV3UOA47BKLC#`tfL@Z#By-t;JRWpz-$Z9dyR%2zL}yWArFdX0?nM&jojs76)kb8* z!_Wxc2|Dx;8d9|}$y;6@9BEY2Z-z>ZGeuE%cQ$LePcgW7^G3!c<#Q?Z+0`qKy`A{8 z1S@7O#W^83nn2RcIWwfyNa@Z^^x*U%brE^Af1oohI}Yr!ppV!em`zdsXR0UJEuGH4 z)O2)9U#xOQQ%tqe$C{I#B_PI>Yf#ujJtpB*T*;IonzESPFsxV2O!;%3kK@KU$n|f^#A4P^{0a9|3>ANza7{J zeu=IxdjEZJFMJ$!fM?PDPl4UwZqfJQTsQ}Eusz&~&fgDNI0&|Y-=XW@2xq`YVKE#5 zhr@r-{m+4Y;Wy~{zlI;fRd6Ak4$ELuxC_1ibFd61z>m=V-vO_o>puj?!)iDJ4u@Ug zU+De+6x|=%LC!&&4mYCzPl9hz0N;e;;0V|i%nZV>N*Ns++COgFltV-NH-m1_Dk_YO zEyZUVi0~0nJyJtRL;H988rpxVtD*g?IaoDji-z{E%r_0~zvO8KB8E|RWk0BQnn6SR zZ)pGCSxnv--O&C+#W3jLW76ko5y|jNd%2W}0O`3kyq1U9< ztI*e44PKJB_G;^@@XQpyNQWP>W-dNRR{O9t^YMwrn0HHo*0_jaNKi~hf-sJr~U zj^6(y^uycX%jo;(LmCc(&ER+F{nx`8@G)2ggU|%uLHC~y52Nq@6t0Df;4D}LbKy1g z{To2`{LA|PHgFYs|3A>}UxpvSQE((|0h`0i==oQIoa;9T&!X?oh3nAmcYu@7>3>UJ zo`oynbnx<~veo!+qXfpe1We!7%M^i?z&L!oRXj(G`1!ddeoWygqvsoGPco@{qGIaC z-^0YD>&c4g8h@`7ldfM>OxJjOqL@@YRWVib#??E;r0MChY4X=tJy$2@6=UJCVxs+J zB-)zh-sQYlOf=6}(Zp1@R-Jn6JX=guzp|pTV`_N2bTnITIt_bQ!`>yw|Bj8fi|Ks7 zj&{D`Pa3XCZ#6OgCK_XXy<$wn&xRtlwA6;DOzFeMLq;Mq#kIXW{8iMF&(_~l#?Wlqh9dX9fEHXpH02RUZgNY;{#Oeg>Y`&YYQfC&^B-t^!A7;+`!XIq1JN zbX2ZQO3bj%buD>1O7TPGdzE=^WPQqO>vKtm-QFGyl}L?S3gdIwEysqdgu~If_^``; zLuNY^gOWXl@uwt$%b|AKX(vjOW8u`k^d2|EQF`g(o#_u*>5F-dv+k|)r&g3Y#_?&L z4~HVQTBkYvF(JoArny)Rxu=fVmUACDLDJji#$dxi&50hjh6c*eBpdd6y= zs+4>?3a7E#L6y7_s^}RjDDV9Lm(jrwMlTorAGO~55#9eJ_!+vr`261vx4~(!DSR3I z{(RUM_5nHf?{dPBa9$$37Jk1AKR zzSirC9AHga=g3(sBRZ?7bil@U)r9LEMknk4Gqi3l`u~<%|Gxm;zYSVJ*7|>e&VMz0 z3X&ju0Jefx(EDW{z|-(JJO<(iun^t_pT!2SBZ%%lA2x#Tpy$trKcdfn0(OAy;av3k zb07y>!w=Et0Lb2|@78`|s)3v|J)SM6HmnTIY zTy!mm(@_3$?W?}{SyjX6VkwGZsxkye!u6BO)8=40&&9I>J2-z=#k%jD;I4XziXK-X8duGFE$TbROY*<<-v#mh)X=`rHWlh#8 zo742YORd($OB_ilheWqUKbm5Fv@Ci+r}x?v@@~o8GM?2^O_7rXR2zs_amAI;6 zkt&PCA|F;6OKVlJNcBZxkq;}z5-e!@vXxgDKK#)nm1HpUdN^O`al~&W_R!}G2_$-&z7_yW?IAkT2tn~RAR{8g{Z6&MSA3o*7Du&OXbjLJwqY&N{(#sfG zhL6!Ij37VqVWs?-7;lcoSYOMyiTL}Gh&f(!x+fDXCMv8Hm3#%CE-g1jN2eaE6&k zI&-~qu=Qor?P_US-W}=1f)a6Cb?b=sW7gnSY8W=B;Z<0bB($>gC3~dyjMrwaFnV7r zwGwN>tLz18KWk;U#AvRHW@N*nboF>iFVG)E+N7D<>DsC-B4#b7)&J0nLG=G2^!sx` z^nat~%HK0V_Wr*OMF0N|oB|!N4{QO?qvPKWH^S9$Ia~$>*cE<>?!OwggCC&#cfi}= za`b;W@9%f${U^aTur+)hz5jEN0on6^6FR^6_RBf{vcCT=koEoR;B+_|mVm75{|P<+ z7U+Z}&;mQd_OKm1i0;1}Hi2u=`RBo0cpaVpHFyF}g=V;ig18&L3Ma!7m;(HSo4l{G z>2}r7{k?1KJ(V|KZr@6vIG32msuCSpJD0I0u0suitZ|yz(ET}Yw8@*FmAXs0romoy z8>Ok#&9*F&Sa#%w?%&Y;X%Y5}!c4=enSS^xua@i$-M3vm?%CgPrVjhZ^)8)fT`m-KVhu6;QYi(G& zrvr}D(CGu&TRa$As=uY;s%8~_(rC?PJ&oV8I=v|`HMjM&^g4JMj^1<8=>NBb^#6aO z?>>wkEc*XsPsL?#KRUmh|NmWd`~%>}=<s*>zJpEeEYg3-%M#}+3Xia4T?;$ z$;w3bjo~7zJti5oO*G$(L^HmoEt8IKS?P$W!nIb1hFy)br;2`E$Sijtm*d!V=HxQx zEp~M=QD16BJr*$+MXNOd6W3*x;wm?Xw=?Ij>B)zc=I|!Q%cC)l&l=bjp@zQ&4LH3}={HfqNj8_PHgNfQ`>DbK`wZKas&V%{?`Ul)xzFhC7E z0kG^`e!I7ae_vR6PB|vd>qix*W&J7{r!fPX*uFQa*sNJV>DaUfuZiu3O0kujYFNeC z?oTy_GZ&f|Z?s~R_5Z2p)~|x-|GR5tN%a5i;9Kba--Jc55Srm3>;QSl!2*~9o5A0( z1w0SGfnUQHK<)s%4BNmya0j-4c_3%~zkm(kdAI|5VPm)iz5i=)G9=+(*acpbHGhz^ z{_cf)AOn)-vtcg0f<546xEwBnQ(%AiD>i|@z(a5id=>^^1$2Sz`9B84cJK(cgAD8m zk6{y81v_b9fE;W8awp)y$_KzwDOJCZoG6ua)6yC6Q53J+=H{Mk+PkRLNT?C?8LLI* zQ|XnQTW0r|sWp3qTm8aIogHf16YpUyxrC9-O@V!(#eza;xn;I`=8B}FvXEx4xU-ajada}6kce3{dzW8!e85b zSAJUBYu;`x@w)ladR=!w<5bnxU&AD7Zi%xRdTR;DKJ=l+ITyYvom$dJJ({!Y5ILMp z*E*Tsa~BLXs{@cmCw$1YEh6^euVPI-((Q(w4p{xqbB62ELDX0E9hXSVX#06iD&PAntIyfcvZ+)C-UJ4qcQlGG7I`PG>Md$GbjH70>qysS81Nc5#L ziSnJT=mD$CNXYV|^;FX*n@agZm@mt}Zu(@&*UgvK>oG5G{-flkPGk+ec9F`zt!I59 zR5=orie?mY!PNX0%LYCQSz!V{ClYu)<%jH9*2Oo}Ty%ooxmG%2QhXF(Fz${-0GR5r zWsJy&Vi{xd|0OHlQAHb)YA6GKlp&+wd1Do&5_P`GGl-GR;6-}R`ITbyM{N>pyAoJ3 zwfZ@YDb_DXW2~<%HW7a%6!8efq!|Tk5Q|1)HMO?+Q?!Vzv*eL#ZT=HRV$~?l#z4+o z5Gp#a4|=sW26E=YNZ_^0nX=Oq5ic@J@vg!mLhtN{i>#Pqb?V|cGZb~Wi>)|E6=kT4 zGyO7BCEUlqS}Dr9^zpA*F^c{#cl$jAqW_~-dq0n$`(F+h!@LBi;JP9YlHgGd`fo1SIc7WI5X1EEy07t+Eus(<%!R>G{ z$h`n-AP;?zhRGoJ37!Zu;rG}CP6s(F;7RNOABU4c&i^yp0~+=Kf2*4{9yRO%4SRqm z$}8$A{(xLUp*P=EGai?C-3->&>t1?9){lDLFM-I93TPB#uL`F2arRAQ4=6E79T^&9d=CY0jjo5*B!;2yf~n>S_VXQ`j~F?_0-=-6~*wn z`lx~rP1>V+c_j2PouBK_>#J`|Bn{oV;FjH)x|Z4YtJ9fdy6x1~bIW%tqaTkdc-^gZ z81--7MvT{O*hYW$EEJbminQdeV!tXj=QBwhr8U3H_n(~WU*c8Df>q)}@snZW)x zoC~3e@l&b(t15+qF1Bas#+ZGkM=zG zP5GxoiR#BE6`@_C}xk@qCMbzb^F^-R@D}*ApM4i_J z?OhPoQi=L*)QcrXmNkuD){S!0!y6{+20|4urm}8j*}%s@)(u7i9~W7-%8GW(Wu5T` zE_Od&ZACejLND-S2=8@1eym~)$vjKUhK1gmN>SED=nX|<93P=~LMY-&LXZ0c*rS=s z$$pnq-$0+^UGSr~g!!X%4f`7seSKMTcpr5U-Qy5EU`@7E^E1;Sd8CFNS2K9co<5uy zDi6o0u_p&}7IhDdL^QOE{I`SR+l5kEVS>mT_AV=T>+=TG}sDW#SZWSJPW^pPrhb!T0a6X&|hr#c#3!DZM;2!J%at1&F z_J+M+Gq?*oz)7$dOoh$i@7P5ih3~-^U;tJ?7j(kr@NMiT3&e&3kvbexfu=*Rukeyc zEJ39WxD7?r%0?_0>4)j*T)D z(@khh6`yfwmcat;m^ON}Bu_j1xTP-HWmI?f@=t^!wkG9jbQPUX?j1}O^2JsIL`v~7 zPG03!+;k~L*Sg3%Ud$K^I9V)eEQma3jc=-S{N*8ewCzyNoKgWEnMnpy=n{{%mU+}m zyF9OE+g1WDgMbL=>S3gMUM@Hw+0?w2^{sCK&?Bi`j!dybuVIG?j1m*o{X zZHqoN(koPZ`r4_E#oXmyLdyA~OP`#j@+~j41#dLP7qpiUv?ZUlgl+k-TSpMK^=zrI zmB62zELduknoktaJD2vqxu3nS~JYic+3|>ZSiNO=J(HMpktTK$qNa%JA z6kL5cVpDvlV6Mi&iz*v=3?nA|A=MOO-pj-FdB2GD#ZiaObtD`k8YpAS*EC)vSt?JN zii=l=z95}g?dF0QoZ&oZtI9(Vm8GghqVj}fQFhdO2vh6GyS3I$R!YK>WwZf%wYxi; zb5wSkgDSJm(8|hq6#U^cqGuH|O`6?ur5&>=Y^G)%2 zB?VWBM@xwoEfUq!oM;{-q;kMTkCqBJEL&92Rd@ZMaJ-?K1JfmCQQ=jT&8kCJZ!I3o zRuj0+>Ieg$pK(hEJ{BUYrw?hRieyMve@u6DYgIy;7O5U)&Z0`Xi)U%WWuy$>1f8OD z^)%g6iaWllaldGXk6B4h5nv@mX9>YpEIhr8s@N#&YGbqHn=PoFsB9~WZ5QY*o!hMImRGBqr7%S6uuzU-XYDd+(Mu_emd2;s4tPL;&SfAS^ci0-9(ifHY!;t zuQjSxJ1RF4RgI#HwM)?sqN`CRhpl{N{@rFLq1#G=5pcnz$yYh3W{%zz5$4UhN}YM8 zTS3v%eCX7{9xE~}OlytHRGb~1S!5y>&!E+7MJM|I06MLIUJI zfG5%WSAm@Oza#t!-TudLJA58`p$GPZR+s=EggE>ez5lC_g?E9h_s@aZQfMQpH)$~5 z7_En&h9RKxnfcb_xnT%sDU{h!7BMnd%KflL2J6}KlWIN1!q`#0sQ0zhh9RI~2yo|j zE^qQ?NXANGPOc7=0c)bXAZ&ApPL|bs#rnWHJZDzfX&3@LA=fYjI7;F+3;|*yXcz*r zT`TZFT)XcZDq-DRXAJ?x6D>8x_m{T|MoTVXJ}vWTYl%m-<`C%ZVyCEndL^pFFxG}K zG6a}BDQla_{iv0G73{bv_`0|)(_{_Gyh?M;VW7f{~1dCkBR^Pqi`vF0*-~FU>?kdLt#hw5w?Is;oa~l^#6_Ecy#^M&;rfyPB;gh ze^E|2%a3W|#=SK+k^?u7d;N zZuI+Qa11oVdJqFy_y0Ov0;2n8pc{^b4v@0}oP6{8vN~2|vN5qPt|Hd4^R_Ut9vO+X z#wkN}9vCLtqpWCSS{Mbkc`2u`{PFEwC6*Q=N{n9WGqEkPVyh~$;_A{dE(lvqq$(>Z zPbT`KYma_3MjI3Tha=Gk@^3BEc}$W&5=ycqYfI=ssM%T z&;PxjQ_<~Df&ZY_{~LY=Igq{o&G13k4z`77(fQ^4zkd?OKXh1+qw60ofAjkhSOppA z1{b!4>$Lv=FVXSgryzF$48lTq6@CA1_$pis{m=~`g`;5!>;W%h3-~cy0w0GI91FX` z-Pi^0f*y#&pRotX{(v*#5ZDs7fSa%Z$i07u!ND*C-T~KR4>$(?flWZp0=O3PFar*P zP2iWtREOs2X$ z$M=Go`RzG%FQr(YE~xLl+UqD+aS?ODj7*ku zVRA(l168k9@*^P!Nr?ow(CryWV@X+HnII%UON_F^qTlT*=r-o`yScuU+H*nTD|#Di zH9GHRl2QR;@1C7l=^pI5nRZ;5-Q?m_pPO%*;%_W7ZouhmXCmDat^R?$yCj=i;pViL z@*y7 zB-5^FHFTAMTta+n`OCYQsPYynB}&aJwR`0ToeL=!krhQ}t?1t6u1e))@52oII#nZU z+Ox#zqtVAKqo1h@GL9+Mew2|!jo8&Y%g(YA6!`i^#{Cjfb0nib`&Qjd0dZ#fJO8v} z9%`GsyXU~FTT9SZgCg!@`M5ppCUSxJv^vU2mci_p>7ZT1uVTq$CTuB_&n&wUmt$p6^E7=VDbovI;g%tLE*?E)dPBAQZrxQK06VXkw z6Ps;04An$jqP8pUzD~ONF5Q~ri+fXfr{JzCIK50{mdZ$+N~D)rZuAE_bX+Ijn;l3e zole&|DCd&pE;o5l&;X^3$_-Dfto1iSW%8kqcss97K+$z$V%u3O(5%4b`6bIB*qlo zYFmS;g6|+iv;fLBkqgi^YaDAZ*5os3r|-K*Jh;c@v$)%^26$U^M8u>zIl4q?(Y|(6YKLpX8n6yn1GM=pFf^1^$>fn5 zG&0L@5a|Aq32zC*$Qy5`F#1=pQscLG%ZMbOtvlwbHHjJ8*OSp}MgKoYE3D@H{~Y?i`27DB z9siH;Yxos>0k((B(DnBLIsfm!a29+9Qt(go`}^SQAp8D1;7FJYhr%HsegIFS`(Fc} zgX2K<1{#v*OX2ILxR`mND;RZMnwt>sh?c1Og zUO}J#HVlER_a6$|z}9dvWpELAWnmQe#_vW6)UyQ2NVf4b3>mqwmn9csDqUKGQYxnQ z0%^F8P&Ol5Iam<#$Yxf79%i4;r%Kp&9Whn1w^b#oMy!a{JJt-Pgdb2<1|$>PJ|l`P zNOB~SQZ6PYl6^ywSW6S*;&x)vF)fr1f7zn!LVB=tU!!-!$X!uPV)wHWtGlM2&tk! zt3EEXcg!eR8MsVk9~7-@UqIO=D!u%=cDcZqOw6z{q59gW?E#`f zS>6g&yTzC!9c(2@ccNikWz4(n*1IY@)?i#o@AHoY)Oz2r1~U`m%t|qisg1(KI4c^X zwL_y0ZZ;<3LqZW-(}Lc0&h=)zjcuk74-FOK@J;%&bKr6G|Hoh@48Q_-9$o)VF!q3>`1cO*Z}j||;RKin zd%%;b=_9HcBZgb%>?C>z-W@L8A%4*V5+$zR}7koy1+f=yuqSRWo^-TyxL zG29NTAp9fp!)oHp4Dq7N7qWexVj!-5gQ-Fb@A-d)@3cJOjmjUlWEy_$~JLC%j$Bt{N4h2&uAo~BDA=xF+bI`^}{5md?~ z=N1NX87`~kcAVUt{F2o1iCj|p1KT9>i5_=Oo{c|p*1PqQ?vjV-ZXGz6n>J|P4LwM; z>Tim>b#NNJv)iJ6H=W)sXo-?W)v!&VCPC9pm(^bsb=k6MlBT63Mf8zSA?PP3FH<3_ zqKOBujaC$}1a2D41gpWsB+K#>>GMtTLl9!@Q%T$96M1Z`MT1u*5~E4&?pA75QtKcu zx^oOhx;b*>yEKa-JNqUvdsvBysnl50;<`*yCt6YJ7{_Ol+8m0wv|Lfm*c!mSq8t;c zl5b%f0Gj~SfOV+1?37!~K8_~Y1EGuGvF^l>;?W`|+82ut2C% z$`nr?>>d;j&`(Yvuh8>z1*Bu4w<$hp(5d*g7MNovpQ3^$FQQboQ|S3cy5Q!_3`2X? zNXcu#X&&UI$}^Rqi?>)U%`f23Ouxa24Dz*vhSf9_|Cqe)g;RXXY*j`{~W z)66EDoBbpVsk{@px2ieWN<~a1quz3;)rwNbI6iWyEfR6it|Ch|6I!IDF7)OZ-i239 zvhPgKZ7m5VV$0FRZ06UN5Yyk35o7js4Q5{A%&+dYhDeI(C&m?cfwyuZD^F(Jk6Qe+ zWwqVfYHVI__qMkAlF^=e>AvFTa@m}8A8yt%E}6hndMkBtp)xzTE~mk zQlFHd@Y6y8`2P;6FD6x?H$)%>Z9uBB-P?XERj;(5K({NuCc$p7D?Eb@K<)?F9=3zK(EEP^Uxmdm z8RTw&cft>_0h|h5uroY}&i^qeKpqZ-=`a<$%%x`CINLch^Uzv_8) zm{AX>;J>;1d_zeuX$ov8>BG( zR^7wzwSkfx_M8jON2&5*6l%`~wWp<7>#h-mpwDkpc7`Ka?qOMy+dQmAMy1lQ=;_&b z5k#_Z$#$%K6SpMk{rs}LXf|e5zIYHbs27fGDOfsrS)l(g~>!;^>DS@j1a$ z_>Fg?HA&vZO0qPd;XUHjQxy)a6qhz@3rdYEY9@iZS_zD)xa;kMIpMa#YR zqW>SJJK_(}`BN|rTA&%$gMXm+zXC7Ar7#)3g1-NL_!7E)FZ94%*cX~$GkA&h{U3nb z10Z(*EQ4d15nodA7E|&7}yCMxQ2E8@51L`UwDD?`4wCT zm&4b3U#Ozz zPGvWoVjlz}1>S9SNhJg#3w|}92++qVPWa8S=am1>%Vy>c^triI*HEHU-03`zu%W58 zKi{TSVqz*aA)jBtSB_k@VB~} z=tKUmTUaU31y^;i-O_qjrM}+1cB^QN6myt zxEO?B11;p3czd?5lZr(PE99Lc`;%OKlq~VQ=l?TvAk*V!GaYjidA2Y_!c+_Y#UE1+ z_6Iw0(WO+T|Cv%Z{X(RYmzox&7dUl6>f+;j?~wAA{#=$lD20@p_lr!u;C)RvmQd=+ zCHi|)UDn|@-a~ReA6F1C_R?_k(}J4{syG&XJFDtcg<0`Q4$ZPFi?QB$9lia2TWnS( z;1sXOiUjvAz3^h!oBwIDrEbh1am}W==^f- z|EX{ytOB_MK+Xkx5#9eZm;md;dT=8;{|)dtkTU?bfze8^GEC4kxE&nBh^WLZ1kRB{-8agOW0}FPIXVU zlsC0%H;T^SYak;~<8*KE^$*8ssBCgaqS1ZV5|vH03Tr5@yLzT$e4|t`JNmk*T@uM# zQN>b)JNg}^k%J#>WfJ|a+zhqEDOJ|dG&@>KaLgpNgduL+>~JzYjvpavsBL?5tYoTG zMpS`O&R}d4mlr*@tz+UWqLeY96>H4C-HaE!w~N=jcCwPMOW6`HW2z|-lV|CPTT!YK zQ`WA!^=}jL2d#)j|33oVRCHO<|Bv>xf7aB|`yYcLm-Ln9#TK%wAz^YfCo7M zlGExrvD*Jok3ik2T)xn*jiqw%QdeQtaRZ5T(^CIQCDr@~wV^ZbZAmgwsApuxZcbL3 z-y*(Ig`+)pfJ57q|E1psNYtFVP{TF z9=bEZW-)7U@>`d+1R$TuI9l7z0In4U2N?YM`Eu$$EFL{r3<~?%8=K5^r=^MR}tS4 zinwAIq7+jj9SLm@DKi6dW~~&jjgd@xbr%RNo0f1oDw-V2JhOPAJ(DeHdB_pvR*#E5 zIX9K}{5OMH*BBjX?&o1-mLh)|`cA9L{2b`=$tWSC7P*R!9uWQi|DmVf1ET+DeMLEY!0r43`~P}z|-jT55uK!A)EuoA7DBE-V&}tmlyrM1)AY+ z==6VutKd6uHgv#b*aTz`fSdvNL%0OwE`X0g0hU82Bw$mxl*Atn&!EfS3^#%J0-ONT zU|-l4{)mo$6MPZUa5T(>MH-vy%k-;Z8@ zA6yATV9o}79lOf&@HpHGSHPFyY&aF3UejA0dVQy|5{cCpqtn)g_NnT@ ztlF$KrlYd+R$Eik+GkAVp>m&NV!3W;nkqAI?rx_lqoFNoJ!I|gbtSaLs(Rhpsu}7| z_6CnW4UKPfmsm+HDl5O%%+UGNB1QGP(Cn6NJA;pPxTuxha`i@*eyLhvWLagD3;da^ z);ZXwYFWjGrWToM-({sErV>)GF~?+mV=GD>V|_cIiFlJx#MYEBuvi3Z5+xiL{W2}H zQ%&@L@xlKLi2nb6rT)iWNB8f91ndw0LGPD!{^Q^Pm=4#W^G|{nkUao$-`{2MF_3%z zGSCM*!Z*D3J&ZD>%+_F?vH}p@hA8DErf$$0_+GMfPbOae+s(bK$r^6FcDrw zrx)LU2SlfDg}1}o;COWQUic_1gMHw3^z|=69^|gSUU(Xv{a&~gl3ixzQ9FRE$t7yE&F(yo}8h! zWztahwcme)BDN$&BDtJPs-4V0UniGEa!)e7DbEs7Um}$ehsSOtM*@FG)$M8!AcJnA zKbx7E=;P*FGgoU(?h|xaU(`gOEFntt6hl2bFVW|Rc|gY>?Zh9!uH&Aq=>l(T`5j} z0M!j46(->lp59xbN=4RzyF4g|0J9Zr@_&12*^EuyTH!yI=cR6AqBHwKbQh-@E_6r zVORJvdjH4J$yZWiXK{9Fom7m9TphOV>BG6~H!DNdGUM#*B+( z@D**Wc-G;+md>3pvvQ^pBl8^##CsX*N(IY=x9sO9j>550+{uV_d3F&7)8w-+xP;-S2TG∋jEp|zJ6`O!(Uo(TjN(t7mz zI)!cw0ROQJ0HXhY7v1*)bYju}r)u5x3+VjILGA!J0``Fqz@zB?_rmG05;{Q61r)o$ zB)Aegz$uV`B_QVm&I7q0-~#Lb+rkhwfDPd1=>3nwKH3(rJ-_8lz`vvG_dz@C0J6XT zeUL}bPrwp53R>Y?==BX4LqN{_6F>ip(fxOT3(1?8zd$c<{Hsv{jS^^-z}j8{ zdbshXo%OXJn!({YYjCh*_s6+9PI^rm{!k_jbzl1WV<_Ucxb4x@$Md0LsrC{j+e5&p zjcb)X(q!-jD}#D^Y^Qyl_Ea)?e9_9I>e$t-T-gP&R#)OnRt9w?u06I)QwuL!iPYjX znC(|@)9|J7*myaa)V&f--MCpiOd9@FCJl8L{(lZdY*|L$lD1J(A%6*#OD)oWtnH;H zZGVlVZ7uPnS{rSsrqKRo6`JV(=d*6T8*AL6|1Z-s;^)!z?}W2qC8S|n*bw9lz;DAj za3*{jWIurT1#AHKqw8M}SHM!31v|kj=>9*42jG7AB;;W}>;QkkCh!M%PHY2k7hDJ8 zH?SI(!hY}pkb47Pf@k0{cn}_d`=Jkxf#_J*rqS2!Qr%6TCB45q<5 z;pddKoDnEK26sR=c;&ygYa?&n^r@kmE0hwsoNUK+nw@rU+~%ea&OS+`16QOHr~Y}f z5oe2q;F@&=-x1ES?#OaZ z(DC7gYl2VzQO{&YzTvI0a zt4eOX>NCaFwDV-TaME2h!Q{s@H+9(;RXd#j!x~D3d%&C%e9K7KO({6GF_0i$`9x&` zw{((f5eoh?WeI`8m)&m8&2(|vVCQO;ff}Sjp*NM&LO~Xt#L2fb%V(pCQ5tr2aDj-5{+eSn!Gy7WK3GT*Qri0_wD)ra8wWVMG_T( z>$jUT(uMDXo}dpAT;_(G4?w(EYBI}4h0{qJA8#-uv{uAV~HMv(LN6{Xs^KM3AE46+m z?W>yX{o2Z&Hp~FFM#O( zsPEp-)#&->!ywFnok8^fyLsRmI184+A+SH}2NK42;0`zo24Dp=gRKAm6&?RpkTU`< z0y!g4?g^X+yTjkm>z{^4;EV7vI0kly&0!;WCy3v`ZEyjkVFv6Cd%(6#1>C&c-TbNcNoT2OM+VPzh^G@kFy$bv?LJLzPTqRxH-M kN=0L`_d6?lm2&5AzaK#!$3S5ZZUc$rb0jvG-&?u-{~U>-+5i9m From 822a59babb2612f8155d6377a1732a0e98065319 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 3 Jun 2026 10:52:54 -0500 Subject: [PATCH 08/38] Updated libSmartAttributes to 0.0.4, added support for setWithWorker and noCreate to setSheetItem() usage --- .types/index.d.ts | 6 +- .../0.0.4/libSmartAttributes.js | 75 ++++++++ libSmartAttributes/script.json | 3 +- libSmartAttributes/src/index.ts | 19 +- libSmartAttributes/tests/index.test.ts | 176 +++++++++++++++--- 5 files changed, 239 insertions(+), 40 deletions(-) create mode 100644 libSmartAttributes/0.0.4/libSmartAttributes.js diff --git a/.types/index.d.ts b/.types/index.d.ts index 5c15596436..df420dcfa9 100644 --- a/.types/index.d.ts +++ b/.types/index.d.ts @@ -554,7 +554,11 @@ declare function getAllObjs(): Roll20Object[]; */ declare function getAttrByName(character_id: string, attribute_name: string, value_type?: "current" | "max"): string; -type SheetItemOptions = { allowThrow?: boolean }; +type SheetItemOptions = { + allowThrow?: boolean, + createAttr?: boolean, + withWorker?: boolean +}; type SheetItemValueTYpe = "current" | "max"; /** diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js new file mode 100644 index 0000000000..5c5547ec09 --- /dev/null +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -0,0 +1,75 @@ +// libSmartAttributes v0.0.4 by GUD Team | libSmartAttributes provides an interface for managing beacon attributes in a slightly smarter way. +var libSmartAttributes = (function () { + 'use strict'; + + async function getAttribute(characterId, name, type = "current") { + // Try for a legacy attribute or beacon computed + const attr = await getSheetItem(characterId, name, type); + if (attr !== null && attr !== undefined) { + return attr; + } + // Then try for the user attribute + const userAttr = await getSheetItem(characterId, `user.${name}`, type); + if (userAttr !== null && userAttr !== undefined) { + return userAttr; + } + log(`Attribute ${name} not found on character ${characterId}`); + return undefined; + } + async function setAttribute(characterId, name, value, type = "current", options) { + try { + await setSheetItem(characterId, name, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return; + } + catch { + // throw will happen on beacon sheets if the computed doesn't exist or is read-only + } + // Then default to a user attribute + setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: false, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return; + } + async function deleteAttribute(characterId, name, type = "current") { + // Try for legacy attribute first + const legacyAttr = findObjs({ + _type: "attribute", + _characterid: characterId, + name: name, + })[0]; + if (legacyAttr) { + legacyAttr.remove(); + return; + } + // Then try for the beacon computed + const beaconAttr = await getSheetItem(characterId, name, type); + if (beaconAttr !== null && beaconAttr !== undefined) { + log(`Cannot delete beacon computed attribute ${name} on character ${characterId}. Setting to undefined instead`); + setSheetItem(characterId, name, undefined, type); + return; + } + // Then try for the user attribute + const userAttr = await getSheetItem(characterId, `user.${name}`, type); + if (userAttr !== null && userAttr !== undefined) { + log(`Deleting user attribute ${name} on character ${characterId}`); + setSheetItem(characterId, `user.${name}`, undefined, type); + return; + } + log(`Attribute ${type} not found on character ${characterId}, nothing to delete`); + return; + } + var index = { + getAttribute, + setAttribute, + deleteAttribute, + }; + + return index; + +})(); diff --git a/libSmartAttributes/script.json b/libSmartAttributes/script.json index 9e156eeb23..66de2c135b 100644 --- a/libSmartAttributes/script.json +++ b/libSmartAttributes/script.json @@ -1,6 +1,6 @@ { "name": "libSmartAttributes", - "version": "0.0.3", + "version": "0.0.4", "description": "libSmartAttributes provides an interface for managing beacon attributes in a slightly smarter way.", "authors": "GUD Team", "roll20userid": "8705027", @@ -10,6 +10,7 @@ "script": "libSmartAttributes.js", "useroptions": [], "previousversions": [ + "0.0.3", "0.0.2", "0.0.1" ] diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index 37bcefc170..9d5347d7a3 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -22,6 +22,7 @@ async function getAttribute( }; type SetOptions = { + setWithWorker?: boolean; noCreate?: boolean; }; @@ -34,20 +35,22 @@ async function setAttribute( ) { try { - await setSheetItem(characterId, name, value, type, {allowThrow: true}); + await setSheetItem(characterId, name, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); return; } catch { // throw will happen on beacon sheets if the computed doesn't exist or is read-only } - // Guard against creating user attributes if noCreate is set - if (options?.noCreate) { - log(`Attribute ${name} not found on character ${characterId}, and noCreate option is set. Skipping creation.`); - return; - } - // Then default to a user attribute - setSheetItem(characterId, `user.${name}`, value, type); + setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: false, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); return; }; diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index b3f2e5fc81..5ce35f5fcf 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -1,17 +1,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import SmartAttributes from "../src/index"; -// Mock Roll20 API functions const mockGetSheetItem = vi.fn(); const mockSetSheetItem = vi.fn(); const mockLog = vi.fn(); - -// Setup global mocks vi.stubGlobal("getSheetItem", mockGetSheetItem); vi.stubGlobal("setSheetItem", mockSetSheetItem); vi.stubGlobal("log", mockLog); +/** Matches default setSheetItem options from setAttribute */ +const sheetOpts = (overrides: { + allowThrow?: boolean; + createAttr?: boolean; + withWorker?: boolean; +} = {}) => ({ + allowThrow: true, + createAttr: true, + withWorker: true, + ...overrides, +}); + describe("SmartAttributes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -50,21 +59,21 @@ describe("SmartAttributes", () => { }); it("should handle falsy beacon values correctly", async () => { - mockGetSheetItem.mockResolvedValueOnce(0); // 0 is now treated as valid + mockGetSheetItem.mockResolvedValueOnce(0); const result = await SmartAttributes.getAttribute(characterId, attributeName); - expect(result).toBe(0); // 0 is returned as valid beacon value + expect(result).toBe(0); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); }); it("should handle empty string beacon values correctly", async () => { - mockGetSheetItem.mockResolvedValueOnce(""); // '' is now treated as valid + mockGetSheetItem.mockResolvedValueOnce(""); const result = await SmartAttributes.getAttribute(characterId, attributeName); - expect(result).toBe(""); // Empty string is returned as valid beacon value + expect(result).toBe(""); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); }); @@ -75,61 +84,158 @@ describe("SmartAttributes", () => { const attributeName = "strength"; const value = "18"; - it("should set beacon computed attribute when no legacy attribute but beacon exists", async () => { + it("should set beacon computed attribute when setSheetItem succeeds", async () => { mockSetSheetItem.mockResolvedValue("updated-value"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, value, "current", {allowThrow: true}); + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); expect(result).toBeUndefined(); }); - it("should default to user attribute when no legacy or beacon attribute exists", async () => { + it("should default to user attribute when primary setSheetItem throws", async () => { mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue("user-value"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, value, "current"); + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: false }) + ); expect(result).toBeUndefined(); }); + it("should pass createAttr false when noCreate is set", async () => { + mockSetSheetItem.mockRejectedValueOnce(new Error("missing computed")); + + await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + noCreate: true, + }); + + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true, createAttr: false }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: false, createAttr: false }) + ); + }); + + it("should pass withWorker false when setWithWorker is false", async () => { + mockSetSheetItem.mockResolvedValue("ok"); + + await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + setWithWorker: false, + }); + + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true, withWorker: false }) + ); + }); + it("should handle complex values correctly", async () => { const complexValue = { nested: { value: 42 } }; mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue(complexValue); const result = await SmartAttributes.setAttribute(characterId, attributeName, complexValue); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, complexValue, "current", {allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, complexValue, "current"); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + complexValue, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + complexValue, + "current", + sheetOpts({ allowThrow: false }) + ); expect(result).toBeUndefined(); }); it("should handle null and undefined values", async () => { - mockSetSheetItem.mockResolvedValue(null); mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue(null); const result = await SmartAttributes.setAttribute(characterId, attributeName, null); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, null, "current",{allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, null, "current"); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + null, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + null, + "current", + sheetOpts({ allowThrow: false }) + ); expect(result).toBeUndefined(); }); - it("should handle falsy beacon values correctly for setting", async () => { - mockGetSheetItem.mockResolvedValue(0); // 0 is now treated as valid existing beacon value + it("should succeed on first setSheetItem without fallback", async () => { mockSetSheetItem.mockResolvedValue("updated"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, value,"current",{allowThrow:true}); + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); expect(result).toBeUndefined(); }); }); @@ -149,7 +255,7 @@ describe("SmartAttributes", () => { }); it("should handle boolean values in attributes", async () => { - mockGetSheetItem.mockResolvedValueOnce(false); // Test with false to show falsy values are valid + mockGetSheetItem.mockResolvedValueOnce(false); const result = await SmartAttributes.getAttribute(characterId, attributeName); @@ -165,11 +271,9 @@ describe("SmartAttributes", () => { mockGetSheetItem.mockResolvedValue("beacon-10"); mockSetSheetItem.mockResolvedValue("beacon-15"); - // Get current value const currentValue = await SmartAttributes.getAttribute(characterId, attributeName); expect(currentValue).toBe("beacon-10"); - // Set new value const result = await SmartAttributes.setAttribute(characterId, attributeName, "beacon-15"); expect(result).toBeUndefined(); }); @@ -177,20 +281,32 @@ describe("SmartAttributes", () => { it("should handle get returning undefined but set still working", async () => { mockGetSheetItem.mockResolvedValue(null); mockSetSheetItem - .mockImplementationOnce(()=>{throw new Error("missing computed");}) + .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue("new-value"); - // Get returns undefined const currentValue = await SmartAttributes.getAttribute(characterId, attributeName); expect(currentValue).toBeUndefined(); - // But set still works by creating user attribute const result = await SmartAttributes.setAttribute(characterId, attributeName, "new-value"); expect(result).toBeUndefined(); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "new-value", "current",{allowThrow:true}); - expect(mockSetSheetItem).toHaveBeenCalledWith(characterId, `user.${attributeName}`, "new-value", "current"); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 1, + characterId, + attributeName, + "new-value", + "current", + sheetOpts({ allowThrow: true }) + ); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + "new-value", + "current", + sheetOpts({ allowThrow: false }) + ); }); }); }); From 800ab25b58d65e150088596d8ce811ba593e7a94 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 3 Jun 2026 13:02:43 -0500 Subject: [PATCH 09/38] Updated to support setSheetItem/libSmartAttributes optional create and optional with workers --- ChatSetAttr/2.0/ChatSetAttr.js | 367 +++++++++--------- ChatSetAttr/ChatSetAttr.js | 367 +++++++++--------- .../src/__tests__/unit/attributes.test.ts | 37 +- ChatSetAttr/src/modules/attributes.ts | 7 +- ChatSetAttr/src/modules/main.ts | 2 +- ChatSetAttr/src/modules/updates.ts | 14 +- 6 files changed, 406 insertions(+), 388 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 56e97685eb..92b306614c 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -13,38 +13,6 @@ var ChatSetAttr = (function (exports) { 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(); @@ -91,6 +59,190 @@ var ChatSetAttr = (function (exports) { marginBottom: "0.5em", }; + const CONFIG_WRAPPER_STYLE = s(frameStyleBase); + const CONFIG_HEADER_STYLE = s(headerStyleBase); + const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", + }); + const CONFIG_ROW_STYLE = s({ + marginBottom: "4px", + }); + const CONFIG_BUTTON_STYLE_ON = s({ + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", + }); + const CONFIG_BUTTON_STYLE_OFF = s({ + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", + }); + 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", null, + 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 buildSetAttributeOptions(overrides = {}) { + const { useWorkers = true } = getConfig() || {}; + return { + noCreate: overrides.noCreate ?? false, + setWithWorker: overrides.setWithWorker ?? useWorkers, + }; + } + async function makeUpdate(operation, results, options) { + const isSetting = operation !== "delattr"; + const errors = []; + const messages = []; + const { noCreate = false } = options || {}; + const setOptions = buildSetAttributeOptions({ noCreate }); + 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 }; + } + + // #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; + } + const DELAY_WRAPPER_STYLE = s(frameStyleBase); const DELAY_HEADER_STYLE = s(headerStyleBase); function createDelayMessage() { @@ -540,118 +692,6 @@ var ChatSetAttr = (function (exports) { return Math.max(Math.min(currentValue, maxValue), 0); } - const CONFIG_WRAPPER_STYLE = s(frameStyleBase); - const CONFIG_HEADER_STYLE = s(headerStyleBase); - const CONFIG_TABLE_STYLE = s({ - width: "100%", - border: "none", - borderCollapse: "separate", - borderSpacing: "0 4px", - }); - const CONFIG_ROW_STYLE = s({ - marginBottom: "4px", - }); - const CONFIG_BUTTON_STYLE_ON = s({ - ...buttonStyleBase, - backgroundColor: "#16A34A", - color: "#FFFFFF", - fontWeight: "500", - }); - const CONFIG_BUTTON_STYLE_OFF = s({ - ...buttonStyleBase, - backgroundColor: "#DC2626", - color: "#FFFFFF", - fontWeight: "500", - }); - 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", null, - 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", @@ -1819,43 +1859,6 @@ var ChatSetAttr = (function (exports) { } } - async function makeUpdate(operation, results, options) { - const isSetting = operation !== "delattr"; - const errors = []; - const messages = []; - const { noCreate = false } = {}; - const { useWorkers = true } = getConfig() || {}; - const setOptions = { - noCreate, - setWithWorker: useWorkers, - }; - 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.`); } @@ -1919,7 +1922,7 @@ var ChatSetAttr = (function (exports) { messages.push(...response.messages); result[target] = response.result; } - const updateResult = await makeUpdate(operation, result); + const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate }); clearTimer("chatsetattr"); messages.push(...updateResult.messages); errors.push(...updateResult.errors); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 56e97685eb..92b306614c 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -13,38 +13,6 @@ var ChatSetAttr = (function (exports) { 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(); @@ -91,6 +59,190 @@ var ChatSetAttr = (function (exports) { marginBottom: "0.5em", }; + const CONFIG_WRAPPER_STYLE = s(frameStyleBase); + const CONFIG_HEADER_STYLE = s(headerStyleBase); + const CONFIG_TABLE_STYLE = s({ + width: "100%", + border: "none", + borderCollapse: "separate", + borderSpacing: "0 4px", + }); + const CONFIG_ROW_STYLE = s({ + marginBottom: "4px", + }); + const CONFIG_BUTTON_STYLE_ON = s({ + ...buttonStyleBase, + backgroundColor: "#16A34A", + color: "#FFFFFF", + fontWeight: "500", + }); + const CONFIG_BUTTON_STYLE_OFF = s({ + ...buttonStyleBase, + backgroundColor: "#DC2626", + color: "#FFFFFF", + fontWeight: "500", + }); + 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", null, + 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 buildSetAttributeOptions(overrides = {}) { + const { useWorkers = true } = getConfig() || {}; + return { + noCreate: overrides.noCreate ?? false, + setWithWorker: overrides.setWithWorker ?? useWorkers, + }; + } + async function makeUpdate(operation, results, options) { + const isSetting = operation !== "delattr"; + const errors = []; + const messages = []; + const { noCreate = false } = options || {}; + const setOptions = buildSetAttributeOptions({ noCreate }); + 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 }; + } + + // #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; + } + const DELAY_WRAPPER_STYLE = s(frameStyleBase); const DELAY_HEADER_STYLE = s(headerStyleBase); function createDelayMessage() { @@ -540,118 +692,6 @@ var ChatSetAttr = (function (exports) { return Math.max(Math.min(currentValue, maxValue), 0); } - const CONFIG_WRAPPER_STYLE = s(frameStyleBase); - const CONFIG_HEADER_STYLE = s(headerStyleBase); - const CONFIG_TABLE_STYLE = s({ - width: "100%", - border: "none", - borderCollapse: "separate", - borderSpacing: "0 4px", - }); - const CONFIG_ROW_STYLE = s({ - marginBottom: "4px", - }); - const CONFIG_BUTTON_STYLE_ON = s({ - ...buttonStyleBase, - backgroundColor: "#16A34A", - color: "#FFFFFF", - fontWeight: "500", - }); - const CONFIG_BUTTON_STYLE_OFF = s({ - ...buttonStyleBase, - backgroundColor: "#DC2626", - color: "#FFFFFF", - fontWeight: "500", - }); - 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", null, - 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", @@ -1819,43 +1859,6 @@ var ChatSetAttr = (function (exports) { } } - async function makeUpdate(operation, results, options) { - const isSetting = operation !== "delattr"; - const errors = []; - const messages = []; - const { noCreate = false } = {}; - const { useWorkers = true } = getConfig() || {}; - const setOptions = { - noCreate, - setWithWorker: useWorkers, - }; - 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.`); } @@ -1919,7 +1922,7 @@ var ChatSetAttr = (function (exports) { messages.push(...response.messages); result[target] = response.result; } - const updateResult = await makeUpdate(operation, result); + const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate }); clearTimer("chatsetattr"); messages.push(...updateResult.messages); errors.push(...updateResult.errors); diff --git a/ChatSetAttr/src/__tests__/unit/attributes.test.ts b/ChatSetAttr/src/__tests__/unit/attributes.test.ts index ddd136b151..9e8e0260cd 100644 --- a/ChatSetAttr/src/__tests__/unit/attributes.test.ts +++ b/ChatSetAttr/src/__tests__/unit/attributes.test.ts @@ -22,6 +22,9 @@ const mocklibSmartAttributes = { // Setup global libSmartAttributes mock global.libSmartAttributes = mocklibSmartAttributes; +/** Matches vitest.setup state (useWorkers: true) and buildSetAttributeOptions defaults */ +const defaultSetOptions = { noCreate: false, setWithWorker: true }; + describe("attributes module", () => { beforeEach(() => { vi.clearAllMocks(); @@ -140,7 +143,7 @@ describe("attributes module", () => { await setSingleAttribute(target, "strength", 18, options); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", options); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", defaultSetOptions); }); it("should set max attribute when isMax is true", async () => { @@ -148,7 +151,7 @@ describe("attributes module", () => { await setSingleAttribute(target, "hp", 100, options, true); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", options); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", defaultSetOptions); }); it("should handle string values", async () => { @@ -156,7 +159,7 @@ describe("attributes module", () => { await setSingleAttribute(target, "name", "Test Character", options); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "name", "Test Character", "current", options); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "name", "Test Character", "current", defaultSetOptions); }); it("should handle boolean values", async () => { @@ -164,7 +167,7 @@ describe("attributes module", () => { await setSingleAttribute(target, "isDead", false, options); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "isDead", false, "current", options); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "isDead", false, "current", defaultSetOptions); }); it("should handle numeric values", async () => { @@ -172,7 +175,7 @@ describe("attributes module", () => { await setSingleAttribute(target, "level", 5, options); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "level", 5, "current", options); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "level", 5, "current", defaultSetOptions); }); }); @@ -189,7 +192,7 @@ describe("attributes module", () => { await setAttributes(target, attributes, options); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", options); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", defaultSetOptions); }); it("should set single attribute with max value", async () => { @@ -201,7 +204,7 @@ describe("attributes module", () => { await setAttributes(target, attributes, options); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", options); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "hp", 100, "max", defaultSetOptions); }); it("should set both current and max values", async () => { @@ -214,8 +217,8 @@ describe("attributes module", () => { 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); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "hp", 75, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "hp", 100, "max", defaultSetOptions); }); it("should set multiple attributes", async () => { @@ -230,10 +233,10 @@ describe("attributes module", () => { 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); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "strength", 18, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "dexterity", 16, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(3, target, "hp", 75, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(4, target, "hp", 100, "max", defaultSetOptions); }); it("should handle different value types", async () => { @@ -248,9 +251,9 @@ describe("attributes module", () => { 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); + expect(mockSetAttribute).toHaveBeenNthCalledWith(1, target, "name", "Test Character", "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(2, target, "level", 5, "current", defaultSetOptions); + expect(mockSetAttribute).toHaveBeenNthCalledWith(3, target, "isDead", false, "current", defaultSetOptions); }); it("should throw error if attribute has no name", async () => { @@ -421,7 +424,7 @@ describe("attributes module", () => { // Set attribute await setAttributes(target, [{ name: "strength", current: 18 }], {}); - expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", {}); + expect(mockSetAttribute).toHaveBeenCalledWith(target, "strength", 18, "current", defaultSetOptions); // Mock that attribute now exists mockGetAttribute.mockResolvedValue(18); diff --git a/ChatSetAttr/src/modules/attributes.ts b/ChatSetAttr/src/modules/attributes.ts index d35f9c6205..84a97aef5e 100644 --- a/ChatSetAttr/src/modules/attributes.ts +++ b/ChatSetAttr/src/modules/attributes.ts @@ -1,4 +1,5 @@ import type { Attribute, AttributeRecord, AttributeValue } from "../types"; +import { buildSetAttributeOptions } from "./updates"; // #region Get Attributes async function getSingleAttribute(target: string, attributeName: string): Promise { @@ -43,7 +44,11 @@ export async function setSingleAttribute( isMax?: boolean ): Promise { const type = isMax ? "max" : "current"; - await libSmartAttributes.setAttribute(target, attributeName, value, type, options); + const setOptions = buildSetAttributeOptions({ + noCreate: options.noCreate, + setWithWorker: options.setWithWorker, + }); + await libSmartAttributes.setAttribute(target, attributeName, value, type, setOptions); }; export async function setAttributes( diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index a8c9d91b50..6c6c50ee6a 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -99,7 +99,7 @@ async function acceptMessage(msg: Roll20ChatMessage) { result[target] = response.result; } - const updateResult = await makeUpdate(operation, result); + const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate }); clearTimer("chatsetattr"); diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts index e16c70695c..bf47dc9c1d 100644 --- a/ChatSetAttr/src/modules/updates.ts +++ b/ChatSetAttr/src/modules/updates.ts @@ -10,6 +10,14 @@ type UpdateResult = { messages: string[]; }; +export function buildSetAttributeOptions(overrides: { noCreate?: boolean; setWithWorker?: boolean } = {}) { + const { useWorkers = true } = getConfig() || {}; + return { + noCreate: overrides.noCreate ?? false, + setWithWorker: overrides.setWithWorker ?? useWorkers, + }; +}; + export async function makeUpdate( operation: Command, results: Record, @@ -20,11 +28,7 @@ export async function makeUpdate( const messages: string[] = []; const { noCreate = false } = options || {}; - const { useWorkers = true } = getConfig() || {}; - const setOptions = { - noCreate, - setWithWorker: useWorkers, - }; + const setOptions = buildSetAttributeOptions({ noCreate }); for (const target in results) { for (const name in results[target]) { From 0d3d090da42a271e8b8f936432be97fe7fd0320d Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 3 Jun 2026 13:32:14 -0500 Subject: [PATCH 10/38] Added handling for readonly computeds. --- .../0.0.4/.libSmartAttributes.js.swp | Bin 0 -> 12288 bytes .../0.0.4/libSmartAttributes.js | 7 ++- libSmartAttributes/src/.index.ts.swp | Bin 0 -> 12288 bytes libSmartAttributes/src/index.ts | 13 ++++- libSmartAttributes/tests/index.test.ts | 45 ++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 libSmartAttributes/0.0.4/.libSmartAttributes.js.swp create mode 100644 libSmartAttributes/src/.index.ts.swp diff --git a/libSmartAttributes/0.0.4/.libSmartAttributes.js.swp b/libSmartAttributes/0.0.4/.libSmartAttributes.js.swp new file mode 100644 index 0000000000000000000000000000000000000000..a9d953656364553000eaa19a06273a84aab35f85 GIT binary patch literal 12288 zcmeHN&2QX96ra)nEu~PE&r>zoM$JZAyxW3GNfd>uQL6|lP?NL=K9X5`cJ0J#FZMXw zRakoC!i7HoaX~6v;KGRuM^4&2riw5{I8CQTgtxXwqBV_71&hwLYb!umDe zbGy@MI1HxkL2mJGY_b`md_y5EH|37?| zvGc$ba2oja8OFW>1YiI!0LOsek2Cf;unJrN4B*eF8T%Rd3Ge{{aNyr#jNJw<1LuJX z@G@`&_~t3bz61i`EN~L|@hD?W;5u*_coX>N2y_Ftflq-o;0ka7I0f8&lCe9$Z@~A! zCQt*udxEhp@IG)2mk zQ$L!4hmL{4Om$`swTUN?DhfRAc$U1G4h`nw8Je|lOGnA)kEB2S-uQ5q;OOk;3p*_Ki*DcPwOG}rig6w1Ju+V(_84%TZNmdKXlNvp=m zz``n|Cw5|yXd`3jRW62Cn_zRS;k(K8mqmxM=ok>n?D zN&$!Abf=3OWY+&<+~CVn#nD3QWMm?1LnVb(jz_XoM*c7&rQuA?-k205?;Vwdk1=9V zVt~bw7%LdAYzwnb4jF;$_*;@%!|-*=z+9l{%wXod#s|3|y+Fp!#y|6pK;vs!IXTvB zUcpONNsQc_>GZtDT7(=cWK>l!9dHo zk&W|t)OVIPd;ygfNuEjX7G|V#_?UqdNsrE?7Yxt86c6vGb~3dh6U@0Ly~1_<-ijUg zy;7aa*9Op`yP z{vC4UEOn=>8Cbpyy=leeO$RLzy~_!)0|@IC-*fvcV^Jv6E?AkzmL$sYrLl61Y3xD9 zne;!1Sd-pjyeaYWaL2Rp-m!n+lPcLK+_YmPC0Z&x=%cO^U-}(Ybp?IJ;;Rh{Xum4V zt0-?zdyW=l@|Z`9;tnt>E3#cSU+&S_xZ@7G+cE0TyG|q7K-Rv7p`qkST6ZjSANyU} z)&<|w*iq1W=T}x2`HB=B{_!ZeZs2b@mJGR|gEI{xB@V!gNsA*HJA_jmOjNZTOsGL`Pci|i32-W*s$BnE2?AbkL$b<+yYA#fY2Tr0=|~`4!9!7Uo!VTJ2Xdd3fm4g52uopLl^o9&t3?@ZZGh=D3K#{3 zP+%u}YVyfA)gQ}^(tY>7IK(?I9d0!9I&fKk9GU=%P47zK<1MggOMQNSp09V)@u}{Ej;1GBo%z(e4@n7H~_ye2==fKzC3!wJkQ(!(u0i%FXAXcC=#fWU&l9EE- zjVhoi6y3X0>UpW}S**7FK;peRk6cOXjK%W=LPmk7W_10f{Gy(a#@6LpOLWNdCDr}N zv&r|!YVd$tQUvJMKUeKeK@Dp--V#YaHEnTxiec6cAnL9Xn>p7ILRO_{=6fb7&|(yd zpg6Yf@uui376^-qy5kAEK*|#0|WXII2vR{Zi-wkpGjoI2&zyUJl2 ztyIX1T$lFji6_*J+JbQiMr8S3D7zyn#M|7FO6Vay(r%PWG~W;&$zToY1|%C|z?CZ% zPx-y2Wc#_N_A&_^stFEn;8C-Mt9AC3Nm!&gA!GTm-C84ZppM{nSKwq3t!5_iK(n5p zSLB{C+smBzzNNSW{$5^PS1fUBP1#!vMAJVi@;X+!UCK(tLTCKdiG?I0)$(PEjX(#{ z5gpZzUGC;g)mB-FZe?hhay*OLoN7;EtGf(U2(fR^)|SJ(iaixEX|N=NdbX_Y1fgU+ zT=OidN1i1eZ0$gW=uNdUg}77cEg@}V)3c|yLx$FAv`9|Qk%T(uXC{#1-8!~Z^ayLSHDS--cDaaBytEWf=zG((yuvAM^ z+d+|O@Ky_{k1a}rGF0U-R3%B30iBYzFGBBeNn+JO)j^SPLKISNKjC}s8m#Y-#iiAV z&FL?(*`^Q{y!P92Z8wIfcWYEkq#G8O*3EY)$@vZYq6#YVGbtrmAeL3VnoXh-!fg}g zYW;ZLGQxGm^V^B|&6>m08l$I}EFOhH($lPRAbxLN#8m#OCaxau4qz8Klfl-@37 zYmfCv$NrPMn^CG3Z8*b~)kB`)mpQQYQK#mjqttdXJyD_o@z_ tv_^BmPzI4kR6o^8=-w-lSZUujXiq{RhdE(=h-5 literal 0 HcmV?d00001 diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index 9d5347d7a3..f1a6872e63 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -26,6 +26,12 @@ type SetOptions = { noCreate?: boolean; }; +type SheetItemError = Error & { + type: string; + details?: Record; +}; + + async function setAttribute( characterId: string, name: string, @@ -41,8 +47,13 @@ async function setAttribute( withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker }); return; - } catch { + } catch (e) { // throw will happen on beacon sheets if the computed doesn't exist or is read-only + switch((e as SheetItemError).type){ + // for read only computeds, we don't want to make a shadow "user." version. + case "COMPUTED_READONLY": + return; + } } // Then default to a user attribute diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index 5ce35f5fcf..6ef0629bc1 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -21,6 +21,13 @@ const sheetOpts = (overrides: { ...overrides, }); +/** Mimics setSheetItem errors from displayErrorMessage(..., true, errorType, details) */ +const sheetItemError = (type: string, message = "setSheetItem failed") => { + const err = new Error(message) as Error & { type: string; details?: Record }; + err.type = type; + return err; +}; + describe("SmartAttributes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -127,6 +134,44 @@ describe("SmartAttributes", () => { expect(result).toBeUndefined(); }); + it("should not create user attribute when computed is read-only", async () => { + mockSetSheetItem.mockRejectedValueOnce( + sheetItemError("COMPUTED_READONLY", 'ERROR: Readonly Property "strength".') + ); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( + characterId, + attributeName, + value, + "current", + sheetOpts({ allowThrow: true }) + ); + expect(result).toBeUndefined(); + }); + + it("should still fall through to user attribute for non-readonly setSheetItem errors", async () => { + mockSetSheetItem + .mockRejectedValueOnce( + sheetItemError("COMPUTED_INVALID", 'ERROR: Property "strength" doesn\'t exist.') + ) + .mockResolvedValue("user-value"); + + await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(mockSetSheetItem).toHaveBeenNthCalledWith( + 2, + characterId, + `user.${attributeName}`, + value, + "current", + sheetOpts({ allowThrow: false }) + ); + }); + it("should pass createAttr false when noCreate is set", async () => { mockSetSheetItem.mockRejectedValueOnce(new Error("missing computed")); From 3032fea1e191d05356366df297e73f301816551e Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 3 Jun 2026 14:12:19 -0500 Subject: [PATCH 11/38] Adding reporting on success with set and delete --- .../0.0.4/libSmartAttributes.js | 54 ++++-- libSmartAttributes/src/index.ts | 51 +++-- libSmartAttributes/src/types.d.ts | 4 +- libSmartAttributes/tests/index.test.ts | 179 ++++++++++++------ 4 files changed, 197 insertions(+), 91 deletions(-) diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js index a947031e33..238538337c 100644 --- a/libSmartAttributes/0.0.4/libSmartAttributes.js +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -23,23 +23,28 @@ var libSmartAttributes = (function () { createAttr: options?.noCreate === undefined ? true : !options.noCreate, withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker }); - return; + return true; } catch (e) { // throw will happen on beacon sheets if the computed doesn't exist or is read-only switch (e.type) { // for read only computeds, we don't want to make a shadow "user." version. case "COMPUTED_READONLY": - return; + return false; } } // Then default to a user attribute - setSheetItem(characterId, `user.${name}`, value, type, { - allowThrow: false, - createAttr: options?.noCreate === undefined ? true : !options.noCreate, - withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker - }); - return; + try { + await setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } + catch { + return false; + } } async function deleteAttribute(characterId, name, type = "current") { // Try for legacy attribute first @@ -50,24 +55,39 @@ var libSmartAttributes = (function () { })[0]; if (legacyAttr) { legacyAttr.remove(); - return; + return true; } // Then try for the beacon computed const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - log(`Cannot delete beacon computed attribute ${name} on character ${characterId}. Setting to undefined instead`); - setSheetItem(characterId, name, undefined, type); - return; + // Cannot delete beacon computed attributes. Setting to undefined instead. + try { + await setSheetItem(characterId, name, undefined, type, { allowThrow: true }); + return true; + } + catch (e) { + switch (e.type) { + // for read only computeds, we don't want to fall through to a "user." version. + case "COMPUTED_READONLY": + return false; + } + } } // Then try for the user attribute const userAttr = await getSheetItem(characterId, `user.${name}`, type); if (userAttr !== null && userAttr !== undefined) { - log(`Deleting user attribute ${name} on character ${characterId}`); - setSheetItem(characterId, `user.${name}`, undefined, type); - return; + try { + await setSheetItem(characterId, `user.${name}`, undefined, type, { + allowThrow: true, + createAttr: false + }); + return true; + } + catch { + return false; + } } - log(`Attribute ${type} not found on character ${characterId}, nothing to delete`); - return; + return false; } var index = { getAttribute, diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index f1a6872e63..17e8eb6385 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -46,23 +46,27 @@ async function setAttribute( createAttr: options?.noCreate === undefined ? true : !options.noCreate, withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker }); - return; + return true; } catch (e) { // throw will happen on beacon sheets if the computed doesn't exist or is read-only switch((e as SheetItemError).type){ // for read only computeds, we don't want to make a shadow "user." version. case "COMPUTED_READONLY": - return; + return false; } } // Then default to a user attribute - setSheetItem(characterId, `user.${name}`, value, type, { - allowThrow: false, - createAttr: options?.noCreate === undefined ? true : !options.noCreate, - withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker - }); - return; + try { + await setSheetItem(characterId, `user.${name}`, value, type, { + allowThrow: true, + createAttr: options?.noCreate === undefined ? true : !options.noCreate, + withWorker: options?.setWithWorker === undefined ? true : options.setWithWorker + }); + return true; + } catch { + return false; + } }; async function deleteAttribute(characterId: string, name: string, type: AttributeType = "current") { @@ -75,27 +79,40 @@ async function deleteAttribute(characterId: string, name: string, type: Attribut if (legacyAttr) { legacyAttr.remove(); - return; + return true; } // Then try for the beacon computed const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - log(`Cannot delete beacon computed attribute ${name} on character ${characterId}. Setting to undefined instead`); - setSheetItem(characterId, name, undefined, type); - return; + // Cannot delete beacon computed attributes. Setting to undefined instead. + try { + await setSheetItem(characterId, name, undefined, type, { allowThrow: true }); + return true; + } catch (e) { + switch((e as SheetItemError).type){ + // for read only computeds, we don't want to fall through to a "user." version. + case "COMPUTED_READONLY": + return false; + } + } } // Then try for the user attribute const userAttr = await getSheetItem(characterId, `user.${name}`, type); if (userAttr !== null && userAttr !== undefined) { - log(`Deleting user attribute ${name} on character ${characterId}`); - setSheetItem(characterId, `user.${name}`, undefined, type); - return; + try { + await setSheetItem(characterId, `user.${name}`, undefined, type, { + allowThrow: true, + createAttr: false + }); + return true; + } catch { + return false; + } } - log(`Attribute ${type} not found on character ${characterId}, nothing to delete`); - return; + return false; }; export default { diff --git a/libSmartAttributes/src/types.d.ts b/libSmartAttributes/src/types.d.ts index b881d3d2ae..d76b339e20 100644 --- a/libSmartAttributes/src/types.d.ts +++ b/libSmartAttributes/src/types.d.ts @@ -1,5 +1,5 @@ declare namespace SmartAttributes { function getAttribute(characterId: string, name: string, type?: "current" | "max"): Promise; - function setAttribute(characterId: string, name: string, value: unknown, type?: "current" | "max", options?: { setWithWorker?: boolean, noCreate?: boolean }): Promise; - function deleteAttribute(characterId: string, name: string, type?: "current" | "max", options?: { setWithWorker?: boolean }): Promise; + function setAttribute(characterId: string, name: string, value: unknown, type?: "current" | "max", options?: { setWithWorker?: boolean, noCreate?: boolean }): Promise; + function deleteAttribute(characterId: string, name: string, type?: "current" | "max"): Promise; } \ No newline at end of file diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index 6ef0629bc1..437c6ad033 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import SmartAttributes from "../src/index"; +const mockFindObjs = vi.fn(); const mockGetSheetItem = vi.fn(); const mockSetSheetItem = vi.fn(); const mockLog = vi.fn(); +vi.stubGlobal("findObjs", mockFindObjs); vi.stubGlobal("getSheetItem", mockGetSheetItem); vi.stubGlobal("setSheetItem", mockSetSheetItem); vi.stubGlobal("log", mockLog); @@ -31,6 +33,7 @@ const sheetItemError = (type: string, message = "setSheetItem failed") => { describe("SmartAttributes", () => { beforeEach(() => { vi.clearAllMocks(); + mockFindObjs.mockReturnValue([]); }); describe("getAttribute", () => { @@ -91,7 +94,7 @@ describe("SmartAttributes", () => { const attributeName = "strength"; const value = "18"; - it("should set beacon computed attribute when setSheetItem succeeds", async () => { + it("should return true when setSheetItem succeeds on computed", async () => { mockSetSheetItem.mockResolvedValue("updated-value"); const result = await SmartAttributes.setAttribute(characterId, attributeName, value); @@ -104,10 +107,10 @@ describe("SmartAttributes", () => { "current", sheetOpts({ allowThrow: true }) ); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should default to user attribute when primary setSheetItem throws", async () => { + it("should return true when falling through to user attribute", async () => { mockSetSheetItem .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue("user-value"); @@ -129,12 +132,12 @@ describe("SmartAttributes", () => { `user.${attributeName}`, value, "current", - sheetOpts({ allowThrow: false }) + sheetOpts({ allowThrow: true }) ); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should not create user attribute when computed is read-only", async () => { + it("should return false and not create user attribute when computed is read-only", async () => { mockSetSheetItem.mockRejectedValueOnce( sheetItemError("COMPUTED_READONLY", 'ERROR: Readonly Property "strength".') ); @@ -149,17 +152,17 @@ describe("SmartAttributes", () => { "current", sheetOpts({ allowThrow: true }) ); - expect(result).toBeUndefined(); + expect(result).toBe(false); }); - it("should still fall through to user attribute for non-readonly setSheetItem errors", async () => { + it("should return true when falling through for non-readonly setSheetItem errors", async () => { mockSetSheetItem .mockRejectedValueOnce( sheetItemError("COMPUTED_INVALID", 'ERROR: Property "strength" doesn\'t exist.') ) .mockResolvedValue("user-value"); - await SmartAttributes.setAttribute(characterId, attributeName, value); + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); expect(mockSetSheetItem).toHaveBeenNthCalledWith( @@ -168,14 +171,28 @@ describe("SmartAttributes", () => { `user.${attributeName}`, value, "current", - sheetOpts({ allowThrow: false }) + sheetOpts({ allowThrow: true }) ); + expect(result).toBe(true); + }); + + it("should return false when user attribute fallback also fails", async () => { + mockSetSheetItem + .mockRejectedValueOnce(new Error("missing computed")) + .mockRejectedValueOnce(new Error("user set failed")); + + const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(2); + expect(result).toBe(false); }); it("should pass createAttr false when noCreate is set", async () => { - mockSetSheetItem.mockRejectedValueOnce(new Error("missing computed")); + mockSetSheetItem + .mockRejectedValueOnce(new Error("missing computed")) + .mockResolvedValue("user-value"); - await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + const result = await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { noCreate: true, }); @@ -193,14 +210,15 @@ describe("SmartAttributes", () => { `user.${attributeName}`, value, "current", - sheetOpts({ allowThrow: false, createAttr: false }) + sheetOpts({ allowThrow: true, createAttr: false }) ); + expect(result).toBe(true); }); it("should pass withWorker false when setWithWorker is false", async () => { mockSetSheetItem.mockResolvedValue("ok"); - await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { + const result = await SmartAttributes.setAttribute(characterId, attributeName, value, "current", { setWithWorker: false, }); @@ -211,9 +229,10 @@ describe("SmartAttributes", () => { "current", sheetOpts({ allowThrow: true, withWorker: false }) ); + expect(result).toBe(true); }); - it("should handle complex values correctly", async () => { + it("should return true for complex values via user fallback", async () => { const complexValue = { nested: { value: 42 } }; mockSetSheetItem .mockRejectedValueOnce(new Error("missing computed")) @@ -222,26 +241,10 @@ describe("SmartAttributes", () => { const result = await SmartAttributes.setAttribute(characterId, attributeName, complexValue); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenNthCalledWith( - 1, - characterId, - attributeName, - complexValue, - "current", - sheetOpts({ allowThrow: true }) - ); - expect(mockSetSheetItem).toHaveBeenNthCalledWith( - 2, - characterId, - `user.${attributeName}`, - complexValue, - "current", - sheetOpts({ allowThrow: false }) - ); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should handle null and undefined values", async () => { + it("should return true when setting null via user fallback", async () => { mockSetSheetItem .mockRejectedValueOnce(new Error("missing computed")) .mockResolvedValue(null); @@ -249,39 +252,105 @@ describe("SmartAttributes", () => { const result = await SmartAttributes.setAttribute(characterId, attributeName, null); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); - expect(mockSetSheetItem).toHaveBeenNthCalledWith( - 1, + expect(result).toBe(true); + }); + }); + + describe("deleteAttribute", () => { + const characterId = "char123"; + const attributeName = "strength"; + + it("should return true when removing a legacy attribute", async () => { + const mockRemove = vi.fn(); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockFindObjs).toHaveBeenCalledWith({ + _type: "attribute", + _characterid: characterId, + name: attributeName, + }); + expect(mockRemove).toHaveBeenCalled(); + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should return true when clearing a writable beacon computed", async () => { + mockGetSheetItem.mockResolvedValueOnce("10"); + mockSetSheetItem.mockResolvedValue(true); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); + expect(mockSetSheetItem).toHaveBeenCalledWith( characterId, attributeName, - null, + undefined, "current", - sheetOpts({ allowThrow: true }) + { allowThrow: true } ); - expect(mockSetSheetItem).toHaveBeenNthCalledWith( - 2, + expect(result).toBe(true); + }); + + it("should return false and not touch user attribute when beacon computed is read-only", async () => { + mockGetSheetItem.mockResolvedValueOnce("10"); + mockSetSheetItem.mockRejectedValueOnce( + sheetItemError("COMPUTED_READONLY", 'ERROR: Readonly Property "strength".') + ); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockSetSheetItem).toHaveBeenCalledWith( characterId, - `user.${attributeName}`, - null, + attributeName, + undefined, "current", - sheetOpts({ allowThrow: false }) + { allowThrow: true } ); - expect(result).toBeUndefined(); + expect(mockGetSheetItem).toHaveBeenCalledTimes(1); + expect(result).toBe(false); }); - it("should succeed on first setSheetItem without fallback", async () => { - mockSetSheetItem.mockResolvedValue("updated"); + it("should return true when deleting an existing user attribute", async () => { + mockGetSheetItem + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("user-value"); + mockSetSheetItem.mockResolvedValue(true); - const result = await SmartAttributes.setAttribute(characterId, attributeName, value); + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); - expect(mockSetSheetItem).toHaveBeenCalledTimes(1); + expect(mockGetSheetItem).toHaveBeenNthCalledWith(1, characterId, attributeName, "current"); + expect(mockGetSheetItem).toHaveBeenNthCalledWith(2, characterId, `user.${attributeName}`, "current"); expect(mockSetSheetItem).toHaveBeenCalledWith( characterId, - attributeName, - value, + `user.${attributeName}`, + undefined, "current", - sheetOpts({ allowThrow: true }) + { allowThrow: true, createAttr: false } ); - expect(result).toBeUndefined(); + expect(result).toBe(true); + }); + + it("should return false when no attribute exists", async () => { + mockGetSheetItem.mockResolvedValue(null); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false when user attribute delete fails", async () => { + mockGetSheetItem + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("user-value"); + mockSetSheetItem.mockRejectedValueOnce(new Error("delete failed")); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(result).toBe(false); }); }); @@ -320,10 +389,10 @@ describe("SmartAttributes", () => { expect(currentValue).toBe("beacon-10"); const result = await SmartAttributes.setAttribute(characterId, attributeName, "beacon-15"); - expect(result).toBeUndefined(); + expect(result).toBe(true); }); - it("should handle get returning undefined but set still working", async () => { + it("should return true when set falls through to user attribute", async () => { mockGetSheetItem.mockResolvedValue(null); mockSetSheetItem .mockRejectedValueOnce(new Error("missing computed")) @@ -333,7 +402,7 @@ describe("SmartAttributes", () => { expect(currentValue).toBeUndefined(); const result = await SmartAttributes.setAttribute(characterId, attributeName, "new-value"); - expect(result).toBeUndefined(); + expect(result).toBe(true); expect(mockSetSheetItem).toHaveBeenCalledTimes(2); expect(mockSetSheetItem).toHaveBeenNthCalledWith( @@ -350,7 +419,7 @@ describe("SmartAttributes", () => { `user.${attributeName}`, "new-value", "current", - sheetOpts({ allowThrow: false }) + sheetOpts({ allowThrow: true }) ); }); }); From 0bb1ef0e28d2855cf5c3dd2449dff26aa61b132d Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 4 Jun 2026 12:51:14 -0500 Subject: [PATCH 12/38] updates --- ChatSetAttr/2.0/ChatSetAttr.js | 161 +++++++++++------- ChatSetAttr/ChatSetAttr.js | 161 +++++++++++------- .../integration/legacyAttributes.test.ts | 23 +++ .../src/__tests__/unit/attributes.test.ts | 49 +++--- .../src/__tests__/unit/commands.test.ts | 26 +-- ChatSetAttr/src/__tests__/unit/update.test.ts | 144 +++++++++++----- ChatSetAttr/src/modules/attributes.ts | 15 +- ChatSetAttr/src/modules/commands.ts | 128 +++++++------- ChatSetAttr/src/modules/main.ts | 21 ++- ChatSetAttr/src/modules/updates.ts | 53 +++++- 10 files changed, 513 insertions(+), 268 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 92b306614c..46e8820e3e 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -171,6 +171,20 @@ var ChatSetAttr = (function (exports) { sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); } + 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); + }); + } + function buildSetAttributeOptions(overrides = {}) { const { useWorkers = true } = getConfig() || {}; return { @@ -178,37 +192,65 @@ var ChatSetAttr = (function (exports) { setWithWorker: overrides.setWithWorker ?? useWorkers, }; } + function failureKey(target, name) { + return `${target}:${name}`; + } + function observerEvent(operation, priorValue, isDelete) { + if (operation === "setattr" && priorValue === undefined) { + return "add"; + } + return "change"; + } async function makeUpdate(operation, results, options) { const isSetting = operation !== "delattr"; const errors = []; const messages = []; - const { noCreate = false } = options || {}; + const failed = []; + const { noCreate = false, priorValues = {}, operation: op = operation } = options || {}; const setOptions = buildSetAttributeOptions({ noCreate }); 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; + const key = failureKey(target, name); + const priorValue = priorValues[target]?.[name]; + const newValue = results[target][name]; if (isSetting) { - const value = results[target][name] ?? ""; + const value = newValue ?? ""; try { - await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + if (!ok) { + failed.push(key); + errors.push(`Failed to set attribute '${name}' on target '${target}'.`); + continue; + } + const event = observerEvent(op, priorValue, false); + notifyObservers(event, target, name, newValue, priorValue); } catch (error) { + failed.push(key); errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); } } else { try { - await libSmartAttributes.deleteAttribute(target, actualName, type); + const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); + if (!ok) { + failed.push(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); + continue; + } + notifyObservers("destroy", target, name, newValue, priorValue); } catch (error) { + failed.push(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); } } } } - return { errors, messages }; + return { errors, messages, failed }; } // #region Get Attributes @@ -393,25 +435,11 @@ var ChatSetAttr = (function (exports) { 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 messagesByKey = {}; const errors = []; - const messages = []; const request = createRequestList(referenced, changes, false); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); @@ -424,31 +452,33 @@ var ChatSetAttr = (function (exports) { 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); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[name] = newMessage; } 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); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[`${name}_max`] = newMessage; } - messages.push(newMessage); } return { result, - messages, + messagesByKey, errors, }; } async function modattr(changes, target, referenced, noCreate = false, feedback) { const result = {}; + const messagesByKey = {}; const errors = []; - const messages = []; const currentValues = await getCurrentValues(target, referenced, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); const characterName = getCharName(target); @@ -467,28 +497,31 @@ var ChatSetAttr = (function (exports) { } if (current !== undefined) { result[name] = calculateModifiedValue(asNumber, current); - notifyObservers("change", target, name, result[name], currentValues[name]); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[name] = newMessage; } 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); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[`${name}_max`] = newMessage; } - messages.push(newMessage); } return { result, - messages, + messagesByKey, errors, }; } async function modbattr(changes, target, referenced, noCreate = false, feedback) { const result = {}; + const messagesByKey = {}; const errors = []; - const messages = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); @@ -508,11 +541,9 @@ var ChatSetAttr = (function (exports) { } 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) { @@ -523,18 +554,23 @@ var ChatSetAttr = (function (exports) { if (feedback.content) { newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); } - messages.push(newMessage); + if (current !== undefined) { + messagesByKey[name] = newMessage; + } + if (max !== undefined) { + messagesByKey[`${name}_max`] = newMessage; + } } return { result, - messages, + messagesByKey, errors, }; } async function resetattr(changes, target, referenced, noCreate = false, feedback) { const result = {}; + const messagesByKey = {}; const errors = []; - const messages = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); @@ -559,22 +595,21 @@ var ChatSetAttr = (function (exports) { 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); + messagesByKey[name] = newMessage; } return { result, - messages, + messagesByKey, errors, }; } async function delattr(changes, target, referenced, _, feedback) { const result = {}; - const messages = []; + const messagesByKey = {}; const currentValues = await getCurrentValues(target, referenced, changes); const characterName = getCharName(target); for (const change of changes) { @@ -584,18 +619,17 @@ var ChatSetAttr = (function (exports) { 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); + messagesByKey[name] = newMessage; + if (currentValues[`${name}_max`] !== undefined) { + messagesByKey[`${name}_max`] = newMessage; + } } return { result, - messages, + messagesByKey, errors: [], }; } @@ -1909,8 +1943,11 @@ var ChatSetAttr = (function (exports) { return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); } // Execute + const priorValues = {}; + const pendingMessages = {}; for (const target of targets) { const attrs = await getAttributes(target, request); + priorValues[target] = attrs; const sectionNames = getAllSectionNames(changes); const repOrders = await getAllRepOrders(target, sectionNames); const modifications = processModifications(changes, attrs, options, repOrders); @@ -1919,13 +1956,23 @@ var ChatSetAttr = (function (exports) { errors.push(...response.errors); continue; } - messages.push(...response.messages); + pendingMessages[target] = { ...pendingMessages[target], ...response.messagesByKey }; result[target] = response.result; } - const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate }); + const updateResult = await makeUpdate(operation, result, { + noCreate: options.nocreate, + priorValues, + operation, + }); clearTimer("chatsetattr"); - messages.push(...updateResult.messages); errors.push(...updateResult.errors); + for (const target in pendingMessages) { + for (const key in pendingMessages[target]) { + if (!updateResult.failed.includes(`${target}:${key}`)) { + messages.push(pendingMessages[target][key]); + } + } + } if (options.silent) return; sendErrors(msg.playerid, "Errors", errors, feedback?.from); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 92b306614c..46e8820e3e 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -171,6 +171,20 @@ var ChatSetAttr = (function (exports) { sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); } + 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); + }); + } + function buildSetAttributeOptions(overrides = {}) { const { useWorkers = true } = getConfig() || {}; return { @@ -178,37 +192,65 @@ var ChatSetAttr = (function (exports) { setWithWorker: overrides.setWithWorker ?? useWorkers, }; } + function failureKey(target, name) { + return `${target}:${name}`; + } + function observerEvent(operation, priorValue, isDelete) { + if (operation === "setattr" && priorValue === undefined) { + return "add"; + } + return "change"; + } async function makeUpdate(operation, results, options) { const isSetting = operation !== "delattr"; const errors = []; const messages = []; - const { noCreate = false } = options || {}; + const failed = []; + const { noCreate = false, priorValues = {}, operation: op = operation } = options || {}; const setOptions = buildSetAttributeOptions({ noCreate }); 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; + const key = failureKey(target, name); + const priorValue = priorValues[target]?.[name]; + const newValue = results[target][name]; if (isSetting) { - const value = results[target][name] ?? ""; + const value = newValue ?? ""; try { - await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + if (!ok) { + failed.push(key); + errors.push(`Failed to set attribute '${name}' on target '${target}'.`); + continue; + } + const event = observerEvent(op, priorValue, false); + notifyObservers(event, target, name, newValue, priorValue); } catch (error) { + failed.push(key); errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); } } else { try { - await libSmartAttributes.deleteAttribute(target, actualName, type); + const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); + if (!ok) { + failed.push(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); + continue; + } + notifyObservers("destroy", target, name, newValue, priorValue); } catch (error) { + failed.push(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); } } } } - return { errors, messages }; + return { errors, messages, failed }; } // #region Get Attributes @@ -393,25 +435,11 @@ var ChatSetAttr = (function (exports) { 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 messagesByKey = {}; const errors = []; - const messages = []; const request = createRequestList(referenced, changes, false); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); @@ -424,31 +452,33 @@ var ChatSetAttr = (function (exports) { 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); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[name] = newMessage; } 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); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[`${name}_max`] = newMessage; } - messages.push(newMessage); } return { result, - messages, + messagesByKey, errors, }; } async function modattr(changes, target, referenced, noCreate = false, feedback) { const result = {}; + const messagesByKey = {}; const errors = []; - const messages = []; const currentValues = await getCurrentValues(target, referenced, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); const characterName = getCharName(target); @@ -467,28 +497,31 @@ var ChatSetAttr = (function (exports) { } if (current !== undefined) { result[name] = calculateModifiedValue(asNumber, current); - notifyObservers("change", target, name, result[name], currentValues[name]); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[name] = newMessage; } 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); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); + } + messagesByKey[`${name}_max`] = newMessage; } - messages.push(newMessage); } return { result, - messages, + messagesByKey, errors, }; } async function modbattr(changes, target, referenced, noCreate = false, feedback) { const result = {}; + const messagesByKey = {}; const errors = []; - const messages = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); @@ -508,11 +541,9 @@ var ChatSetAttr = (function (exports) { } 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) { @@ -523,18 +554,23 @@ var ChatSetAttr = (function (exports) { if (feedback.content) { newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); } - messages.push(newMessage); + if (current !== undefined) { + messagesByKey[name] = newMessage; + } + if (max !== undefined) { + messagesByKey[`${name}_max`] = newMessage; + } } return { result, - messages, + messagesByKey, errors, }; } async function resetattr(changes, target, referenced, noCreate = false, feedback) { const result = {}; + const messagesByKey = {}; const errors = []; - const messages = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); @@ -559,22 +595,21 @@ var ChatSetAttr = (function (exports) { 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); + messagesByKey[name] = newMessage; } return { result, - messages, + messagesByKey, errors, }; } async function delattr(changes, target, referenced, _, feedback) { const result = {}; - const messages = []; + const messagesByKey = {}; const currentValues = await getCurrentValues(target, referenced, changes); const characterName = getCharName(target); for (const change of changes) { @@ -584,18 +619,17 @@ var ChatSetAttr = (function (exports) { 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); + messagesByKey[name] = newMessage; + if (currentValues[`${name}_max`] !== undefined) { + messagesByKey[`${name}_max`] = newMessage; + } } return { result, - messages, + messagesByKey, errors: [], }; } @@ -1909,8 +1943,11 @@ var ChatSetAttr = (function (exports) { return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); } // Execute + const priorValues = {}; + const pendingMessages = {}; for (const target of targets) { const attrs = await getAttributes(target, request); + priorValues[target] = attrs; const sectionNames = getAllSectionNames(changes); const repOrders = await getAllRepOrders(target, sectionNames); const modifications = processModifications(changes, attrs, options, repOrders); @@ -1919,13 +1956,23 @@ var ChatSetAttr = (function (exports) { errors.push(...response.errors); continue; } - messages.push(...response.messages); + pendingMessages[target] = { ...pendingMessages[target], ...response.messagesByKey }; result[target] = response.result; } - const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate }); + const updateResult = await makeUpdate(operation, result, { + noCreate: options.nocreate, + priorValues, + operation, + }); clearTimer("chatsetattr"); - messages.push(...updateResult.messages); errors.push(...updateResult.errors); + for (const target in pendingMessages) { + for (const key in pendingMessages[target]) { + if (!updateResult.failed.includes(`${target}:${key}`)) { + messages.push(pendingMessages[target][key]); + } + } + } if (options.silent) return; sendErrors(msg.playerid, "Errors", errors, feedback?.from); diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index 40e6cf6392..e8e0d0254c 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -876,6 +876,29 @@ describe("ChatSetAttr Integration Tests", () => { ]); }); }); + + it("should not notify observers when setAttribute returns false", 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); + const setAttributeSpy = vi.spyOn(libSmartAttributes, "setAttribute").mockResolvedValue(false); + + executeCommand("!setattr --charid char1 --ExistingAttr|20"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Failed to set attribute 'ExistingAttr' on target 'char1'") + ); + expect(errorCall).toBeDefined(); + }); + + expect(mockObserver).not.toHaveBeenCalled(); + setAttributeSpy.mockRestore(); + }); }); describe("Repeating Sections", () => { diff --git a/ChatSetAttr/src/__tests__/unit/attributes.test.ts b/ChatSetAttr/src/__tests__/unit/attributes.test.ts index 9e8e0260cd..71d26a9103 100644 --- a/ChatSetAttr/src/__tests__/unit/attributes.test.ts +++ b/ChatSetAttr/src/__tests__/unit/attributes.test.ts @@ -139,7 +139,7 @@ describe("attributes module", () => { const options = { replace: true }; it("should set current attribute", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); await setSingleAttribute(target, "strength", 18, options); @@ -147,7 +147,7 @@ describe("attributes module", () => { }); it("should set max attribute when isMax is true", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); await setSingleAttribute(target, "hp", 100, options, true); @@ -155,7 +155,7 @@ describe("attributes module", () => { }); it("should handle string values", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); await setSingleAttribute(target, "name", "Test Character", options); @@ -163,7 +163,7 @@ describe("attributes module", () => { }); it("should handle boolean values", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); await setSingleAttribute(target, "isDead", false, options); @@ -171,12 +171,19 @@ describe("attributes module", () => { }); it("should handle numeric values", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); await setSingleAttribute(target, "level", 5, options); expect(mockSetAttribute).toHaveBeenCalledWith(target, "level", 5, "current", defaultSetOptions); }); + + it("should throw when setAttribute returns false", async () => { + mockSetAttribute.mockResolvedValue(false); + + await expect(setSingleAttribute(target, "strength", 18, options)) + .rejects.toThrow("Failed to set attribute 'strength' on target 'character-123'."); + }); }); describe("setAttributes", () => { @@ -184,7 +191,7 @@ describe("attributes module", () => { const options = { replace: true, silent: false }; it("should set single attribute with current value", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); const attributes: Attribute[] = [ { name: "strength", current: 18 } @@ -196,7 +203,7 @@ describe("attributes module", () => { }); it("should set single attribute with max value", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); const attributes: Attribute[] = [ { name: "hp", max: 100 } @@ -208,7 +215,7 @@ describe("attributes module", () => { }); it("should set both current and max values", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); const attributes: Attribute[] = [ { name: "hp", current: 75, max: 100 } @@ -222,7 +229,7 @@ describe("attributes module", () => { }); it("should set multiple attributes", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); const attributes: Attribute[] = [ { name: "strength", current: 18 }, @@ -240,7 +247,7 @@ describe("attributes module", () => { }); it("should handle different value types", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); const attributes: Attribute[] = [ { name: "name", current: "Test Character" }, @@ -300,7 +307,7 @@ describe("attributes module", () => { callOrder.push(currentCall); // Simulate async delay await new Promise(resolve => setTimeout(resolve, Math.random() * 10)); - return undefined; + return true; }); const attributes: Attribute[] = [ @@ -328,11 +335,11 @@ describe("attributes module", () => { expect(mockDeleteAttribute).toHaveBeenCalledWith(target, "oldAttribute"); }); - it("should handle delete failures", async () => { - mockDeleteAttribute.mockRejectedValue(new Error("Delete failed")); + it("should throw when deleteAttribute returns false", async () => { + mockDeleteAttribute.mockResolvedValue(false); await expect(deleteSingleAttribute(target, "nonexistent")) - .rejects.toThrow("Delete failed"); + .rejects.toThrow("Failed to delete attribute 'nonexistent' on target 'character-123'."); }); }); @@ -397,13 +404,11 @@ describe("attributes module", () => { expect(callOrder).toEqual([1, 2, 3]); }); - it("should handle different return types from libSmartAttributes", async () => { - mockDeleteAttribute - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(undefined); + it("should throw when deleteAttribute returns false", async () => { + mockDeleteAttribute.mockResolvedValue(false); - await deleteAttributes(target, ["attr1", "attr2", "attr3"]); + await expect(deleteAttributes(target, ["attr1", "attr2", "attr3"])) + .rejects.toThrow("Failed to delete attribute 'attr1' on target 'character-123'."); expect(mockDeleteAttribute).toHaveBeenCalledTimes(3); }); @@ -415,7 +420,7 @@ describe("attributes module", () => { it("should handle a complete workflow", async () => { // Setup mocks mockGetAttribute.mockResolvedValue(undefined); // Attribute doesn't exist - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); mockDeleteAttribute.mockResolvedValue(true); // Get attribute (should be undefined initially) @@ -437,7 +442,7 @@ describe("attributes module", () => { }); it("should handle batch operations efficiently", async () => { - mockSetAttribute.mockResolvedValue(undefined); + mockSetAttribute.mockResolvedValue(true); mockDeleteAttribute.mockResolvedValue(true); const attributes: Attribute[] = [ diff --git a/ChatSetAttr/src/__tests__/unit/commands.test.ts b/ChatSetAttr/src/__tests__/unit/commands.test.ts index 8ebc902e6a..e293e4cf82 100644 --- a/ChatSetAttr/src/__tests__/unit/commands.test.ts +++ b/ChatSetAttr/src/__tests__/unit/commands.test.ts @@ -37,10 +37,10 @@ describe("commands", () => { strength: 15, dexterity: 12, }); - expect(result.messages).toEqual([ - "Set attribute 'strength' on ID: char1.", - "Set attribute 'dexterity' on ID: char1.", - ]); + expect(result.messagesByKey).toEqual({ + strength: "Set attribute 'strength' on ID: char1.", + dexterity: "Set attribute 'dexterity' on ID: char1.", + }); expect(result.errors).toEqual([]); }); @@ -56,10 +56,10 @@ describe("commands", () => { hp_max: 25, mp_max: 15, }); - expect(result.messages).toEqual([ - "Set attribute 'hp' on ID: char1.", - "Set attribute 'mp' on ID: char1.", - ]); + expect(result.messagesByKey).toEqual({ + hp_max: "Set attribute 'hp' on ID: char1.", + mp_max: "Set attribute 'mp' on ID: char1.", + }); expect(result.errors).toEqual([]); }); @@ -396,10 +396,10 @@ describe("commands", () => { tempattr: undefined, tempattr_max: undefined, }); - expect(result.messages).toEqual([ - "Deleted attribute 'oldattr' on ID: char1.", - "Deleted attribute 'tempattr' on ID: char1.", - ]); + expect(result.messagesByKey).toEqual({ + oldattr: "Deleted attribute 'oldattr' on ID: char1.", + tempattr: "Deleted attribute 'tempattr' on ID: char1.", + }); expect(result.errors).toEqual([]); }); @@ -423,7 +423,7 @@ describe("commands", () => { const result = await delattr(changes, "char1", [], false, feedbackMock); expect(result.result).toEqual({}); - expect(result.messages).toEqual([]); + expect(result.messagesByKey).toEqual({}); expect(result.errors).toEqual([]); }); }); diff --git a/ChatSetAttr/src/__tests__/unit/update.test.ts b/ChatSetAttr/src/__tests__/unit/update.test.ts index a30d2d116a..98ed99055c 100644 --- a/ChatSetAttr/src/__tests__/unit/update.test.ts +++ b/ChatSetAttr/src/__tests__/unit/update.test.ts @@ -7,6 +7,10 @@ vi.mock("../../modules/config", () => ({ getConfig: vi.fn(), })); +vi.mock("../../modules/observer", () => ({ + notifyObservers: vi.fn(), +})); + // Mock libSmartAttributes global const mocklibSmartAttributes = { getAttribute: vi.fn(), @@ -17,7 +21,9 @@ const mocklibSmartAttributes = { global.libSmartAttributes = mocklibSmartAttributes; import { getConfig } from "../../modules/config"; +import { notifyObservers } from "../../modules/observer"; const mockGetConfig = vi.mocked(getConfig); +const mockNotifyObservers = vi.mocked(notifyObservers); describe("updates", () => { beforeEach(() => { @@ -34,7 +40,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -63,7 +69,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -94,7 +100,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -121,7 +127,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -153,7 +159,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -184,7 +190,7 @@ describe("updates", () => { "char3": { "wisdom": 14 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -209,7 +215,7 @@ describe("updates", () => { }; mocklibSmartAttributes.setAttribute - .mockResolvedValueOnce(undefined) // success succeeds + .mockResolvedValueOnce(true) // success succeeds .mockRejectedValueOnce(new Error("Failed to set failure")); // failure fails const result = await makeUpdate("setattr", results); @@ -230,9 +236,9 @@ describe("updates", () => { }; mocklibSmartAttributes.setAttribute - .mockResolvedValueOnce(undefined) // success1 + .mockResolvedValueOnce(true) // success1 .mockRejectedValueOnce(new Error("Error 1")) // failure1 - .mockResolvedValueOnce(undefined) // success2 + .mockResolvedValueOnce(true) // success2 .mockRejectedValueOnce(new Error("Error 2")); // failure2 const result = await makeUpdate("setattr", results); @@ -277,7 +283,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); const result = await makeUpdate("setattr", results); @@ -312,7 +318,7 @@ describe("updates", () => { "char1": { "strength": 15, "hp_max": 25 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("modattr", results); @@ -330,7 +336,7 @@ describe("updates", () => { "char1": { "dexterity": 12, "mp_max": 15 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("modbattr", results); @@ -348,7 +354,7 @@ describe("updates", () => { "char1": { "wisdom": 14, "sp_max": 20 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("resetattr", results); @@ -371,7 +377,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); await makeUpdate("delattr", results); @@ -395,7 +401,7 @@ describe("updates", () => { "char3": { "oldAttr3": "value" }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); await makeUpdate("delattr", results); @@ -411,7 +417,7 @@ describe("updates", () => { }; mocklibSmartAttributes.deleteAttribute - .mockResolvedValueOnce(undefined) // attr1 succeeds + .mockResolvedValueOnce(true) // attr1 succeeds .mockRejectedValueOnce(new Error("Cannot delete attr2")); // attr2 fails const result = await makeUpdate("delattr", results); @@ -422,13 +428,75 @@ describe("updates", () => { }); }); + describe("boolean return values and observers", () => { + it("should record error and failed key when setAttribute returns false", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(false); + + const result = await makeUpdate("setattr", { char1: { strength: 15 } }); + + expect(result.errors).toEqual([ + "Failed to set attribute 'strength' on target 'char1'.", + ]); + expect(result.failed).toEqual(["char1:strength"]); + expect(mockNotifyObservers).not.toHaveBeenCalled(); + }); + + it("should record error and failed key when deleteAttribute returns false", async () => { + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(false); + + const result = await makeUpdate("delattr", { char1: { strength: undefined } }); + + expect(result.errors).toEqual([ + "Failed to delete attribute 'strength' on target 'char1'.", + ]); + expect(result.failed).toEqual(["char1:strength"]); + expect(mockNotifyObservers).not.toHaveBeenCalled(); + }); + + it("should notify observers on successful set with change event", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + const priorValues = { char1: { strength: 10 } }; + + await makeUpdate("setattr", { char1: { strength: 15 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).toHaveBeenCalledWith("change", "char1", "strength", 15, 10); + }); + + it("should notify observers with add event when prior value is undefined", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + const priorValues = { char1: {} }; + + await makeUpdate("setattr", { char1: { NewAttr: 42 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).toHaveBeenCalledWith("add", "char1", "NewAttr", 42, undefined); + }); + + it("should notify observers with destroy event on successful delete", async () => { + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { strength: 10 } }; + + await makeUpdate("delattr", { char1: { strength: undefined } }, { priorValues }); + + expect(mockNotifyObservers).toHaveBeenCalledWith("destroy", "char1", "strength", undefined, 10); + }); + + it("should not notify observers when setAttribute returns false", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(false); + const priorValues = { char1: { strength: 10 } }; + + await makeUpdate("setattr", { char1: { strength: 15 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).not.toHaveBeenCalled(); + }); + }); + describe("options handling", () => { it("should use default options when none provided", async () => { const results: Record = { "char1": { "strength": 15 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -447,7 +515,7 @@ describe("updates", () => { }; const options = { noCreate: true }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results, options); @@ -467,7 +535,7 @@ describe("updates", () => { "char1": { "strength": 15 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -488,7 +556,7 @@ describe("updates", () => { }; const options = { noCreate: true }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results, options); @@ -538,9 +606,9 @@ describe("updates", () => { }; mocklibSmartAttributes.setAttribute - .mockResolvedValueOnce(undefined) // success1 + .mockResolvedValueOnce(true) // success1 .mockRejectedValueOnce(new Error("Error 1")) // failure1 - .mockResolvedValueOnce(undefined) // success2 + .mockResolvedValueOnce(true) // success2 .mockRejectedValueOnce(new Error("Error 2")); // failure2 const result = await makeUpdate("setattr", results); @@ -597,7 +665,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -626,7 +694,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -662,7 +730,7 @@ describe("updates", () => { "char1": { "strength": 15, "hp_max": 25 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("modattr", results); @@ -680,7 +748,7 @@ describe("updates", () => { "char1": { "dexterity": 12, "mp_max": 15 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("modbattr", results); @@ -698,7 +766,7 @@ describe("updates", () => { "char1": { "wisdom": 14, "sp_max": 20 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("resetattr", results); @@ -721,7 +789,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); await makeUpdate("delattr", results); @@ -746,7 +814,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); await makeUpdate("delattr", results); @@ -770,7 +838,7 @@ describe("updates", () => { "char3": { "wisdom": 14 }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); await makeUpdate("delattr", results); @@ -786,7 +854,7 @@ describe("updates", () => { }; mocklibSmartAttributes.deleteAttribute - .mockResolvedValueOnce(undefined) // strength succeeds + .mockResolvedValueOnce(true) // strength succeeds .mockRejectedValueOnce(new Error("Failed to delete dexterity")); // dexterity fails const result = await makeUpdate("delattr", results); @@ -807,7 +875,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); const result = await makeUpdate("delattr", results); @@ -832,7 +900,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); await makeUpdate("delattr", results); @@ -852,7 +920,7 @@ describe("updates", () => { }; mocklibSmartAttributes.deleteAttribute - .mockResolvedValueOnce(undefined) // hp current succeeds + .mockResolvedValueOnce(true) // hp current succeeds .mockRejectedValueOnce(new Error("Max deletion failed")); // hp max fails const result = await makeUpdate("delattr", results); @@ -872,7 +940,7 @@ describe("updates", () => { }, }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); const result = await makeUpdate("delattr", results); @@ -914,7 +982,7 @@ describe("updates", () => { ), }; - mocklibSmartAttributes.deleteAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); await makeUpdate("delattr", results); @@ -954,7 +1022,7 @@ describe("updates", () => { "char1": { "strength": 15 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); @@ -974,7 +1042,7 @@ describe("updates", () => { "char1": { "strength": 15 }, }; - mocklibSmartAttributes.setAttribute.mockResolvedValue(undefined); + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); await makeUpdate("setattr", results); diff --git a/ChatSetAttr/src/modules/attributes.ts b/ChatSetAttr/src/modules/attributes.ts index 84a97aef5e..e84f64873c 100644 --- a/ChatSetAttr/src/modules/attributes.ts +++ b/ChatSetAttr/src/modules/attributes.ts @@ -48,7 +48,10 @@ export async function setSingleAttribute( noCreate: options.noCreate, setWithWorker: options.setWithWorker, }); - await libSmartAttributes.setAttribute(target, attributeName, value, type, setOptions); + const ok = await libSmartAttributes.setAttribute(target, attributeName, value, type, setOptions); + if (!ok) { + throw new Error(`Failed to set attribute '${attributeName}' on target '${target}'.`); + } }; export async function setAttributes( @@ -82,17 +85,19 @@ export async function deleteSingleAttribute( target: string, attributeName: string, ): Promise { - await libSmartAttributes.deleteAttribute(target, attributeName); + const ok = await libSmartAttributes.deleteAttribute(target, attributeName); + if (!ok) { + throw new Error(`Failed to delete attribute '${attributeName}' on target '${target}'.`); + } }; export async function deleteAttributes( target: string, attributeNames: string[], ): Promise { - const promises: Promise[] = []; + const promises: Promise[] = []; for (const name of attributeNames) { - const promise = libSmartAttributes.deleteAttribute(target, name); - promises.push(promise); + promises.push(deleteSingleAttribute(target, name)); } await Promise.all(promises); }; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/commands.ts b/ChatSetAttr/src/modules/commands.ts index bfd4ce45d5..3b6cfe77ed 100644 --- a/ChatSetAttr/src/modules/commands.ts +++ b/ChatSetAttr/src/modules/commands.ts @@ -2,11 +2,10 @@ import type { Command, Attribute, AttributeRecord, AttributeValue, FeedbackObjec import { getAttributes } from "./attributes"; import { createFeedbackMessage } from "./feedback"; import { getCharName } from "./helpers"; -import { notifyObservers } from "./observer"; export type HandlerResponse = { result: AttributeRecord; - messages: string[]; + messagesByKey: Record; errors: string[]; }; @@ -27,8 +26,8 @@ export async function setattr( feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; + const messagesByKey: Record = {}; const errors: string[] = []; - const messages: string[] = []; const request = createRequestList(referenced, changes, false); const currentValues = await getCurrentValues(target, request, changes); @@ -42,43 +41,37 @@ export async function setattr( 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, - ); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + messagesByKey[name] = newMessage; } 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, - ); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + messagesByKey[`${name}_max`] = newMessage; } - messages.push(newMessage); } return { result, - messages, + messagesByKey, errors, }; @@ -92,8 +85,8 @@ export async function modattr( feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; + const messagesByKey: Record = {}; const errors: string[] = []; - const messages: string[] = []; const currentValues = await getCurrentValues(target, referenced, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); @@ -113,29 +106,35 @@ export async function modattr( } if (current !== undefined) { result[name] = calculateModifiedValue(asNumber, current); - notifyObservers("change", target, name, result[name], currentValues[name]); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + messagesByKey[name] = newMessage; } 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, - ); + let newMessage = `Set attribute '${name}' on ${characterName}.`; + if (feedback.content) { + newMessage = createFeedbackMessage( + characterName, + feedback, + currentValues, + result, + ); + } + messagesByKey[`${name}_max`] = newMessage; } - - messages.push(newMessage); } return { result, - messages, + messagesByKey, errors, }; }; @@ -148,8 +147,8 @@ export async function modbattr( feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; + const messagesByKey: Record = {}; const errors: string[] = []; - const messages: string[] = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); @@ -170,11 +169,9 @@ export async function modbattr( } 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) { @@ -195,12 +192,17 @@ export async function modbattr( ); } - messages.push(newMessage); + if (current !== undefined) { + messagesByKey[name] = newMessage; + } + if (max !== undefined) { + messagesByKey[`${name}_max`] = newMessage; + } } return { result, - messages, + messagesByKey, errors, }; } @@ -213,8 +215,8 @@ export async function resetattr( feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; + const messagesByKey: Record = {}; const errors: string[] = []; - const messages: string[] = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); @@ -240,8 +242,6 @@ export async function resetattr( result[name] = 0; } - notifyObservers("change", target, name, result[name], currentValues[name]); - let newMessage = `Reset attribute '${name}' on ${characterName}.`; if (feedback.content) { newMessage = createFeedbackMessage( @@ -252,12 +252,12 @@ export async function resetattr( ); } - messages.push(newMessage); + messagesByKey[name] = newMessage; } return { result, - messages, + messagesByKey, errors, }; } @@ -270,7 +270,7 @@ export async function delattr( feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; - const messages: string[] = []; + const messagesByKey: Record = {}; const currentValues = await getCurrentValues(target, referenced, changes); const characterName = getCharName(target); @@ -282,12 +282,6 @@ export async function delattr( 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, @@ -297,11 +291,15 @@ export async function delattr( ); } - messages.push(newMessage); + messagesByKey[name] = newMessage; + + if (currentValues[`${name}_max`] !== undefined) { + messagesByKey[`${name}_max`] = newMessage; + } } return { result, - messages, + messagesByKey, errors: [], }; }; diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 6c6c50ee6a..34e023acaa 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -82,8 +82,12 @@ async function acceptMessage(msg: Roll20ChatMessage) { } // Execute + const priorValues: Record = {}; + const pendingMessages: Record> = {}; + for (const target of targets) { const attrs = await getAttributes(target, request); + priorValues[target] = attrs; const sectionNames = getAllSectionNames(changes); const repOrders = await getAllRepOrders(target, sectionNames); const modifications = processModifications(changes, attrs, options, repOrders); @@ -95,17 +99,28 @@ async function acceptMessage(msg: Roll20ChatMessage) { continue; } - messages.push(...response.messages); + pendingMessages[target] = { ...pendingMessages[target], ...response.messagesByKey }; result[target] = response.result; } - const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate }); + const updateResult = await makeUpdate(operation, result, { + noCreate: options.nocreate, + priorValues, + operation, + }); clearTimer("chatsetattr"); - messages.push(...updateResult.messages); errors.push(...updateResult.errors); + for (const target in pendingMessages) { + for (const key in pendingMessages[target]) { + if (!updateResult.failed.includes(`${target}:${key}`)) { + messages.push(pendingMessages[target][key]); + } + } + } + if (options.silent) return; sendErrors(msg.playerid, "Errors", errors, feedback?.from); if (options.mute) return; diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts index bf47dc9c1d..e3c04f7a66 100644 --- a/ChatSetAttr/src/modules/updates.ts +++ b/ChatSetAttr/src/modules/updates.ts @@ -1,13 +1,17 @@ -import { type AttributeRecord, type Command } from "../types"; +import { type AttributeRecord, type AttributeValue, type Command, type ObserverEvent } from "../types"; import { getConfig } from "./config"; +import { notifyObservers } from "./observer"; type UpdateOptions = { noCreate?: boolean; + priorValues?: Record; + operation?: Command; }; -type UpdateResult = { +export type UpdateResult = { errors: string[]; messages: string[]; + failed: string[]; }; export function buildSetAttributeOptions(overrides: { noCreate?: boolean; setWithWorker?: boolean } = {}) { @@ -18,6 +22,20 @@ export function buildSetAttributeOptions(overrides: { noCreate?: boolean; setWit }; }; +function failureKey(target: string, name: string): string { + return `${target}:${name}`; +}; + +function observerEvent(operation: Command, priorValue: AttributeValue | undefined, isDelete: boolean): ObserverEvent { + if (isDelete) { + return "destroy"; + } + if (operation === "setattr" && priorValue === undefined) { + return "add"; + } + return "change"; +}; + export async function makeUpdate( operation: Command, results: Record, @@ -26,8 +44,9 @@ export async function makeUpdate( const isSetting = operation !== "delattr"; const errors: string[] = []; const messages: string[] = []; + const failed: string[] = []; - const { noCreate = false } = options || {}; + const { noCreate = false, priorValues = {}, operation: op = operation } = options || {}; const setOptions = buildSetAttributeOptions({ noCreate }); for (const target in results) { @@ -35,21 +54,39 @@ export async function makeUpdate( const isMax = name.endsWith("_max"); const type = isMax ? "max" : "current"; const actualName = isMax ? name.slice(0, -4) : name; + const key = failureKey(target, name); + const priorValue = priorValues[target]?.[name]; + const newValue = results[target][name]; if (isSetting) { - const value = results[target][name] ?? ""; + const value = newValue ?? ""; try { - await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); + if (!ok) { + failed.push(key); + errors.push(`Failed to set attribute '${name}' on target '${target}'.`); + continue; + } + const event = observerEvent(op, priorValue, false); + notifyObservers(event, target, name, newValue, priorValue); } catch (error: unknown) { + failed.push(key); errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); } } else { try { - await libSmartAttributes.deleteAttribute(target, actualName, type); + const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); + if (!ok) { + failed.push(key); + errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); + continue; + } + notifyObservers("destroy", target, name, newValue, priorValue); } catch (error: unknown) { + failed.push(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); } @@ -57,5 +94,5 @@ export async function makeUpdate( } } - return { errors, messages }; -}; \ No newline at end of file + return { errors, messages, failed }; +}; From ac4895809eb84a10602431a4fc7cbba7f3d8aa1e Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 4 Jun 2026 12:59:23 -0500 Subject: [PATCH 13/38] Adding Legacy sheet detection to deleteAttribute --- .types/index.d.ts | 5 +++- .../0.0.4/libSmartAttributes.js | 25 ++++++++++++------- libSmartAttributes/src/index.ts | 25 ++++++++++++------- libSmartAttributes/tests/index.test.ts | 22 ++++++++++++++++ 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/.types/index.d.ts b/.types/index.d.ts index df420dcfa9..40c2fe9312 100644 --- a/.types/index.d.ts +++ b/.types/index.d.ts @@ -218,7 +218,10 @@ type CharacterProperties = { _defaulttoken: string; }; -declare type Roll20Character = Prettify>; +declare type Roll20Character = Prettify & { + /** Experimental: legacy Roll20 attributes vs Beacon sheet */ + sheetEnvironment?: "legacy" | "beacon"; +}>; // Attribute type with proper properties type AttributeProperties = { diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js index 238538337c..bdeb24b9fb 100644 --- a/libSmartAttributes/0.0.4/libSmartAttributes.js +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -47,15 +47,22 @@ var libSmartAttributes = (function () { } } async function deleteAttribute(characterId, name, type = "current") { - // Try for legacy attribute first - const legacyAttr = findObjs({ - _type: "attribute", - _characterid: characterId, - name: name, - })[0]; - if (legacyAttr) { - legacyAttr.remove(); - return true; + const character = getObj("character", characterId); + if (!character) { + return false; + } + if (character?.sheetEnvironment === "legacy") { + // Try for legacy attribute first + const legacyAttr = findObjs({ + _type: "attribute", + _characterid: characterId, + name: name, + })[0]; + if (legacyAttr) { + legacyAttr.remove(); + return true; + } + return false; } // Then try for the beacon computed const beaconAttr = await getSheetItem(characterId, name, type); diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index 17e8eb6385..0b2bd5a1fb 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -70,16 +70,23 @@ async function setAttribute( }; async function deleteAttribute(characterId: string, name: string, type: AttributeType = "current") { - // Try for legacy attribute first - const legacyAttr = findObjs({ - _type: "attribute", - _characterid: characterId, - name: name, - })[0]; + const character = getObj("character",characterId); + if(!character) { + return false; + } + if( character?.sheetEnvironment === "legacy"){ + // Try for legacy attribute first + const legacyAttr = findObjs({ + _type: "attribute", + _characterid: characterId, + name: name, + })[0]; - if (legacyAttr) { - legacyAttr.remove(); - return true; + if (legacyAttr) { + legacyAttr.remove(); + return true; + } + return false; } // Then try for the beacon computed diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index 437c6ad033..1a2726d5c7 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import SmartAttributes from "../src/index"; const mockFindObjs = vi.fn(); +const mockGetObj = vi.fn(); const mockGetSheetItem = vi.fn(); const mockSetSheetItem = vi.fn(); const mockLog = vi.fn(); vi.stubGlobal("findObjs", mockFindObjs); +vi.stubGlobal("getObj", mockGetObj); vi.stubGlobal("getSheetItem", mockGetSheetItem); vi.stubGlobal("setSheetItem", mockSetSheetItem); vi.stubGlobal("log", mockLog); @@ -34,6 +36,7 @@ describe("SmartAttributes", () => { beforeEach(() => { vi.clearAllMocks(); mockFindObjs.mockReturnValue([]); + mockGetObj.mockReturnValue({ sheetEnvironment: "beacon" }); }); describe("getAttribute", () => { @@ -262,6 +265,7 @@ describe("SmartAttributes", () => { it("should return true when removing a legacy attribute", async () => { const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({ sheetEnvironment: "legacy" }); mockFindObjs.mockReturnValue([{ remove: mockRemove }]); const result = await SmartAttributes.deleteAttribute(characterId, attributeName); @@ -276,6 +280,24 @@ describe("SmartAttributes", () => { expect(result).toBe(true); }); + it("should return false when legacy character has no matching attribute", async () => { + mockGetObj.mockReturnValue({ sheetEnvironment: "legacy" }); + mockFindObjs.mockReturnValue([]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return false when character is not found", async () => { + mockGetObj.mockReturnValue(null); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(result).toBe(false); + }); + it("should return true when clearing a writable beacon computed", async () => { mockGetSheetItem.mockResolvedValueOnce("10"); mockSetSheetItem.mockResolvedValue(true); From 1697d4518fa8f96af71ea67788390b26a4244c24 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 5 Jun 2026 11:31:40 -0500 Subject: [PATCH 14/38] Make delete on a computed fail. --- .../0.0.4/libSmartAttributes.js | 14 ++------ libSmartAttributes/src/.index.ts.swo | Bin 0 -> 16384 bytes libSmartAttributes/src/index.ts | 13 ++----- libSmartAttributes/tests/index.test.ts | 32 ++---------------- 4 files changed, 7 insertions(+), 52 deletions(-) create mode 100644 libSmartAttributes/src/.index.ts.swo diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js index bdeb24b9fb..79bf5693df 100644 --- a/libSmartAttributes/0.0.4/libSmartAttributes.js +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -67,18 +67,8 @@ var libSmartAttributes = (function () { // Then try for the beacon computed const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - // Cannot delete beacon computed attributes. Setting to undefined instead. - try { - await setSheetItem(characterId, name, undefined, type, { allowThrow: true }); - return true; - } - catch (e) { - switch (e.type) { - // for read only computeds, we don't want to fall through to a "user." version. - case "COMPUTED_READONLY": - return false; - } - } + // Cannot delete beacon computed attributes. + return false; } // Then try for the user attribute const userAttr = await getSheetItem(characterId, `user.${name}`, type); diff --git a/libSmartAttributes/src/.index.ts.swo b/libSmartAttributes/src/.index.ts.swo new file mode 100644 index 0000000000000000000000000000000000000000..c03ccdc4f21b658f0dc5df5265119476c7274812 GIT binary patch literal 16384 zcmeI2TWB0r7{^c3i<(-kR($@lYTUqfH?3IhrZL8(4?$~6np$eD&Ft>k-AQ(5(wW(8 zLR^bJ_*n6Vh~Psjf-fS754MPCi!UvB1M3A}#QLIw`XC6F`2Xg%x5=f&)>6*EFPk}Y z=FB<&`Og2$IWw8w$3}M1_GGWXwo!-?nbwfu*;Sez>CNa+Nh)xf$QAd>mN&%&SQa~x76i^B%1(X6xfonn|1(X6x0i}RaKq;UUPzopolmbctrGQdE zDR31kpylw|ACvpjYfw47{}1N>=WZ6_Yw!U$23`cypaiyq3pWYzIrs>i1Sh~N;9+n( zSOqR@5aJ(j0=xx|gJa+*u)#s_IM@%igC4L3tO5co+$hA4;0!nk-UiPB6HI|gkO4j5 z7I1345M?02I2Z=McA{;-0%KqY*ao(OEnq!Z4L-d=he4oa!So19C#C=-_)`EAj{}wn7j)51zGvF|I5*z?mW+QARvk;#c#-|;Q zZWO3uON+GN!w$N0-q6ajMy5g8qGoACdY9Dy0&2ce`hXi4}YclGhEH z>!Eo;lr;_8spse?t*Yq`6{NGLD5W#v$a35*KkG44zQZ!za207LohgvSpv( z6yCD<`~^Q(g$93KsrJT(MtAMrJHB)H(Adtw;n7DQ*&jSR2@V_H5Re>)meg|SO{F>XiSjhQoyyq>s!Gb4#@!B8HNzptA97D{$ice2HJT#{x}*q3%%mTC1Q-A=>Z5220FMMjECuS0U0AL5Qx z2_xKIv_rBDBXylM)7^XuxHWW!2siE(OZLK?touoa|aV-b!3-31R zFbYV-?c-kNogdmt5Kt}2;pvTT(~xI*zww&qyG(SDv^1q|A8k68WI@Z$aix=%ESobj z4(syTQ$NI2MPjYzK<`05O2lepz~X|j=O6cT>b zgFUZe;VI*&C#n(4RxC>zPRwn2joa+mZ>!Y?vC7qrh|-!XCLcoV7x$Ugl(f=hPIGPT zfh0nBUzkq)jaEVf2-S+DFn4oi!DT{0bWulg?ZbQnNy8j+lNT$n%fEh@(Yu92=*VVu z96h<3b}#OwFW%2u2fi7tR6)mMNfT}-3p!gcz+s%#Dkax7v}H%QP|l`t%^lU2Ft3ZU zuCg`-Ga|dF<;-fdZLQR_a9#GmDsD9Fu&7N>V?akp`aRL+M+cj2f(Q4&PT{1|W-pbq zI-WUTxw`GT?q+sf5oLvK9r#)ZG4Ba$@j&w&Z;|j4o{h&-vxJ>`%5O>Q> z!`G(_vubdP=Wf15U_Ugs5tv($+~Pf`edr=_JodS!k&}+5m+XNwjmfNO<#q(N<6rCn zb{4OS-jP1Rdo80Q49OiOshfS+EjZ}oC^=)A_|)0pl2C`bpEMrw3JrBZfk8ojJcXkY z7vnx;RO(epEP{77v-936q1G+!#Y1B6G!6aHockhzRL^`y;l(5FMXD}R3g%_P+!g$MQ zNl#I%tj)%#Pw@PI26OfA0MGw}_xpcgj{hEb4IBX#kOAZ1L68R9z`fuu5CdIcBM{(s zl=p*Q?zi~;2Al)00$v~Z3G;QeDFu`QN&%&SQa~x76i^B%1(X7pQ2|UzgEt!=H?8!0 zE34srf#=qZ^JLy{oM0q`oSil;UXk?{ikfGsk#kd?_S9%ab5pGSnNE@49AO#Ps~S&N zgQ|bhQ1u|~R7BcoH_e~l^U879zNoaQHb>=)N{c!-O0N~1TZ_mOl_n$d$@Y4*U~Ec}UH>~;HR_->|g0lob_XSn3ixa1qWVfh!C6FD{j literal 0 HcmV?d00001 diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index 0b2bd5a1fb..c964fe6cfb 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -92,17 +92,8 @@ async function deleteAttribute(characterId: string, name: string, type: Attribut // Then try for the beacon computed const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - // Cannot delete beacon computed attributes. Setting to undefined instead. - try { - await setSheetItem(characterId, name, undefined, type, { allowThrow: true }); - return true; - } catch (e) { - switch((e as SheetItemError).type){ - // for read only computeds, we don't want to fall through to a "user." version. - case "COMPUTED_READONLY": - return false; - } - } + // Cannot delete beacon computed attributes. + return false; } // Then try for the user attribute diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index 1a2726d5c7..645796473f 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -298,40 +298,14 @@ describe("SmartAttributes", () => { expect(result).toBe(false); }); - it("should return true when clearing a writable beacon computed", async () => { + it("should return false when a beacon computed exists", async () => { mockGetSheetItem.mockResolvedValueOnce("10"); - mockSetSheetItem.mockResolvedValue(true); const result = await SmartAttributes.deleteAttribute(characterId, attributeName); - expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); - expect(mockSetSheetItem).toHaveBeenCalledWith( - characterId, - attributeName, - undefined, - "current", - { allowThrow: true } - ); - expect(result).toBe(true); - }); - - it("should return false and not touch user attribute when beacon computed is read-only", async () => { - mockGetSheetItem.mockResolvedValueOnce("10"); - mockSetSheetItem.mockRejectedValueOnce( - sheetItemError("COMPUTED_READONLY", 'ERROR: Readonly Property "strength".') - ); - - const result = await SmartAttributes.deleteAttribute(characterId, attributeName); - - expect(mockSetSheetItem).toHaveBeenCalledTimes(1); - expect(mockSetSheetItem).toHaveBeenCalledWith( - characterId, - attributeName, - undefined, - "current", - { allowThrow: true } - ); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); + expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); + expect(mockSetSheetItem).not.toHaveBeenCalled(); expect(result).toBe(false); }); From dc0913efd0cb9b705ccf04a4264467645ad6ab9e Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 5 Jun 2026 11:43:31 -0500 Subject: [PATCH 15/38] Corrections --- .../0.0.4/libSmartAttributes.js | 4 +--- libSmartAttributes/src/index.ts | 7 +++--- libSmartAttributes/tests/index.test.ts | 24 ++++++++++++++++++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js index 79bf5693df..ac1d3d8233 100644 --- a/libSmartAttributes/0.0.4/libSmartAttributes.js +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -52,7 +52,6 @@ var libSmartAttributes = (function () { return false; } if (character?.sheetEnvironment === "legacy") { - // Try for legacy attribute first const legacyAttr = findObjs({ _type: "attribute", _characterid: characterId, @@ -64,10 +63,9 @@ var libSmartAttributes = (function () { } return false; } - // Then try for the beacon computed + // Beacon computeds cannot be deleted (no change to the computed value). const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - // Cannot delete beacon computed attributes. return false; } // Then try for the user attribute diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index c964fe6cfb..93a1a72466 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -74,8 +74,8 @@ async function deleteAttribute(characterId: string, name: string, type: Attribut if(!character) { return false; } - if( character?.sheetEnvironment === "legacy"){ - // Try for legacy attribute first + + if (character?.sheetEnvironment === "legacy") { const legacyAttr = findObjs({ _type: "attribute", _characterid: characterId, @@ -89,10 +89,9 @@ async function deleteAttribute(characterId: string, name: string, type: Attribut return false; } - // Then try for the beacon computed + // Beacon computeds cannot be deleted (no change to the computed value). const beaconAttr = await getSheetItem(characterId, name, type); if (beaconAttr !== null && beaconAttr !== undefined) { - // Cannot delete beacon computed attributes. return false; } diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index 645796473f..95f7de0b65 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -298,17 +298,39 @@ describe("SmartAttributes", () => { expect(result).toBe(false); }); - it("should return false when a beacon computed exists", async () => { + it("should return false when a beacon computed exists and no legacy attribute exists", async () => { mockGetSheetItem.mockResolvedValueOnce("10"); const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + expect(mockFindObjs).toHaveBeenCalledTimes(0); expect(mockGetSheetItem).toHaveBeenCalledTimes(1); expect(mockGetSheetItem).toHaveBeenCalledWith(characterId, attributeName, "current"); expect(mockSetSheetItem).not.toHaveBeenCalled(); expect(result).toBe(false); }); + it("should return false for falsy beacon computed values without calling setSheetItem", async () => { + mockGetSheetItem.mockResolvedValueOnce(0); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockSetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should ignore legacy attribute on beacon character before checking computed", async () => { + const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({ sheetEnvironment: "beacon" }); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockRemove).not.toHaveBeenCalled(); + expect(mockGetSheetItem).toHaveBeenCalledTimes(2); + expect(result).toBe(false); + }); + it("should return true when deleting an existing user attribute", async () => { mockGetSheetItem .mockResolvedValueOnce(null) From 79887e39caca804a70a71fd3c7a645f3a4868041 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 5 Jun 2026 12:11:27 -0500 Subject: [PATCH 16/38] updates for deleteAttribute with computed. --- .../integration/legacyAttributes.test.ts | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index e8e0d0254c..20e9a91099 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -36,6 +36,13 @@ describe("ChatSetAttr Integration Tests", () => { resetAllCallbacks(); }); + /** libSmartAttributes.deleteAttribute uses legacy removal only when sheetEnvironment is legacy */ + function createLegacyCharacter(properties: Record) { + const character = createObj("character", properties); + Object.assign(character, { sheetEnvironment: "legacy" }); + return character; + } + describe("Attribute Setting Commands", () => { it("should set Strength to 15 for selected characters", async () => { // arrange @@ -372,8 +379,8 @@ describe("ChatSetAttr Integration Tests", () => { 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 }); + createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + createLegacyCharacter({ _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" }); @@ -455,7 +462,7 @@ describe("ChatSetAttr Integration Tests", () => { 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 }); + createLegacyCharacter({ _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" }); @@ -473,6 +480,25 @@ describe("ChatSetAttr Integration Tests", () => { expect(toKeep).toBe("30"); }); }); + + it("should not delete attributes exposed as beacon computeds", 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: "ComputedLike", current: "10" }); + + executeCommand("!delattr --charid char1 --ComputedLike"); + + await vi.waitFor(async () => { + const value = await libSmartAttributes.getAttribute("char1", "ComputedLike"); + expect(value).toBe("10"); + + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Failed to delete attribute 'ComputedLike' on target 'char1'") + ); + expect(errorCall).toBeDefined(); + }); + }); }); describe("Targeting Options", () => { @@ -855,7 +881,7 @@ describe("ChatSetAttr Integration Tests", () => { 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 }); + createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); createObj("attribute", { _id: "attr1", _characterid: "char1", name: "DeleteMe", current: "10" }); const mockObserver = vi.fn(); @@ -899,6 +925,27 @@ describe("ChatSetAttr Integration Tests", () => { expect(mockObserver).not.toHaveBeenCalled(); setAttributeSpy.mockRestore(); }); + + it("should not notify observers when deleteAttribute fails on a computed", 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: "ComputedLike", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("destroy", mockObserver); + + executeCommand("!delattr --charid char1 --ComputedLike"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Failed to delete attribute 'ComputedLike' on target 'char1'") + ); + expect(errorCall).toBeDefined(); + }); + + expect(mockObserver).not.toHaveBeenCalled(); + }); }); describe("Repeating Sections", () => { @@ -978,7 +1025,7 @@ describe("ChatSetAttr Integration Tests", () => { 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 character = createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); const firstWeaponNameAttr = createObj("attribute", { From 29477d6893149f437f2b2e54af298380bd33e6e2 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 10 Jun 2026 11:31:43 -0500 Subject: [PATCH 17/38] Fixing --mute --- ChatSetAttr/2.0/ChatSetAttr.js | 438 ++++++++++++++++-- ChatSetAttr/ChatSetAttr.js | 438 ++++++++++++++++-- ChatSetAttr/src/__mocks__/apiObjects.mock.ts | 15 +- .../src/__mocks__/beaconAttributes.mock.ts | 16 +- .../integration/legacyAttributes.test.ts | 164 +++++-- .../legacy/legacyIntegration.test.ts | 34 +- ChatSetAttr/src/__tests__/unit/chat.test.ts | 75 ++- .../src/__tests__/unit/message.test.ts | 6 + .../src/__tests__/unit/observer.test.ts | 175 +++---- .../__tests__/unit/observerPayload.test.ts | 272 +++++++++++ .../src/__tests__/unit/targets.test.ts | 65 +++ ChatSetAttr/src/__tests__/unit/update.test.ts | 167 ++++++- ChatSetAttr/src/modules/chat.ts | 41 +- ChatSetAttr/src/modules/commands.ts | 4 +- ChatSetAttr/src/modules/main.ts | 44 +- ChatSetAttr/src/modules/observer.ts | 16 +- ChatSetAttr/src/modules/observerPayload.ts | 355 ++++++++++++++ ChatSetAttr/src/modules/targets.ts | 28 +- ChatSetAttr/src/modules/updates.ts | 158 ++++++- ChatSetAttr/src/types.ts | 24 +- .../0.0.4/.libSmartAttributes.js.swp | Bin 12288 -> 0 bytes libSmartAttributes/src/.index.ts.swo | Bin 16384 -> 0 bytes libSmartAttributes/src/.index.ts.swp | Bin 12288 -> 0 bytes 23 files changed, 2243 insertions(+), 292 deletions(-) create mode 100644 ChatSetAttr/src/__tests__/unit/observerPayload.test.ts create mode 100644 ChatSetAttr/src/modules/observerPayload.ts delete mode 100644 libSmartAttributes/0.0.4/.libSmartAttributes.js.swp delete mode 100644 libSmartAttributes/src/.index.ts.swo delete mode 100644 libSmartAttributes/src/.index.ts.swp diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 46e8820e3e..c7dcd623be 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -178,13 +178,248 @@ var ChatSetAttr = (function (exports) { } observers[event].push(callback); } - function notifyObservers(event, targetID, attributeName, newValue, oldValue) { + function notifyObservers(event, obj, prev) { const callbacks = observers[event] || []; callbacks.forEach(callback => { - callback(event, targetID, attributeName, newValue, oldValue); + callback(obj, prev); }); } + const WRITABLE_KEYS = new Set(["current", "max"]); + function normalizeKey(key) { + return key.startsWith("_") ? key.slice(1) : key; + } + function toAttrString(value) { + if (value === undefined || value === null) { + return ""; + } + return String(value); + } + function hasSheetItemValue(value) { + return value !== null && value !== undefined && value !== ""; + } + function hasPriorValue$1(value) { + return value !== undefined && value !== null && value !== ""; + } + function toSnapshot(targetId, actualName, kind, state, id = "") { + return { + _id: id, + _type: kind, + _characterid: targetId, + name: actualName, + current: state.current, + max: state.max, + }; + } + function mergeAttributeState(targetId, actualName, priorValues, results, isDelete) { + const maxKey = `${actualName}_max`; + const priorCurrent = priorValues[targetId]?.[actualName]; + const priorMax = priorValues[targetId]?.[maxKey]; + if (isDelete) { + return { + current: toAttrString(priorCurrent), + max: toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + const newCurrent = results[targetId]?.[actualName]; + const newMax = results[targetId]?.[maxKey]; + return { + current: newCurrent !== undefined ? toAttrString(newCurrent) : toAttrString(priorCurrent), + max: newMax !== undefined ? toAttrString(newMax) : toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + function tryFindLegacyAttribute(targetId, actualName) { + return findObjs({ + _type: "attribute", + _characterid: targetId, + name: actualName, + })[0]; + } + function isLegacySheet(targetId) { + const character = getObj("character", targetId); + return character?.sheetEnvironment === "legacy"; + } + function legacyAttributeForSheet(targetId, actualName) { + if (!isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } + async function resolveObserverKind(targetId, actualName) { + if (isLegacySheet(targetId)) { + return "attribute"; + } + const computed = await getSheetItem(targetId, actualName, "current"); + const computedMax = await getSheetItem(targetId, actualName, "max"); + if (hasSheetItemValue(computed) || hasSheetItemValue(computedMax)) { + return "computed"; + } + const userAttr = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + if (hasSheetItemValue(userAttr) || hasSheetItemValue(userMax)) { + return "userAttribute"; + } + return "computed"; + } + function isNewAttributeOrUser(kind, state) { + if (kind === "computed") { + return false; + } + return state.priorCurrent === "" && state.priorMax === ""; + } + function sheetItemPath(kind, actualName) { + return kind === "userAttribute" ? `user.${actualName}` : actualName; + } + async function writeSheetItemValue(characterId, kind, actualName, key, value) { + const normalized = normalizeKey(key); + if (!WRITABLE_KEYS.has(normalized)) { + return false; + } + const type = normalized; + const path = sheetItemPath(kind, actualName); + try { + await setSheetItem(characterId, path, value, type, { + allowThrow: true, + createAttr: true, + withWorker: true, + }); + return true; + } + catch { + return false; + } + } + function createObserverAttributeObject(targetId, actualName, kind, state, id = "") { + const snapshot = toSnapshot(targetId, actualName, kind, state, id); + const obj = { + get(key) { + const normalized = normalizeKey(key); + const byKey = { + id: snapshot._id, + _id: snapshot._id, + type: snapshot._type, + _type: snapshot._type, + characterid: snapshot._characterid, + _characterid: snapshot._characterid, + name: snapshot.name, + current: snapshot.current, + max: snapshot.max, + }; + return byKey[normalized] ?? byKey[key]; + }, + set(keyOrProps, value) { + const updates = {}; + if (typeof keyOrProps === "string") { + const normalized = normalizeKey(keyOrProps); + if (WRITABLE_KEYS.has(normalized) && value !== undefined) { + updates[normalized] = value; + } + } + else { + if (keyOrProps.current !== undefined) { + updates.current = keyOrProps.current; + } + if (keyOrProps.max !== undefined) { + updates.max = keyOrProps.max; + } + } + for (const [key, nextValue] of Object.entries(updates)) { + if (nextValue === undefined) { + continue; + } + void writeSheetItemValue(targetId, kind, actualName, key, nextValue).then(ok => { + if (ok) { + snapshot[key] = nextValue; + } + }); + } + return obj; + }, + toJSON() { + return { ...snapshot }; + }, + }; + return obj; + } + function resolveObserverObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + function resolveObserverAddObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + async function captureDeletePriorState(targetId, actualName, kind, priorValues) { + const maxKey = `${actualName}_max`; + let priorCurrent = priorValues[targetId]?.[actualName]; + let priorMax = priorValues[targetId]?.[maxKey]; + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = legacyAttr.get("current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = legacyAttr.get("max"); + } + } + else { + const userCurrent = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + const hasUserValues = hasSheetItemValue(userCurrent) || hasSheetItemValue(userMax); + const path = hasUserValues || kind === "userAttribute" + ? `user.${actualName}` + : actualName; + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = await getSheetItem(targetId, path, "current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = await getSheetItem(targetId, path, "max"); + } + if (!hasPriorValue$1(priorCurrent) && hasUserValues) { + priorCurrent = userCurrent; + } + if (!hasPriorValue$1(priorMax) && hasUserValues) { + priorMax = userMax; + } + } + const current = toAttrString(priorCurrent); + const max = toAttrString(priorMax); + return { + current, + max, + priorCurrent: current, + priorMax: max, + }; + } + function logicalAttributeKey(target, actualName) { + return `${target}:${actualName}`; + } + function toActualName(name) { + const isMax = name.endsWith("_max"); + return { + actualName: isMax ? name.slice(0, -4) : name, + isMax, + }; + } + function buildSetAttributeOptions(overrides = {}) { const { useWorkers = true } = getConfig() || {}; return { @@ -195,26 +430,78 @@ var ChatSetAttr = (function (exports) { function failureKey(target, name) { return `${target}:${name}`; } - function observerEvent(operation, priorValue, isDelete) { - if (operation === "setattr" && priorValue === undefined) { - return "add"; + function collectLogicalGroups(results) { + const groups = new Map(); + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const key = logicalAttributeKey(target, actualName); + const existing = groups.get(key); + if (existing) { + existing.keys.push(name); + } + else { + groups.set(key, { target, actualName, keys: [name] }); + } + } } - return "change"; + return Array.from(groups.values()); + } + function groupHasFailure(group, failed) { + return group.keys.some(name => failed.has(failureKey(group.target, name))); + } + function shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results) { + if (!isMax) { + return false; + } + const maxKey = `${actualName}_max`; + const hasCompanionCurrent = Object.hasOwn(results[target], actualName); + const character = getObj("character", target); + if (character?.sheetEnvironment === "legacy") { + return hasCompanionCurrent; + } + // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. + if (hasCompanionCurrent) { + return true; + } + if (!hasPriorValue(priorValues[target]?.[maxKey])) { + return true; + } + return false; + } + function hasPriorValue(value) { + return value !== undefined && value !== null && value !== ""; } async function makeUpdate(operation, results, options) { const isSetting = operation !== "delattr"; const errors = []; const messages = []; const failed = []; - const { noCreate = false, priorValues = {}, operation: op = operation } = options || {}; + const failedSet = new Set(); + const { noCreate = false, priorValues = {} } = options || {}; const setOptions = buildSetAttributeOptions({ noCreate }); + const deleteKinds = new Map(); + const deleteStates = new Map(); + if (!isSetting) { + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const groupKey = logicalAttributeKey(target, actualName); + if (!deleteKinds.has(groupKey)) { + deleteKinds.set(groupKey, await resolveObserverKind(target, actualName)); + } + if (!deleteStates.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteStates.set(groupKey, await captureDeletePriorState(target, actualName, kind, priorValues)); + } + } + } + } for (const target in results) { for (const name in results[target]) { - const isMax = name.endsWith("_max"); + const { actualName, isMax } = toActualName(name); const type = isMax ? "max" : "current"; - const actualName = isMax ? name.slice(0, -4) : name; const key = failureKey(target, name); - const priorValue = priorValues[target]?.[name]; const newValue = results[target][name]; if (isSetting) { const value = newValue ?? ""; @@ -222,34 +509,64 @@ var ChatSetAttr = (function (exports) { const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); if (!ok) { failed.push(key); + failedSet.add(key); errors.push(`Failed to set attribute '${name}' on target '${target}'.`); - continue; } - const event = observerEvent(op, priorValue, false); - notifyObservers(event, target, name, newValue, priorValue); } catch (error) { failed.push(key); + failedSet.add(key); errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); } } else { + if (shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results)) { + continue; + } try { const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); if (!ok) { failed.push(key); + failedSet.add(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); - continue; } - notifyObservers("destroy", target, name, newValue, priorValue); } catch (error) { failed.push(key); + failedSet.add(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); } } } } + const groups = collectLogicalGroups(results); + for (const group of groups) { + if (groupHasFailure(group, failedSet)) { + continue; + } + const groupKey = logicalAttributeKey(group.target, group.actualName); + const state = isSetting + ? mergeAttributeState(group.target, group.actualName, priorValues, results, false) + : deleteStates.get(groupKey) ?? mergeAttributeState(group.target, group.actualName, priorValues, results, true); + const kind = isSetting + ? await resolveObserverKind(group.target, group.actualName) + : deleteKinds.get(logicalAttributeKey(group.target, group.actualName)) ?? await resolveObserverKind(group.target, group.actualName); + if (isSetting) { + const prev = toSnapshot(group.target, group.actualName, kind, { + current: state.priorCurrent, + max: state.priorMax, + }); + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + if (isNewAttributeOrUser(kind, state)) { + notifyObservers("add", resolveObserverAddObj(group.target, group.actualName, kind, state)); + } + notifyObservers("change", obj, prev); + } + else { + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + notifyObservers("destroy", obj); + } + } return { errors, messages, failed }; } @@ -355,26 +672,36 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); } - //import { s } from "../utils/chat"; + function normalizeCommandOutputOptions(options = {}) { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; + } function getPlayerName(playerID) { const player = getObj("player", playerID); return player?.get("_displayname") || undefined; } - function sendMessages(playerID, header, messages, from = "ChatSetAttr") { + function sendMessages(playerID, header, messages, from = "ChatSetAttr", output) { + if (output?.silent) { + return; + } const newMessage = createChatMessage(header, messages); const player = getPlayerName(playerID); sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } - function sendErrors(playerID, header, errors, from = "ChatSetAttr") { - if (errors.length === 0) + function sendErrors(playerID, header, errors, from = "ChatSetAttr", output) { + if (errors.length === 0 || output?.mute) { return; + } const newMessage = createErrorMessage(header, errors); const player = getPlayerName(playerID); sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } - function sendDelayMessage(silent = false) { - if (silent) + function sendDelayMessage(output) { + if (output?.silent) { return; + } const delayMessage = createDelayMessage(); sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); } @@ -669,9 +996,7 @@ var ChatSetAttr = (function (exports) { for (const change of changes) { if (change.name) { queriedAttributes.add(change.name); - if (change.max !== undefined) { - queriedAttributes.add(`${change.name}_max`); - } + queriedAttributes.add(`${change.name}_max`); } } const attributes = await getAttributes(target, Array.from(queriedAttributes)); @@ -1806,12 +2131,31 @@ var ChatSetAttr = (function (exports) { errors, }; } + function splitCommaSeparatedValues(valueString) { + if (!valueString) { + return []; + } + return valueString.split(/\s*,\s*/).map(v => v.trim()).filter(v => v.length > 0); + } + function parseTargetOption(option) { + const trimmed = option.trim(); + const spaceIndex = trimmed.indexOf(" "); + if (spaceIndex === -1) { + return { type: trimmed, values: [] }; + } + const type = trimmed.slice(0, spaceIndex); + const remainder = trimmed.slice(spaceIndex + 1).trim(); + if (type === "name" || type === "charid") { + return { type, values: splitCommaSeparatedValues(remainder) }; + } + return { type, values: [] }; + } function generateNameTargets(values) { const { playerID } = getPermissions(); const targets = []; const errors = []; for (const name of values) { - const characters = findObjs({ _type: "character", name: name }); + const characters = findObjs({ _type: "character", name }, { caseInsensitive: true }); if (characters.length === 0) { errors.push(`Character with name "${name}" not found.`); continue; @@ -1838,7 +2182,7 @@ var ChatSetAttr = (function (exports) { const characterIDs = []; const errors = []; for (const option of targetOptions) { - const [type, ...values] = option.split(/[, ]/).map(v => v.trim()).filter(v => v.length > 0); + const { type, values } = parseTargetOption(option); if (type === "sel" || type === "sel-noparty" || type === "sel-party") { const results = generateSelectedTargets(message, type); characterIDs.push(...results.targets); @@ -1916,31 +2260,32 @@ var ChatSetAttr = (function (exports) { const result = {}; // Parse Message const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); + const output = normalizeCommandOutputOptions(options); // Start Timer - startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + startTimer("chatsetattr", 8000, () => sendDelayMessage(output)); // Check Config and Permissions const config = getConfig(); const isAPI = "API" === msg.playerid; const isGM = playerIsGM(msg.playerid); if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { - return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors, output); } if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { - return errorOut("You do not have permission to target the party.", msg.playerid, errors); + return errorOut("You do not have permission to target the party.", msg.playerid, errors, output); } if ((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { - return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors, output); } // Preprocess const { targets, errors: targetErrors } = generateTargets(msg, targeting); errors.push(...targetErrors); if (targets.length === 0) { - return errorOut("No valid targets found.", msg.playerid, errors); + return errorOut("No valid targets found.", msg.playerid, errors, output); } const request = generateRequest(references, changes); const command = handlers[operation]; if (!command) { - return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors, output); } // Execute const priorValues = {}; @@ -1961,9 +2306,7 @@ var ChatSetAttr = (function (exports) { } const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate, - priorValues, - operation, - }); + priorValues}); clearTimer("chatsetattr"); errors.push(...updateResult.errors); for (const target in pendingMessages) { @@ -1973,31 +2316,28 @@ var ChatSetAttr = (function (exports) { } } } - if (options.silent) - return; - sendErrors(msg.playerid, "Errors", errors, feedback?.from); - if (options.mute) - return; + sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); + sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from, output); } - function errorOut(errorText, playerid, errors) { + function errorOut(errorText, playerid, errors, output) { errors.push(errorText); - sendErrors(playerid, "Errors", errors); + sendErrors(playerid, "Errors", errors, undefined, output); clearTimer("chatsetattr"); } function generateRequest(references, changes) { const referenceSet = new Set(references); for (const change of changes) { - if (change.name && !referenceSet.has(change.name)) { + if (!change.name) { + continue; + } + if (!referenceSet.has(change.name)) { referenceSet.add(change.name); } - if (change.max !== undefined) { - const maxName = `${change.name}_max`; - if (!referenceSet.has(maxName)) { - referenceSet.add(maxName); - } + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); } } return Array.from(referenceSet); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 46e8820e3e..c7dcd623be 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -178,13 +178,248 @@ var ChatSetAttr = (function (exports) { } observers[event].push(callback); } - function notifyObservers(event, targetID, attributeName, newValue, oldValue) { + function notifyObservers(event, obj, prev) { const callbacks = observers[event] || []; callbacks.forEach(callback => { - callback(event, targetID, attributeName, newValue, oldValue); + callback(obj, prev); }); } + const WRITABLE_KEYS = new Set(["current", "max"]); + function normalizeKey(key) { + return key.startsWith("_") ? key.slice(1) : key; + } + function toAttrString(value) { + if (value === undefined || value === null) { + return ""; + } + return String(value); + } + function hasSheetItemValue(value) { + return value !== null && value !== undefined && value !== ""; + } + function hasPriorValue$1(value) { + return value !== undefined && value !== null && value !== ""; + } + function toSnapshot(targetId, actualName, kind, state, id = "") { + return { + _id: id, + _type: kind, + _characterid: targetId, + name: actualName, + current: state.current, + max: state.max, + }; + } + function mergeAttributeState(targetId, actualName, priorValues, results, isDelete) { + const maxKey = `${actualName}_max`; + const priorCurrent = priorValues[targetId]?.[actualName]; + const priorMax = priorValues[targetId]?.[maxKey]; + if (isDelete) { + return { + current: toAttrString(priorCurrent), + max: toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + const newCurrent = results[targetId]?.[actualName]; + const newMax = results[targetId]?.[maxKey]; + return { + current: newCurrent !== undefined ? toAttrString(newCurrent) : toAttrString(priorCurrent), + max: newMax !== undefined ? toAttrString(newMax) : toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + function tryFindLegacyAttribute(targetId, actualName) { + return findObjs({ + _type: "attribute", + _characterid: targetId, + name: actualName, + })[0]; + } + function isLegacySheet(targetId) { + const character = getObj("character", targetId); + return character?.sheetEnvironment === "legacy"; + } + function legacyAttributeForSheet(targetId, actualName) { + if (!isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } + async function resolveObserverKind(targetId, actualName) { + if (isLegacySheet(targetId)) { + return "attribute"; + } + const computed = await getSheetItem(targetId, actualName, "current"); + const computedMax = await getSheetItem(targetId, actualName, "max"); + if (hasSheetItemValue(computed) || hasSheetItemValue(computedMax)) { + return "computed"; + } + const userAttr = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + if (hasSheetItemValue(userAttr) || hasSheetItemValue(userMax)) { + return "userAttribute"; + } + return "computed"; + } + function isNewAttributeOrUser(kind, state) { + if (kind === "computed") { + return false; + } + return state.priorCurrent === "" && state.priorMax === ""; + } + function sheetItemPath(kind, actualName) { + return kind === "userAttribute" ? `user.${actualName}` : actualName; + } + async function writeSheetItemValue(characterId, kind, actualName, key, value) { + const normalized = normalizeKey(key); + if (!WRITABLE_KEYS.has(normalized)) { + return false; + } + const type = normalized; + const path = sheetItemPath(kind, actualName); + try { + await setSheetItem(characterId, path, value, type, { + allowThrow: true, + createAttr: true, + withWorker: true, + }); + return true; + } + catch { + return false; + } + } + function createObserverAttributeObject(targetId, actualName, kind, state, id = "") { + const snapshot = toSnapshot(targetId, actualName, kind, state, id); + const obj = { + get(key) { + const normalized = normalizeKey(key); + const byKey = { + id: snapshot._id, + _id: snapshot._id, + type: snapshot._type, + _type: snapshot._type, + characterid: snapshot._characterid, + _characterid: snapshot._characterid, + name: snapshot.name, + current: snapshot.current, + max: snapshot.max, + }; + return byKey[normalized] ?? byKey[key]; + }, + set(keyOrProps, value) { + const updates = {}; + if (typeof keyOrProps === "string") { + const normalized = normalizeKey(keyOrProps); + if (WRITABLE_KEYS.has(normalized) && value !== undefined) { + updates[normalized] = value; + } + } + else { + if (keyOrProps.current !== undefined) { + updates.current = keyOrProps.current; + } + if (keyOrProps.max !== undefined) { + updates.max = keyOrProps.max; + } + } + for (const [key, nextValue] of Object.entries(updates)) { + if (nextValue === undefined) { + continue; + } + void writeSheetItemValue(targetId, kind, actualName, key, nextValue).then(ok => { + if (ok) { + snapshot[key] = nextValue; + } + }); + } + return obj; + }, + toJSON() { + return { ...snapshot }; + }, + }; + return obj; + } + function resolveObserverObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + function resolveObserverAddObj(targetId, actualName, kind, state) { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); + } + async function captureDeletePriorState(targetId, actualName, kind, priorValues) { + const maxKey = `${actualName}_max`; + let priorCurrent = priorValues[targetId]?.[actualName]; + let priorMax = priorValues[targetId]?.[maxKey]; + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = legacyAttr.get("current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = legacyAttr.get("max"); + } + } + else { + const userCurrent = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + const hasUserValues = hasSheetItemValue(userCurrent) || hasSheetItemValue(userMax); + const path = hasUserValues || kind === "userAttribute" + ? `user.${actualName}` + : actualName; + if (!hasPriorValue$1(priorCurrent)) { + priorCurrent = await getSheetItem(targetId, path, "current"); + } + if (!hasPriorValue$1(priorMax)) { + priorMax = await getSheetItem(targetId, path, "max"); + } + if (!hasPriorValue$1(priorCurrent) && hasUserValues) { + priorCurrent = userCurrent; + } + if (!hasPriorValue$1(priorMax) && hasUserValues) { + priorMax = userMax; + } + } + const current = toAttrString(priorCurrent); + const max = toAttrString(priorMax); + return { + current, + max, + priorCurrent: current, + priorMax: max, + }; + } + function logicalAttributeKey(target, actualName) { + return `${target}:${actualName}`; + } + function toActualName(name) { + const isMax = name.endsWith("_max"); + return { + actualName: isMax ? name.slice(0, -4) : name, + isMax, + }; + } + function buildSetAttributeOptions(overrides = {}) { const { useWorkers = true } = getConfig() || {}; return { @@ -195,26 +430,78 @@ var ChatSetAttr = (function (exports) { function failureKey(target, name) { return `${target}:${name}`; } - function observerEvent(operation, priorValue, isDelete) { - if (operation === "setattr" && priorValue === undefined) { - return "add"; + function collectLogicalGroups(results) { + const groups = new Map(); + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const key = logicalAttributeKey(target, actualName); + const existing = groups.get(key); + if (existing) { + existing.keys.push(name); + } + else { + groups.set(key, { target, actualName, keys: [name] }); + } + } } - return "change"; + return Array.from(groups.values()); + } + function groupHasFailure(group, failed) { + return group.keys.some(name => failed.has(failureKey(group.target, name))); + } + function shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results) { + if (!isMax) { + return false; + } + const maxKey = `${actualName}_max`; + const hasCompanionCurrent = Object.hasOwn(results[target], actualName); + const character = getObj("character", target); + if (character?.sheetEnvironment === "legacy") { + return hasCompanionCurrent; + } + // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. + if (hasCompanionCurrent) { + return true; + } + if (!hasPriorValue(priorValues[target]?.[maxKey])) { + return true; + } + return false; + } + function hasPriorValue(value) { + return value !== undefined && value !== null && value !== ""; } async function makeUpdate(operation, results, options) { const isSetting = operation !== "delattr"; const errors = []; const messages = []; const failed = []; - const { noCreate = false, priorValues = {}, operation: op = operation } = options || {}; + const failedSet = new Set(); + const { noCreate = false, priorValues = {} } = options || {}; const setOptions = buildSetAttributeOptions({ noCreate }); + const deleteKinds = new Map(); + const deleteStates = new Map(); + if (!isSetting) { + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const groupKey = logicalAttributeKey(target, actualName); + if (!deleteKinds.has(groupKey)) { + deleteKinds.set(groupKey, await resolveObserverKind(target, actualName)); + } + if (!deleteStates.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteStates.set(groupKey, await captureDeletePriorState(target, actualName, kind, priorValues)); + } + } + } + } for (const target in results) { for (const name in results[target]) { - const isMax = name.endsWith("_max"); + const { actualName, isMax } = toActualName(name); const type = isMax ? "max" : "current"; - const actualName = isMax ? name.slice(0, -4) : name; const key = failureKey(target, name); - const priorValue = priorValues[target]?.[name]; const newValue = results[target][name]; if (isSetting) { const value = newValue ?? ""; @@ -222,34 +509,64 @@ var ChatSetAttr = (function (exports) { const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); if (!ok) { failed.push(key); + failedSet.add(key); errors.push(`Failed to set attribute '${name}' on target '${target}'.`); - continue; } - const event = observerEvent(op, priorValue, false); - notifyObservers(event, target, name, newValue, priorValue); } catch (error) { failed.push(key); + failedSet.add(key); errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); } } else { + if (shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results)) { + continue; + } try { const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); if (!ok) { failed.push(key); + failedSet.add(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); - continue; } - notifyObservers("destroy", target, name, newValue, priorValue); } catch (error) { failed.push(key); + failedSet.add(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); } } } } + const groups = collectLogicalGroups(results); + for (const group of groups) { + if (groupHasFailure(group, failedSet)) { + continue; + } + const groupKey = logicalAttributeKey(group.target, group.actualName); + const state = isSetting + ? mergeAttributeState(group.target, group.actualName, priorValues, results, false) + : deleteStates.get(groupKey) ?? mergeAttributeState(group.target, group.actualName, priorValues, results, true); + const kind = isSetting + ? await resolveObserverKind(group.target, group.actualName) + : deleteKinds.get(logicalAttributeKey(group.target, group.actualName)) ?? await resolveObserverKind(group.target, group.actualName); + if (isSetting) { + const prev = toSnapshot(group.target, group.actualName, kind, { + current: state.priorCurrent, + max: state.priorMax, + }); + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + if (isNewAttributeOrUser(kind, state)) { + notifyObservers("add", resolveObserverAddObj(group.target, group.actualName, kind, state)); + } + notifyObservers("change", obj, prev); + } + else { + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + notifyObservers("destroy", obj); + } + } return { errors, messages, failed }; } @@ -355,26 +672,36 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); } - //import { s } from "../utils/chat"; + function normalizeCommandOutputOptions(options = {}) { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; + } function getPlayerName(playerID) { const player = getObj("player", playerID); return player?.get("_displayname") || undefined; } - function sendMessages(playerID, header, messages, from = "ChatSetAttr") { + function sendMessages(playerID, header, messages, from = "ChatSetAttr", output) { + if (output?.silent) { + return; + } const newMessage = createChatMessage(header, messages); const player = getPlayerName(playerID); sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } - function sendErrors(playerID, header, errors, from = "ChatSetAttr") { - if (errors.length === 0) + function sendErrors(playerID, header, errors, from = "ChatSetAttr", output) { + if (errors.length === 0 || output?.mute) { return; + } const newMessage = createErrorMessage(header, errors); const player = getPlayerName(playerID); sendChat(from, `/w "${player || "GM"}" ${newMessage}`); } - function sendDelayMessage(silent = false) { - if (silent) + function sendDelayMessage(output) { + if (output?.silent) { return; + } const delayMessage = createDelayMessage(); sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); } @@ -669,9 +996,7 @@ var ChatSetAttr = (function (exports) { for (const change of changes) { if (change.name) { queriedAttributes.add(change.name); - if (change.max !== undefined) { - queriedAttributes.add(`${change.name}_max`); - } + queriedAttributes.add(`${change.name}_max`); } } const attributes = await getAttributes(target, Array.from(queriedAttributes)); @@ -1806,12 +2131,31 @@ var ChatSetAttr = (function (exports) { errors, }; } + function splitCommaSeparatedValues(valueString) { + if (!valueString) { + return []; + } + return valueString.split(/\s*,\s*/).map(v => v.trim()).filter(v => v.length > 0); + } + function parseTargetOption(option) { + const trimmed = option.trim(); + const spaceIndex = trimmed.indexOf(" "); + if (spaceIndex === -1) { + return { type: trimmed, values: [] }; + } + const type = trimmed.slice(0, spaceIndex); + const remainder = trimmed.slice(spaceIndex + 1).trim(); + if (type === "name" || type === "charid") { + return { type, values: splitCommaSeparatedValues(remainder) }; + } + return { type, values: [] }; + } function generateNameTargets(values) { const { playerID } = getPermissions(); const targets = []; const errors = []; for (const name of values) { - const characters = findObjs({ _type: "character", name: name }); + const characters = findObjs({ _type: "character", name }, { caseInsensitive: true }); if (characters.length === 0) { errors.push(`Character with name "${name}" not found.`); continue; @@ -1838,7 +2182,7 @@ var ChatSetAttr = (function (exports) { const characterIDs = []; const errors = []; for (const option of targetOptions) { - const [type, ...values] = option.split(/[, ]/).map(v => v.trim()).filter(v => v.length > 0); + const { type, values } = parseTargetOption(option); if (type === "sel" || type === "sel-noparty" || type === "sel-party") { const results = generateSelectedTargets(message, type); characterIDs.push(...results.targets); @@ -1916,31 +2260,32 @@ var ChatSetAttr = (function (exports) { const result = {}; // Parse Message const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); + const output = normalizeCommandOutputOptions(options); // Start Timer - startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + startTimer("chatsetattr", 8000, () => sendDelayMessage(output)); // Check Config and Permissions const config = getConfig(); const isAPI = "API" === msg.playerid; const isGM = playerIsGM(msg.playerid); if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { - return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors, output); } if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { - return errorOut("You do not have permission to target the party.", msg.playerid, errors); + return errorOut("You do not have permission to target the party.", msg.playerid, errors, output); } if ((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { - return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors, output); } // Preprocess const { targets, errors: targetErrors } = generateTargets(msg, targeting); errors.push(...targetErrors); if (targets.length === 0) { - return errorOut("No valid targets found.", msg.playerid, errors); + return errorOut("No valid targets found.", msg.playerid, errors, output); } const request = generateRequest(references, changes); const command = handlers[operation]; if (!command) { - return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors, output); } // Execute const priorValues = {}; @@ -1961,9 +2306,7 @@ var ChatSetAttr = (function (exports) { } const updateResult = await makeUpdate(operation, result, { noCreate: options.nocreate, - priorValues, - operation, - }); + priorValues}); clearTimer("chatsetattr"); errors.push(...updateResult.errors); for (const target in pendingMessages) { @@ -1973,31 +2316,28 @@ var ChatSetAttr = (function (exports) { } } } - if (options.silent) - return; - sendErrors(msg.playerid, "Errors", errors, feedback?.from); - if (options.mute) - return; + sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); + sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from, output); } - function errorOut(errorText, playerid, errors) { + function errorOut(errorText, playerid, errors, output) { errors.push(errorText); - sendErrors(playerid, "Errors", errors); + sendErrors(playerid, "Errors", errors, undefined, output); clearTimer("chatsetattr"); } function generateRequest(references, changes) { const referenceSet = new Set(references); for (const change of changes) { - if (change.name && !referenceSet.has(change.name)) { + if (!change.name) { + continue; + } + if (!referenceSet.has(change.name)) { referenceSet.add(change.name); } - if (change.max !== undefined) { - const maxName = `${change.name}_max`; - if (!referenceSet.has(maxName)) { - referenceSet.add(maxName); - } + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); } } return Array.from(referenceSet); diff --git a/ChatSetAttr/src/__mocks__/apiObjects.mock.ts b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts index 8f0cf89745..b4f8dd2ec0 100644 --- a/ChatSetAttr/src/__mocks__/apiObjects.mock.ts +++ b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts @@ -85,9 +85,22 @@ export function mockGetObj( return found; }; +function propertyMatches( + actual: unknown, + expected: unknown, + caseInsensitive: boolean, +): boolean { + if (caseInsensitive && typeof actual === "string" && typeof expected === "string") { + return actual.toLowerCase() === expected.toLowerCase(); + } + return actual === expected; +}; + export function mockFindObjs( attrs: Partial & { _type: T }, + options?: { caseInsensitive?: boolean }, ): Roll20ObjectTypeToInstance[T][] { + const caseInsensitive = options?.caseInsensitive ?? false; debugLog("================================="); debugLog("mockFindObjs called with attrs:", attrs); debugLog("Current allObjects:", allObjects.map(obj => obj.id)); @@ -102,7 +115,7 @@ export function mockFindObjs( debugWarn(`Property ${fixedKey} not found on object ${obj.id}`); return false; } - if ((obj.properties as Record)[fixedKey] !== value) { + if (!propertyMatches((obj.properties as Record)[fixedKey], value, caseInsensitive)) { debugWarn(`Property ${fixedKey} on object ${obj.id} has value ${(obj.properties as Record)[fixedKey]}, expected ${value}`); return false; } diff --git a/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts index 3f7d1a7f6b..ae58e6a373 100644 --- a/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts +++ b/ChatSetAttr/src/__mocks__/beaconAttributes.mock.ts @@ -72,7 +72,7 @@ type SetSheetItemOptions = { export async function setSheetItem( characterId: string, attributeName: string, - value: string, + value: string | undefined, type: "current" | "max" = "current", options?: SetSheetItemOptions, ): Promise { @@ -88,6 +88,20 @@ export async function setSheetItem( throw new Error(`Sheet item ${attributeName} not found on character ${characterId}`); } + if (value === undefined || value === null) { + if (!beaconAttributes[characterId]?.[attributeName]) { + return false; + } + + if (isUserAttribute && type === "current") { + delete beaconAttributes[characterId][attributeName]; + return true; + } + + beaconAttributes[characterId][attributeName][type] = ""; + return true; + } + if (!beaconAttributes[characterId]) { beaconAttributes[characterId] = {}; } diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index 20e9a91099..c18cd1129a 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -73,10 +73,26 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should set attributes for comma-separated multi-word character names", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "bob1", name: "Bob the Slayer", controlledby: player.id }); + createObj("character", { _id: "timmy1", name: "Timmy the Weak", controlledby: player.id }); + + executeCommand("!setattr --name bob the slayer, timmy the weak --time|now"); + + await vi.waitFor(async () => { + const bobTime = await libSmartAttributes.getAttribute("bob1", "time"); + const timmyTime = await libSmartAttributes.getAttribute("timmy1", "time"); + + expect(bobTime).toBe("now"); + expect(timmyTime).toBe("now"); + }); + }); + 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: "john2", name: "Jonathan", controlledby: player.id }); createObj("character", { _id: "char3", name: "NotJohn", controlledby: player.id }); executeCommand("!setattr --name John --HP|17|27 --Dex|10"); @@ -801,7 +817,7 @@ describe("ChatSetAttr Integration Tests", () => { createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); vi.mocked(sendChat).mockClear(); - executeCommand("!setattr --charid char1 --mute --mod --NonNumeric|abc --Value|5"); + executeCommand("!setattr --charid char1 --mute --nocreate --MissingAttr|5"); await vi.waitFor(() => { const errorCall = vi.mocked(sendChat).mock.calls.find(call => @@ -811,6 +827,36 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should suppress errors when mute is used with no valid target", 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 --mute --thisisafakecommand --hp|140"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("No valid targets found") + ); + expect(errorCall).toBeUndefined(); + }); + }); + + it("should still show errors when silent is used with no valid target", 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 --silent --thisisafakecommand --hp|140"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("No valid targets found") + ); + expect(errorCall).toBeDefined(); + }); + }); + 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 }); @@ -835,24 +881,28 @@ describe("ChatSetAttr Integration Tests", () => { 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(); + const mockAddObserver = vi.fn(); + const mockChangeObserver = vi.fn(); - ChatSetAttr.registerObserver("add", mockObserver); + ChatSetAttr.registerObserver("add", mockAddObserver); + ChatSetAttr.registerObserver("change", mockChangeObserver); 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 - ]); + expect(mockAddObserver).toHaveBeenCalled(); + expect(mockChangeObserver).toHaveBeenCalled(); }); + + const addCall = mockAddObserver.mock.calls.find(call => call[0]?.get?.("name") === "NewAttribute"); + expect(addCall).toBeDefined(); + expect(addCall?.[0].get("current")).toBe("42"); + expect(mockChangeObserver.mock.calls.find(call => call[0]?.get?.("name") === "NewAttribute")?.[0].get("current")).toBe("42"); + expect(mockChangeObserver.mock.calls.find(call => call[0]?.get?.("name") === "NewAttribute")?.[1]).toEqual(expect.objectContaining({ + name: "NewAttribute", + current: "", + max: "", + })); }); it("should observe attribute changes with registered observers", async () => { @@ -867,22 +917,21 @@ describe("ChatSetAttr Integration Tests", () => { await vi.waitFor(() => { expect(mockObserver).toHaveBeenCalled(); - const calls = mockObserver.mock.calls; - const firstCall = calls[0]; - expect(firstCall).toStrictEqual([ - "change", - "char1", - "ExistingAttr", - "20", - "10", - ]); }); + + const firstCall = mockObserver.mock.calls[0]; + expect(firstCall[0].get("name")).toBe("ExistingAttr"); + expect(firstCall[0].get("current")).toBe("20"); + expect(firstCall[1]).toEqual(expect.objectContaining({ + name: "ExistingAttr", + current: "10", + })); }); it("should observe attribute deletions with registered observers", async () => { const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); - createObj("attribute", { _id: "attr1", _characterid: "char1", name: "DeleteMe", current: "10" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "DeleteMe", current: "10", max: "20" }); const mockObserver = vi.fn(); ChatSetAttr.registerObserver("destroy", mockObserver); @@ -891,16 +940,65 @@ describe("ChatSetAttr Integration Tests", () => { await vi.waitFor(() => { expect(mockObserver).toHaveBeenCalled(); - const calls = mockObserver.mock.calls; - const firstCall = calls[0]; - expect(firstCall).toStrictEqual([ - "destroy", - "char1", - "DeleteMe", - undefined, - "10", - ]); }); + + const firstCall = mockObserver.mock.calls[0]; + expect(firstCall[0].get("name")).toBe("DeleteMe"); + expect(firstCall[0].get("current")).toBe("10"); + expect(firstCall[0].get("max")).toBe("20"); + expect(firstCall[1]).toBeUndefined(); + }); + + it("should observe userAttribute 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 }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("destroy", mockObserver); + + executeCommand("!setattr --charid char1 --UserOnlyAttr|42"); + await vi.waitFor(() => { + expect(getBeaconAttributeNames("char1").some(name => name === "user.UserOnlyAttr")).toBe(true); + }); + + mockObserver.mockClear(); + executeCommand("!delattr --charid char1 --UserOnlyAttr"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + }); + + const firstCall = mockObserver.mock.calls[0]; + expect(firstCall[0].get("name")).toBe("UserOnlyAttr"); + expect(firstCall[0].get("current")).toBe("42"); + expect(firstCall[0].toJSON()._type).toBe("userAttribute"); + }); + + it("should observe userAttribute deletions with max on 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("destroy", mockObserver); + + executeCommand("!setattr --charid char1 --UserAttrWithMax|42"); + executeCommand("!setattr --charid char1 --UserAttrWithMax_max|100"); + await vi.waitFor(() => { + expect(getBeaconAttributeNames("char1").some(name => name === "user.UserAttrWithMax")).toBe(true); + }); + + mockObserver.mockClear(); + executeCommand("!delattr --charid char1 --UserAttrWithMax"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + }); + + const firstCall = mockObserver.mock.calls[0]; + expect(firstCall[0].get("name")).toBe("UserAttrWithMax"); + expect(firstCall[0].get("current")).toBe("42"); + expect(firstCall[0].get("max")).toBe("100"); + expect(firstCall[0].toJSON()._type).toBe("userAttribute"); }); it("should not notify observers when setAttribute returns false", async () => { diff --git a/ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts b/ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts index 4c4ea6b75c..05f497f7f4 100644 --- a/ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts +++ b/ChatSetAttr/src/__tests__/legacy/legacyIntegration.test.ts @@ -754,7 +754,7 @@ describe("ChatSetAttr Integration Tests", () => { createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); vi.mocked(sendChat).mockClear(); - executeCommand("!setattr --charid char1 --mute --mod --NonNumeric|abc --Value|5"); + executeCommand("!setattr --charid char1 --mute --nocreate --MissingAttr|5"); await vi.waitFor(() => { const errorCall = vi.mocked(sendChat).mock.calls.find(call => @@ -764,6 +764,36 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should suppress errors when mute is used with no valid target", 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 --mute --thisisafakecommand --hp|140"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("No target characters") + ); + expect(errorCall).toBeUndefined(); + }); + }); + + it("should still show errors when silent is used with no valid target", 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 --silent --thisisafakecommand --hp|140"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("No target characters") + ); + expect(errorCall).toBeDefined(); + }); + }); + 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 }); @@ -799,7 +829,7 @@ describe("ChatSetAttr Integration Tests", () => { const calls = mockObserver.mock.calls; const hasAddCall = calls.some(call => { const attr = call[0]; - return attr && attr.get("name") === "NewAttribute" && attr.get("current") === "42"; + return attr && attr.get("name") === "NewAttribute"; }); expect(hasAddCall).toBe(true); }); diff --git a/ChatSetAttr/src/__tests__/unit/chat.test.ts b/ChatSetAttr/src/__tests__/unit/chat.test.ts index abc0989e85..f8a7499243 100644 --- a/ChatSetAttr/src/__tests__/unit/chat.test.ts +++ b/ChatSetAttr/src/__tests__/unit/chat.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getPlayerName, sendMessages, sendErrors } from "../../modules/chat"; +import { + getPlayerName, + sendMessages, + sendErrors, + sendDelayMessage, + normalizeCommandOutputOptions, +} from "../../modules/chat"; // Mock the templates vi.mock("../../templates/messages", () => ({ @@ -7,6 +13,10 @@ vi.mock("../../templates/messages", () => ({ createErrorMessage: vi.fn(), })); +vi.mock("../../templates/delay", () => ({ + createDelayMessage: vi.fn(), +})); + // Mock Roll20 globals const mockPlayer = { get: vi.fn(), @@ -19,8 +29,10 @@ global.getObj = mockGetObj; global.sendChat = mockSendChat; import { createChatMessage, createErrorMessage } from "../../templates/messages"; +import { createDelayMessage } from "../../templates/delay"; const mockCreateChatMessage = vi.mocked(createChatMessage); const mockCreateErrorMessage = vi.mocked(createErrorMessage); +const mockCreateDelayMessage = vi.mocked(createDelayMessage); describe("chat", () => { beforeEach(() => { @@ -258,6 +270,67 @@ describe("chat", () => { }); }); + describe("normalizeCommandOutputOptions", () => { + it("should treat mute as silent for feedback and delay", () => { + expect(normalizeCommandOutputOptions({ mute: true })).toEqual({ + mute: true, + silent: true, + }); + }); + + it("should keep silent-only distinct from mute for errors", () => { + expect(normalizeCommandOutputOptions({ silent: true })).toEqual({ + mute: false, + silent: true, + }); + }); + }); + + describe("command output suppression", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateChatMessage.mockReturnValue("formatted-chat-message"); + mockCreateErrorMessage.mockReturnValue("formatted-error-message"); + mockCreateDelayMessage.mockReturnValue("delay-message"); + }); + + it("should suppress errors when mute is set", () => { + sendErrors("player123", "Errors", ["No valid targets found."], "ChatSetAttr", { mute: true, silent: true }); + + expect(mockCreateErrorMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should still send errors when only silent is set", () => { + sendErrors("player123", "Errors", ["No valid targets found."], "ChatSetAttr", { mute: false, silent: true }); + + expect(mockCreateErrorMessage).toHaveBeenCalled(); + expect(mockSendChat).toHaveBeenCalled(); + }); + + it("should suppress success messages when silent is set", () => { + sendMessages("player123", "Setting Attributes", ["Set attribute"], "ChatSetAttr", { mute: false, silent: true }); + + expect(mockCreateChatMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should suppress success messages when mute is set", () => { + sendMessages("player123", "Setting Attributes", ["Set attribute"], "ChatSetAttr", { mute: true, silent: true }); + + expect(mockCreateChatMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + + it("should suppress delay notice when mute is set", () => { + sendDelayMessage({ mute: true, silent: true }); + + expect(mockCreateDelayMessage).not.toHaveBeenCalled(); + expect(mockSendChat).not.toHaveBeenCalled(); + }); + }); + describe("integration scenarios", () => { beforeEach(() => { mockCreateChatMessage.mockReturnValue("chat-message"); diff --git a/ChatSetAttr/src/__tests__/unit/message.test.ts b/ChatSetAttr/src/__tests__/unit/message.test.ts index fe985dbba4..c2c188869c 100644 --- a/ChatSetAttr/src/__tests__/unit/message.test.ts +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -252,6 +252,12 @@ describe("message", () => { expect(result.targeting).toContain("name Gandalf"); }); + it("should preserve multi-word comma-separated name targeting", () => { + const result = parse("!setattr --name bob the slayer, timmy the weak --time|now"); + expect(result.targeting).toContain("name bob the slayer, timmy the weak"); + expect(result.changes).toEqual([{ name: "time", current: "now" }]); + }); + it("should parse sel target", () => { const result = parse("!setattr --sel --strength 18"); expect(result.targeting).toContain("sel"); diff --git a/ChatSetAttr/src/__tests__/unit/observer.test.ts b/ChatSetAttr/src/__tests__/unit/observer.test.ts index 8593469334..6e89cb4b57 100644 --- a/ChatSetAttr/src/__tests__/unit/observer.test.ts +++ b/ChatSetAttr/src/__tests__/unit/observer.test.ts @@ -1,164 +1,167 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { ObserverCallback } from "../../types"; +import type { ObserverAttributeSnapshot, ObserverCallback } from "../../types"; +import { createObserverAttributeObject } from "../../modules/observerPayload"; import { notifyObservers, registerObserver } from "../../modules/observer"; +function makeObserverObj(current = "10", max = "20") { + return createObserverAttributeObject("char1", "hp", "computed", { current, max }); +}; + +function makePrev(current = "5", max = "10"): ObserverAttributeSnapshot { + return { + _id: "", + _type: "computed", + _characterid: "char1", + name: "hp", + current, + max, + }; +}; + describe("observer", () => { beforeEach(async () => { - // Reset modules to clear the observers state vi.resetModules(); }); describe("registerObserver", () => { - it("should add a callback for a new event", () => { + it("should add a callback for a new event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const mockCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); - registerObserver("add", mockCallback); + reg("add", mockCallback); + notify("add", obj); - // Verify by triggering notification - notifyObservers("add", "exampleID", "exampleAttribute", "newValue", "oldValue"); expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith("add", "exampleID", "exampleAttribute", "newValue", "oldValue"); + expect(mockCallback).toHaveBeenCalledWith(obj, undefined); }); - it("should add multiple callbacks for the same event", () => { + it("should add multiple callbacks for the same event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const mockCallback1: ObserverCallback = vi.fn(); const mockCallback2: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + const prev = makePrev(); - registerObserver("change", mockCallback1); - registerObserver("change", mockCallback2); - - notifyObservers("change", "exampleID", "exampleAttribute", "newValue", "oldValue"); + reg("change", mockCallback1); + reg("change", mockCallback2); + notify("change", obj, prev); - expect(mockCallback1).toHaveBeenCalledTimes(1); - expect(mockCallback2).toHaveBeenCalledTimes(1); - expect(mockCallback1).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", "newValue", "oldValue"); - expect(mockCallback2).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", "newValue", "oldValue"); + expect(mockCallback1).toHaveBeenCalledWith(obj, prev); + expect(mockCallback2).toHaveBeenCalledWith(obj, prev); }); - it("should add callbacks for different events", () => { + it("should add callbacks for different events", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const addCallback: ObserverCallback = vi.fn(); const changeCallback: ObserverCallback = vi.fn(); const destroyCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); + const prev = makePrev(); - 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"); + reg("add", addCallback); + reg("change", changeCallback); + reg("destroy", destroyCallback); - expect(addCallback).toHaveBeenCalledTimes(1); - expect(changeCallback).toHaveBeenCalledTimes(1); - expect(destroyCallback).toHaveBeenCalledTimes(1); + notify("add", obj); + notify("change", obj, prev); + notify("destroy", obj); - expect(addCallback).toHaveBeenCalledWith("add", "exampleID", "exampleAttribute", "value1", "value2"); - expect(changeCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", "value3", "value4"); - expect(destroyCallback).toHaveBeenCalledWith("destroy", "exampleID", "exampleAttribute", "value5", "value6"); + expect(addCallback).toHaveBeenCalledWith(obj, undefined); + expect(changeCallback).toHaveBeenCalledWith(obj, prev); + expect(destroyCallback).toHaveBeenCalledWith(obj, undefined); }); - it("should allow the same callback to be added multiple times", () => { + it("should allow the same callback to be added multiple times", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const mockCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); - registerObserver("add", mockCallback); - registerObserver("add", mockCallback); + reg("add", mockCallback); + reg("add", mockCallback); + notify("add", obj); - 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", () => { + it("should call all callbacks for a given event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const mockCallback1: ObserverCallback = vi.fn(); const mockCallback2: ObserverCallback = vi.fn(); - const mockCallback3: ObserverCallback = vi.fn(); - - registerObserver("change", mockCallback1); - registerObserver("change", mockCallback2); - registerObserver("change", mockCallback3); + const obj = makeObserverObj("100", "50"); + const prev = makePrev("50", "25"); - notifyObservers("change", "exampleID", "exampleAttribute", 100, 50); + reg("change", mockCallback1); + reg("change", mockCallback2); + notify("change", obj, prev); - expect(mockCallback1).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", 100, 50); - expect(mockCallback2).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", 100, 50); - expect(mockCallback3).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", 100, 50); + expect(mockCallback1).toHaveBeenCalledWith(obj, prev); + expect(mockCallback2).toHaveBeenCalledWith(obj, prev); }); it("should handle notification when no observers exist for event", () => { - // This should not throw an error expect(() => { - notifyObservers("add", "exampleID", "exampleAttribute", "newValue", "oldValue"); + notifyObservers("add", makeObserverObj()); }).not.toThrow(); }); - it("should only notify observers for the specific event", () => { + it("should only notify observers for the specific event", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const addCallback: ObserverCallback = vi.fn(); const changeCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); - registerObserver("add", addCallback); - registerObserver("change", changeCallback); + reg("add", addCallback); + reg("change", changeCallback); + notify("add", obj); - notifyObservers("add", "exampleID", "exampleAttribute", "value1", "value2"); - - expect(addCallback).toHaveBeenCalledWith("add", "exampleID", "exampleAttribute", "value1", "value2"); + expect(addCallback).toHaveBeenCalledWith(obj, undefined); expect(changeCallback).not.toHaveBeenCalled(); }); - it("should handle different attribute value types", () => { + it("should pass observer objects that support get", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); 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"); + const obj = makeObserverObj("25", "30"); - // Test with booleans - notifyObservers("change", "exampleID", "exampleAttribute", true, false); - expect(mockCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", true, false); + reg("change", mockCallback); + notify("change", obj, makePrev("10", "20")); - // Test with undefined - notifyObservers("change", "exampleID", "exampleAttribute", undefined, "someValue"); - expect(mockCallback).toHaveBeenCalledWith("change", "exampleID", "exampleAttribute", undefined, "someValue"); + expect(mockCallback.mock.calls[0][0].get("current")).toBe("25"); + expect(mockCallback.mock.calls[0][0].get("name")).toBe("hp"); }); - it("should handle callback execution errors gracefully", () => { + it("should handle callback execution errors gracefully", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const errorCallback: ObserverCallback = vi.fn(() => { throw new Error("Callback error"); }); const normalCallback: ObserverCallback = vi.fn(); + const obj = makeObserverObj(); - registerObserver("destroy", errorCallback); - registerObserver("destroy", normalCallback); + reg("destroy", errorCallback); + reg("destroy", normalCallback); - // This should not prevent other callbacks from executing expect(() => { - notifyObservers("destroy", "targetID", "exampleAttribute", "value1", "value2"); + notify("destroy", obj); }).toThrow("Callback error"); expect(errorCallback).toHaveBeenCalled(); }); - it("should call callbacks in the order they were added", () => { + it("should call callbacks in the order they were added", async () => { + const { registerObserver: reg, notifyObservers: notify } = await import("../../modules/observer"); const callOrder: number[] = []; + const obj = makeObserverObj(); - 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"); + reg("add", vi.fn(() => callOrder.push(1))); + reg("add", vi.fn(() => callOrder.push(2))); + reg("add", vi.fn(() => callOrder.push(3))); + notify("add", obj); expect(callOrder).toEqual([1, 2, 3]); }); }); -}); \ No newline at end of file +}); diff --git a/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts b/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts new file mode 100644 index 0000000000..31e7c4f4c0 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { resetAllObjects } from "../../__mocks__/apiObjects.mock"; +import { + captureDeletePriorState, + createObserverAttributeObject, + emptySnapshot, + isNewAttributeOrUser, + mergeAttributeState, + resolveObserverAddObj, + resolveObserverKind, + resolveObserverObj, + toSnapshot, + tryFindLegacyAttribute, +} from "../../modules/observerPayload"; + +describe("observerPayload", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetAllObjects(); + }); + + afterEach(() => { + resetAllObjects(); + }); + + describe("createObserverAttributeObject", () => { + it("should expose get, set, and toJSON", () => { + const obj = createObserverAttributeObject("char1", "hp", "computed", { current: "10", max: "20" }); + + expect(obj.get("name")).toBe("hp"); + expect(obj.get("current")).toBe("10"); + expect(obj.get("max")).toBe("20"); + expect(obj.get("_type")).toBe("computed"); + expect(obj.toJSON()).toEqual({ + _id: "", + _type: "computed", + _characterid: "char1", + name: "hp", + current: "10", + max: "20", + }); + }); + + it("should call setSheetItem for computed set(key, value)", async () => { + const setSheetItemSpy = vi.spyOn(global, "setSheetItem").mockResolvedValue(true); + + const obj = createObserverAttributeObject("char1", "hp", "computed", { current: "10", max: "20" }); + obj.set("current", "99"); + + await vi.waitFor(() => { + expect(setSheetItemSpy).toHaveBeenCalledWith( + "char1", + "hp", + "99", + "current", + { allowThrow: true, createAttr: true, withWorker: true }, + ); + expect(obj.get("current")).toBe("99"); + }); + setSheetItemSpy.mockRestore(); + }); + + it("should call setSheetItem for userAttribute set({ max })", async () => { + const setSheetItemSpy = vi.spyOn(global, "setSheetItem").mockResolvedValue(true); + + const obj = createObserverAttributeObject("char1", "notes", "userAttribute", { current: "a", max: "" }); + obj.set({ max: "5" }); + + await vi.waitFor(() => { + expect(setSheetItemSpy).toHaveBeenCalledWith( + "char1", + "user.notes", + "5", + "max", + { allowThrow: true, createAttr: true, withWorker: true }, + ); + expect(obj.get("max")).toBe("5"); + }); + setSheetItemSpy.mockRestore(); + }); + }); + + describe("mergeAttributeState", () => { + it("should merge hp and hp_max keys", () => { + const state = mergeAttributeState( + "char1", + "hp", + { char1: { hp: 8, hp_max: 18 } }, + { char1: { hp: 10, hp_max: 20 } }, + false, + ); + + expect(state).toEqual({ + current: "10", + max: "20", + priorCurrent: "8", + priorMax: "18", + }); + }); + + it("should use prior values for delete operations", () => { + const state = mergeAttributeState( + "char1", + "hp", + { char1: { hp: 10, hp_max: 20 } }, + { char1: { hp: undefined, hp_max: undefined } }, + true, + ); + + expect(state).toEqual({ + current: "10", + max: "20", + priorCurrent: "10", + priorMax: "20", + }); + }); + }); + + describe("resolveObserverKind", () => { + it("should return attribute when legacy object exists", async () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "legacy" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10" }); + + await expect(resolveObserverKind("char1", "hp")).resolves.toBe("attribute"); + }); + + it("should return computed when beacon value exists", async () => { + createObj("character", { _id: "char1", name: "Hero" }); + await setSheetItem("char1", "beacon_hp", "10", "current"); + + await expect(resolveObserverKind("char1", "beacon_hp")).resolves.toBe("computed"); + }); + + it("should return computed on beacon sheets even when a legacy attribute object exists", async () => { + createObj("character", { _id: "char1", name: "Hero" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ComputedLike", current: "10" }); + await setSheetItem("char1", "ComputedLike", "10", "current"); + + await expect(resolveObserverKind("char1", "ComputedLike")).resolves.toBe("computed"); + }); + }); + + describe("resolveObserverObj", () => { + it("should prefer live legacy attribute objects", () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "legacy" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + + const obj = resolveObserverObj("char1", "hp", "attribute", { + current: "10", + max: "20", + priorCurrent: "5", + priorMax: "15", + }); + + expect(obj).toBe(legacy); + }); + + it("should build synthetic computed object on beacon sheets even when legacy attribute exists", () => { + createObj("character", { _id: "char1", name: "Hero" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ComputedLike", current: "10", max: "20" }); + + const obj = resolveObserverObj("char1", "ComputedLike", "computed", { + current: "10", + max: "20", + priorCurrent: "5", + priorMax: "15", + }); + + expect(obj.toJSON()._type).toBe("computed"); + expect(obj.get("current")).toBe("10"); + expect(obj).not.toBe(findObjs({ _type: "attribute", _characterid: "char1", name: "ComputedLike" })[0]); + }); + + it("should build synthetic object when legacy attribute is missing", () => { + const obj = resolveObserverObj("char1", "hp", "computed", { + current: "10", + max: "20", + priorCurrent: "5", + priorMax: "15", + }); + + expect(obj.toJSON()._type).toBe("computed"); + expect(obj.get("current")).toBe("10"); + }); + }); + + describe("resolveObserverAddObj", () => { + it("should return synthetic object with added values", () => { + const obj = resolveObserverAddObj("char1", "NewAttr", "userAttribute", { current: "42", max: "100" }); + + expect(obj.get("current")).toBe("42"); + expect(obj.get("max")).toBe("100"); + expect(obj.toJSON()._type).toBe("userAttribute"); + }); + + it("should return live legacy object when available", () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "legacy" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "NewAttr", current: "42", max: "100" }); + + const obj = resolveObserverAddObj("char1", "NewAttr", "attribute", { current: "42", max: "100" }); + + expect(obj).toBe(legacy); + }); + }); + + describe("captureDeletePriorState", () => { + it("should read max from legacy attribute when priorValues omit hp_max", async () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "legacy" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + + const state = await captureDeletePriorState("char1", "hp", "attribute", { char1: { hp: 10 } }); + + expect(state.current).toBe("10"); + expect(state.max).toBe("20"); + }); + }); + + describe("isNewAttributeOrUser", () => { + it("should be false for computed attributes", () => { + expect(isNewAttributeOrUser("computed", { + current: "1", + max: "", + priorCurrent: "", + priorMax: "", + })).toBe(false); + }); + + it("should be true for new user attributes", () => { + expect(isNewAttributeOrUser("userAttribute", { + current: "42", + max: "", + priorCurrent: "", + priorMax: "", + })).toBe(true); + }); + }); + + describe("toSnapshot and emptySnapshot", () => { + it("should build plain prev snapshots", () => { + expect(toSnapshot("char1", "hp", "attribute", { current: "5", max: "10" }, "attr1")).toEqual({ + _id: "attr1", + _type: "attribute", + _characterid: "char1", + name: "hp", + current: "5", + max: "10", + }); + + expect(emptySnapshot("char1", "hp", "computed")).toEqual({ + _id: "", + _type: "computed", + _characterid: "char1", + name: "hp", + current: "", + max: "", + }); + }); + }); + + describe("tryFindLegacyAttribute", () => { + it("should find legacy attributes by name", () => { + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10" }); + + expect(tryFindLegacyAttribute("char1", "hp")).toBe(legacy); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/targets.test.ts b/ChatSetAttr/src/__tests__/unit/targets.test.ts index bec9929083..aa6e1c06f1 100644 --- a/ChatSetAttr/src/__tests__/unit/targets.test.ts +++ b/ChatSetAttr/src/__tests__/unit/targets.test.ts @@ -330,6 +330,71 @@ describe("generateTargets", () => { expect(result.targets).toEqual(["char1", "char2"]); expect(result.errors).toContain("Permission error. You do not have permission to modify character with name \"Charlie\"."); }); + + it("should resolve a single multi-word character name", () => { + const characterBob = makeMockCharacter("char1", "player1"); + const message = makeMockMessage("", []); + const findObjsSpy = vi.fn((props: Record) => { + if (props.name === "bob the slayer") return [characterBob]; + return []; + }); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation(findObjsSpy); + + const result = generateTargets(message, ["name bob the slayer"]); + + expect(result.targets).toEqual(["char1"]); + expect(findObjsSpy).toHaveBeenCalledTimes(1); + expect(findObjsSpy).toHaveBeenCalledWith( + { _type: "character", name: "bob the slayer" }, + { caseInsensitive: true }, + ); + }); + + it("should resolve comma-separated multi-word character names", () => { + const characterBob = makeMockCharacter("char1", "player1"); + const characterTimmy = makeMockCharacter("char2", "player1"); + const message = makeMockMessage("", []); + const findObjsSpy = vi.fn((props: Record) => { + if (props.name === "bob the slayer") return [characterBob]; + if (props.name === "timmy the weak") return [characterTimmy]; + return []; + }); + vi.mocked(checkPermissionForTarget).mockReturnValue(true); + vi.mocked(getPermissions).mockReturnValue({ playerID: "player1", isGM: false, canModify: true }); + vi.mocked(global.findObjs).mockImplementation(findObjsSpy); + + const result = generateTargets(message, ["name bob the slayer, timmy the weak"]); + + expect(result.targets).toEqual(["char1", "char2"]); + expect(findObjsSpy).toHaveBeenCalledTimes(2); + expect(findObjsSpy).toHaveBeenCalledWith( + { _type: "character", name: "bob the slayer" }, + { caseInsensitive: true }, + ); + expect(findObjsSpy).toHaveBeenCalledWith( + { _type: "character", name: "timmy the weak" }, + { caseInsensitive: true }, + ); + }); + + it("should match character names case-insensitively", () => { + const characterBob = makeMockCharacter("char1", "player1"); + 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, options?: { caseInsensitive?: boolean }) => { + if (props._type === "character" && options?.caseInsensitive && typeof props.name === "string") { + if (props.name.toLowerCase() === "bob the slayer") return [characterBob]; + } + return []; + }); + + const result = generateTargets(message, ["name bob the slayer"]); + + expect(result.targets).toEqual(["char1"]); + }); }); describe("target = party", () => { diff --git a/ChatSetAttr/src/__tests__/unit/update.test.ts b/ChatSetAttr/src/__tests__/unit/update.test.ts index 98ed99055c..3d7e7941bb 100644 --- a/ChatSetAttr/src/__tests__/unit/update.test.ts +++ b/ChatSetAttr/src/__tests__/unit/update.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { AttributeRecord } from "../../types"; +import { resetAllObjects } from "../../__mocks__/apiObjects.mock"; import { makeUpdate } from "../../modules/updates"; // Mock the config module @@ -28,6 +29,7 @@ const mockNotifyObservers = vi.mocked(notifyObservers); describe("updates", () => { beforeEach(() => { vi.clearAllMocks(); + resetAllObjects(); mockGetConfig.mockReturnValue({ useWorkers: false }); }); @@ -459,16 +461,48 @@ describe("updates", () => { await makeUpdate("setattr", { char1: { strength: 15 } }, { priorValues, operation: "setattr" }); - expect(mockNotifyObservers).toHaveBeenCalledWith("change", "char1", "strength", 15, 10); + expect(mockNotifyObservers).toHaveBeenCalledWith( + "change", + expect.objectContaining({ get: expect.any(Function) }), + expect.objectContaining({ + _characterid: "char1", + name: "strength", + current: "10", + }), + ); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("15"); }); - it("should notify observers with add event when prior value is undefined", async () => { + it("should notify observers with add and change when prior value is undefined", async () => { mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name) => { + if (name === "user.NewAttr") { + return "42"; + } + return undefined; + }); const priorValues = { char1: {} }; await makeUpdate("setattr", { char1: { NewAttr: 42 } }, { priorValues, operation: "setattr" }); - expect(mockNotifyObservers).toHaveBeenCalledWith("add", "char1", "NewAttr", 42, undefined); + expect(mockNotifyObservers).toHaveBeenCalledTimes(2); + expect(mockNotifyObservers).toHaveBeenNthCalledWith( + 1, + "add", + expect.objectContaining({ get: expect.any(Function) }), + ); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("42"); + expect(mockNotifyObservers).toHaveBeenNthCalledWith( + 2, + "change", + expect.objectContaining({ get: expect.any(Function) }), + expect.objectContaining({ + name: "NewAttr", + current: "", + max: "", + }), + ); + expect(mockNotifyObservers.mock.calls[1][1].get("current")).toBe("42"); }); it("should notify observers with destroy event on successful delete", async () => { @@ -477,7 +511,89 @@ describe("updates", () => { await makeUpdate("delattr", { char1: { strength: undefined } }, { priorValues }); - expect(mockNotifyObservers).toHaveBeenCalledWith("destroy", "char1", "strength", undefined, 10); + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][0]).toBe("destroy"); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("10"); + expect(mockNotifyObservers.mock.calls[0][1].get("name")).toBe("strength"); + }); + + it("should include max on destroy when legacy attribute had max", async () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "legacy" }); + createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { hp: 10 } }; + + await makeUpdate("delattr", { char1: { hp: undefined } }, { priorValues }); + + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("10"); + expect(mockNotifyObservers.mock.calls[0][1].get("max")).toBe("20"); + }); + + it("should notify destroy for userAttribute delete without max", async () => { + vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name, type) => { + if (name === "user.UserOnlyAttr" && type === "current") { + return "42"; + } + return undefined; + }); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { UserOnlyAttr: "42" } }; + + await makeUpdate("delattr", { + char1: { UserOnlyAttr: undefined, UserOnlyAttr_max: undefined }, + }, { priorValues }); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(1); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "UserOnlyAttr", "current"); + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][0]).toBe("destroy"); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("42"); + expect(mockNotifyObservers.mock.calls[0][1].toJSON()._type).toBe("userAttribute"); + }); + + it("should notify destroy for userAttribute delete with max", async () => { + vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name, type) => { + if (name === "user.UserAttrWithMax") { + return type === "current" ? "42" : "100"; + } + return undefined; + }); + mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); + const priorValues = { char1: { UserAttrWithMax: "42", UserAttrWithMax_max: "100" } }; + + await makeUpdate("delattr", { + char1: { UserAttrWithMax: undefined, UserAttrWithMax_max: undefined }, + }, { priorValues }); + + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(1); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "UserAttrWithMax", "current"); + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][0]).toBe("destroy"); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("42"); + expect(mockNotifyObservers.mock.calls[0][1].get("max")).toBe("100"); + expect(mockNotifyObservers.mock.calls[0][1].toJSON()._type).toBe("userAttribute"); + }); + + it("should group hp and hp_max into one change notification", async () => { + mocklibSmartAttributes.setAttribute.mockResolvedValue(true); + const priorValues = { char1: { hp: 8, hp_max: 18 } }; + + await makeUpdate("setattr", { char1: { hp: 10, hp_max: 20 } }, { priorValues, operation: "setattr" }); + + expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers).toHaveBeenCalledWith( + "change", + expect.objectContaining({ get: expect.any(Function) }), + expect.objectContaining({ + name: "hp", + current: "8", + max: "18", + }), + ); + expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("10"); + expect(mockNotifyObservers.mock.calls[0][1].get("max")).toBe("20"); }); it("should not notify observers when setAttribute returns false", async () => { @@ -813,10 +929,16 @@ describe("updates", () => { "mp_max": 15, }, }; + const priorValues = { + char1: { + hp_max: 25, + mp_max: 15, + }, + }; mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); - await makeUpdate("delattr", results); + await makeUpdate("delattr", results, { priorValues }); expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(2); expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith( @@ -899,34 +1021,41 @@ describe("updates", () => { "mp_max": 10, }, }; + const priorValues = { + char1: { + hp: 20, + hp_max: 25, + strength: 14, + mp_max: 10, + }, + }; mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); - await makeUpdate("delattr", results); + await makeUpdate("delattr", results, { priorValues }); - expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(4); + expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledTimes(3); expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "hp", "current"); - expect(mocklibSmartAttributes.deleteAttribute).toHaveBeenCalledWith("char1", "hp", "max"); + expect(mocklibSmartAttributes.deleteAttribute).not.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 () => { + it("should handle delete errors for max-only attribute deletion", async () => { const results: Record = { "char1": { - "hp": 20, - "hp_max": 25, + "mp_max": 10, }, }; + const priorValues = { char1: { mp_max: 10 } }; mocklibSmartAttributes.deleteAttribute - .mockResolvedValueOnce(true) // hp current succeeds - .mockRejectedValueOnce(new Error("Max deletion failed")); // hp max fails + .mockRejectedValueOnce(new Error("Max deletion failed")); - const result = await makeUpdate("delattr", results); + const result = await makeUpdate("delattr", results, { priorValues }); expect(result.errors).toEqual([ - "Failed to delete attribute 'hp' on target 'char1': Error: Max deletion failed", + "Failed to delete attribute 'mp' on target 'char1': Error: Max deletion failed", ]); }); @@ -939,12 +1068,16 @@ describe("updates", () => { "max": "value", // attribute named "max" without underscore }, }; + const priorValues = { + char1: { + a_max: "value", + }, + }; mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); - const result = await makeUpdate("delattr", results); + const result = await makeUpdate("delattr", results, { priorValues }); - 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"); diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index 1a95258623..a27cfd0a6b 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -1,9 +1,26 @@ import { createDelayMessage } from "../templates/delay"; import { createChatMessage, createErrorMessage } from "../templates/messages"; import { createNotifyMessage } from "../templates/notification"; -import { buttonStyleBase } from "../templates/styles"; import { createWelcomeMessage } from "../templates/welcome"; -//import { s } from "../utils/chat"; + +export type CommandOutputOptions = { + silent?: boolean; + mute?: boolean; +}; + +export type NormalizedCommandOutputOptions = { + silent: boolean; + mute: boolean; +}; + +export function normalizeCommandOutputOptions( + options: CommandOutputOptions = {}, +): NormalizedCommandOutputOptions { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; +}; export function getPlayerName(playerID: string): string | undefined { const player = getObj("player", playerID); @@ -15,7 +32,12 @@ export function sendMessages( header: string, messages: string[], from: string = "ChatSetAttr", + output?: NormalizedCommandOutputOptions, ): void { + if (output?.silent) { + return; + } + const newMessage = createChatMessage(header, messages); const player = getPlayerName(playerID); sendChat(from, `/w "${player || "GM"}" ${newMessage}`); @@ -26,15 +48,22 @@ export function sendErrors( header: string, errors: string[], from: string = "ChatSetAttr", + output?: NormalizedCommandOutputOptions, ): void { - if (errors.length === 0) return; + if (errors.length === 0 || output?.mute) { + return; + } + const newMessage = createErrorMessage(header, errors); const player = getPlayerName(playerID); sendChat(from, `/w "${player || "GM"}" ${newMessage}`); }; -export function sendDelayMessage(silent: boolean = false): void { - if (silent) return; +export function sendDelayMessage(output?: NormalizedCommandOutputOptions): void { + if (output?.silent) { + return; + } + const delayMessage = createDelayMessage(); sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); }; @@ -47,4 +76,4 @@ export function sendNotification(title: string, content: string, archive?: boole export function sendWelcomeMessage(): void { const welcomeMessage = createWelcomeMessage(); 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 index 3b6cfe77ed..f406838679 100644 --- a/ChatSetAttr/src/modules/commands.ts +++ b/ChatSetAttr/src/modules/commands.ts @@ -357,9 +357,7 @@ async function getCurrentValues( for (const change of changes) { if (change.name) { queriedAttributes.add(change.name); - if (change.max !== undefined) { - queriedAttributes.add(`${change.name}_max`); - } + queriedAttributes.add(`${change.name}_max`); } } const attributes = await getAttributes(target, Array.from(queriedAttributes)); diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 34e023acaa..2074f6267a 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -1,7 +1,7 @@ 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 { sendDelayMessage, sendErrors, sendMessages, normalizeCommandOutputOptions } from "./chat"; import { handlers } from "./commands"; import { checkConfigMessage, getConfig, handleConfigCommand, hasFlag } from "./config"; import { checkHelpMessage, handleHelpCommand } from "./help"; @@ -47,8 +47,10 @@ async function acceptMessage(msg: Roll20ChatMessage) { feedback, } = parseMessage(msg.content); + const output = normalizeCommandOutputOptions(options); + // Start Timer - startTimer("chatsetattr", 8000, () => sendDelayMessage(options.silent)); + startTimer("chatsetattr", 8000, () => sendDelayMessage(output)); // Check Config and Permissions const config = getConfig(); @@ -56,29 +58,29 @@ async function acceptMessage(msg: Roll20ChatMessage) { const isGM = playerIsGM(msg.playerid); if (options.evaluate && !isAPI && !isGM && !config.playersCanEvaluate) { - return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors); + return errorOut("You do not have permission to use the evaluate option.", msg.playerid, errors, output); } if (targeting.includes("party") && !isAPI && !isGM && !config.playersCanTargetParty) { - return errorOut("You do not have permission to target the party.", msg.playerid, errors); + return errorOut("You do not have permission to target the party.", msg.playerid, errors, output); } if((operation === "modattr" || operation === "modbattr") && !isAPI && !isGM && !config.playersCanModify) { - return errorOut("You do not have permission to modify attributes.", msg.playerid, errors); + return errorOut("You do not have permission to modify attributes.", msg.playerid, errors, output); } // Preprocess const { targets, errors: targetErrors } = generateTargets(msg, targeting); errors.push(...targetErrors); if (targets.length === 0) { - return errorOut("No valid targets found.", msg.playerid, errors); + return errorOut("No valid targets found.", msg.playerid, errors, output); } const request = generateRequest(references, changes); const command = handlers[operation]; if (!command) { - return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors); + return errorOut(`Invalid operation: ${operation}`, msg.playerid, errors, output); } // Execute @@ -121,17 +123,20 @@ async function acceptMessage(msg: Roll20ChatMessage) { } } - if (options.silent) return; - sendErrors(msg.playerid, "Errors", errors, feedback?.from); - if (options.mute) return; + sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from); + sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from, output); }; -function errorOut(errorText: string, playerid: string, errors: string[]) { +function errorOut( + errorText: string, + playerid: string, + errors: string[], + output: ReturnType, +) { errors.push(errorText); - sendErrors(playerid, "Errors", errors); + sendErrors(playerid, "Errors", errors, undefined, output); clearTimer("chatsetattr"); } @@ -142,14 +147,15 @@ export function generateRequest( ): string[] { const referenceSet = new Set(references); for (const change of changes) { - if (change.name && !referenceSet.has(change.name)) { + if (!change.name) { + continue; + } + if (!referenceSet.has(change.name)) { referenceSet.add(change.name); } - if (change.max !== undefined) { - const maxName = `${change.name}_max`; - if (!referenceSet.has(maxName)) { - referenceSet.add(maxName); - } + const maxName = `${change.name}_max`; + if (!referenceSet.has(maxName)) { + referenceSet.add(maxName); } } return Array.from(referenceSet); diff --git a/ChatSetAttr/src/modules/observer.ts b/ChatSetAttr/src/modules/observer.ts index a04dc3b976..7e79cd50f3 100644 --- a/ChatSetAttr/src/modules/observer.ts +++ b/ChatSetAttr/src/modules/observer.ts @@ -1,4 +1,10 @@ -import type { AttributeValue, ObserverCallback, ObserverEvent, ObserverRecord } from "../types"; +import type { + ObserverAttributeSnapshot, + ObserverCallback, + ObserverCallbackTarget, + ObserverEvent, + ObserverRecord, +} from "../types"; const observers: ObserverRecord = {}; @@ -14,13 +20,11 @@ export function registerObserver( export function notifyObservers( event: ObserverEvent, - targetID: string, - attributeName: string, - newValue: AttributeValue, - oldValue: AttributeValue + obj: ObserverCallbackTarget, + prev?: ObserverAttributeSnapshot ): void { const callbacks = observers[event] || []; callbacks.forEach(callback => { - callback(event, targetID, attributeName, newValue, oldValue); + callback(obj, prev); }); }; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/observerPayload.ts b/ChatSetAttr/src/modules/observerPayload.ts new file mode 100644 index 0000000000..d48186d243 --- /dev/null +++ b/ChatSetAttr/src/modules/observerPayload.ts @@ -0,0 +1,355 @@ +import type { + AttributeRecord, + AttributeValue, + ObserverAttributeKind, + ObserverAttributeObject, + ObserverAttributeSnapshot, + ObserverCallbackTarget, +} from "../types"; + +export type MergedAttributeState = { + current: string; + max: string; + priorCurrent: string; + priorMax: string; +}; + +const WRITABLE_KEYS = new Set(["current", "max"]); + +function normalizeKey(key: string): string { + return key.startsWith("_") ? key.slice(1) : key; +}; + +function toAttrString(value: AttributeValue | undefined): string { + if (value === undefined || value === null) { + return ""; + } + return String(value); +}; + +function hasSheetItemValue(value: unknown): boolean { + return value !== null && value !== undefined && value !== ""; +}; + +function hasPriorValue(value: AttributeValue | undefined): boolean { + return value !== undefined && value !== null && value !== ""; +}; + +export function toSnapshot( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: Pick, + id = "", +): ObserverAttributeSnapshot { + return { + _id: id, + _type: kind, + _characterid: targetId, + name: actualName, + current: state.current, + max: state.max, + }; +}; + +export function emptySnapshot( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + id = "", +): ObserverAttributeSnapshot { + return toSnapshot(targetId, actualName, kind, { current: "", max: "" }, id); +}; + +export function mergeAttributeState( + targetId: string, + actualName: string, + priorValues: Record, + results: Record, + isDelete: boolean, +): MergedAttributeState { + const maxKey = `${actualName}_max`; + const priorCurrent = priorValues[targetId]?.[actualName]; + const priorMax = priorValues[targetId]?.[maxKey]; + + if (isDelete) { + return { + current: toAttrString(priorCurrent), + max: toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; + } + + const newCurrent = results[targetId]?.[actualName]; + const newMax = results[targetId]?.[maxKey]; + + return { + current: newCurrent !== undefined ? toAttrString(newCurrent) : toAttrString(priorCurrent), + max: newMax !== undefined ? toAttrString(newMax) : toAttrString(priorMax), + priorCurrent: toAttrString(priorCurrent), + priorMax: toAttrString(priorMax), + }; +}; + +export function tryFindLegacyAttribute( + targetId: string, + actualName: string, +): Roll20Attribute | undefined { + return findObjs({ + _type: "attribute", + _characterid: targetId, + name: actualName, + })[0]; +}; + +export function isLegacySheet(targetId: string): boolean { + const character = getObj("character", targetId); + return character?.sheetEnvironment === "legacy"; +}; + +function legacyAttributeForSheet( + targetId: string, + actualName: string, +): Roll20Attribute | undefined { + if (!isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); +}; + +export async function resolveObserverKind( + targetId: string, + actualName: string, +): Promise { + if (isLegacySheet(targetId)) { + return "attribute"; + } + + const computed = await getSheetItem(targetId, actualName, "current"); + const computedMax = await getSheetItem(targetId, actualName, "max"); + if (hasSheetItemValue(computed) || hasSheetItemValue(computedMax)) { + return "computed"; + } + + const userAttr = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + if (hasSheetItemValue(userAttr) || hasSheetItemValue(userMax)) { + return "userAttribute"; + } + + return "computed"; +}; + +export function isNewAttributeOrUser( + kind: ObserverAttributeKind, + state: MergedAttributeState, +): boolean { + if (kind === "computed") { + return false; + } + return state.priorCurrent === "" && state.priorMax === ""; +}; + +function sheetItemPath(kind: ObserverAttributeKind, actualName: string): string { + return kind === "userAttribute" ? `user.${actualName}` : actualName; +}; + +async function writeSheetItemValue( + characterId: string, + kind: ObserverAttributeKind, + actualName: string, + key: string, + value: string, +): Promise { + const normalized = normalizeKey(key); + if (!WRITABLE_KEYS.has(normalized)) { + return false; + } + + const type = normalized as "current" | "max"; + const path = sheetItemPath(kind, actualName); + + try { + await setSheetItem(characterId, path, value, type, { + allowThrow: true, + createAttr: true, + withWorker: true, + }); + return true; + } catch { + return false; + } +}; + +export function createObserverAttributeObject( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: Pick, + id = "", +): ObserverAttributeObject { + const snapshot = toSnapshot(targetId, actualName, kind, state, id); + + const obj: ObserverAttributeObject = { + get(key: string) { + const normalized = normalizeKey(key); + const byKey: Record = { + id: snapshot._id, + _id: snapshot._id, + type: snapshot._type, + _type: snapshot._type, + characterid: snapshot._characterid, + _characterid: snapshot._characterid, + name: snapshot.name, + current: snapshot.current, + max: snapshot.max, + }; + return byKey[normalized] ?? byKey[key]; + }, + + set(keyOrProps: string | Partial, value?: string) { + const updates: Partial> = {}; + + if (typeof keyOrProps === "string") { + const normalized = normalizeKey(keyOrProps); + if (WRITABLE_KEYS.has(normalized) && value !== undefined) { + updates[normalized as "current" | "max"] = value; + } + } else { + if (keyOrProps.current !== undefined) { + updates.current = keyOrProps.current; + } + if (keyOrProps.max !== undefined) { + updates.max = keyOrProps.max; + } + } + + for (const [key, nextValue] of Object.entries(updates)) { + if (nextValue === undefined) { + continue; + } + void writeSheetItemValue(targetId, kind, actualName, key, nextValue).then(ok => { + if (ok) { + snapshot[key as "current" | "max"] = nextValue; + } + }); + } + + return obj; + }, + + toJSON() { + return { ...snapshot }; + }, + }; + + return obj; +}; + +export function resolveObserverObj( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: MergedAttributeState, +): ObserverCallbackTarget { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); +}; + +export function resolveObserverAddObj( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + state: Pick, +): ObserverCallbackTarget { + if (kind === "attribute") { + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + return legacyAttr; + } + } + + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + const id = legacyAttr?.get("_id") ?? ""; + return createObserverAttributeObject(targetId, actualName, kind, state, id); +}; + +export async function captureDeletePriorState( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, + priorValues: Record, +): Promise { + const maxKey = `${actualName}_max`; + let priorCurrent = priorValues[targetId]?.[actualName]; + let priorMax = priorValues[targetId]?.[maxKey]; + + const legacyAttr = legacyAttributeForSheet(targetId, actualName); + if (legacyAttr) { + if (!hasPriorValue(priorCurrent)) { + priorCurrent = legacyAttr.get("current"); + } + if (!hasPriorValue(priorMax)) { + priorMax = legacyAttr.get("max"); + } + } else { + const userCurrent = await getSheetItem(targetId, `user.${actualName}`, "current"); + const userMax = await getSheetItem(targetId, `user.${actualName}`, "max"); + const hasUserValues = hasSheetItemValue(userCurrent) || hasSheetItemValue(userMax); + const path = hasUserValues || kind === "userAttribute" + ? `user.${actualName}` + : actualName; + + if (!hasPriorValue(priorCurrent)) { + priorCurrent = await getSheetItem(targetId, path, "current"); + } + if (!hasPriorValue(priorMax)) { + priorMax = await getSheetItem(targetId, path, "max"); + } + + if (!hasPriorValue(priorCurrent) && hasUserValues) { + priorCurrent = userCurrent; + } + if (!hasPriorValue(priorMax) && hasUserValues) { + priorMax = userMax; + } + } + + const current = toAttrString(priorCurrent); + const max = toAttrString(priorMax); + return { + current, + max, + priorCurrent: current, + priorMax: max, + }; +}; + +export function logicalAttributeKey(target: string, actualName: string): string { + return `${target}:${actualName}`; +}; + +export function parseResultKey(key: string): { target: string; name: string } { + const separator = key.indexOf(":"); + return { + target: key.slice(0, separator), + name: key.slice(separator + 1), + }; +}; + +export function toActualName(name: string): { actualName: string; isMax: boolean } { + const isMax = name.endsWith("_max"); + return { + actualName: isMax ? name.slice(0, -4) : name, + isMax, + }; +}; diff --git a/ChatSetAttr/src/modules/targets.ts b/ChatSetAttr/src/modules/targets.ts index a9329c5f45..0fd4daae4a 100644 --- a/ChatSetAttr/src/modules/targets.ts +++ b/ChatSetAttr/src/modules/targets.ts @@ -148,13 +148,37 @@ function generatePartyTargets() { }; +function splitCommaSeparatedValues(valueString: string): string[] { + if (!valueString) { + return []; + } + return valueString.split(/\s*,\s*/).map(v => v.trim()).filter(v => v.length > 0); +}; + +function parseTargetOption(option: string): { type: string; values: string[] } { + const trimmed = option.trim(); + const spaceIndex = trimmed.indexOf(" "); + if (spaceIndex === -1) { + return { type: trimmed, values: [] }; + } + + const type = trimmed.slice(0, spaceIndex); + const remainder = trimmed.slice(spaceIndex + 1).trim(); + + if (type === "name" || type === "charid") { + return { type, values: splitCommaSeparatedValues(remainder) }; + } + + return { type, values: [] }; +}; + 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 }); + const characters = findObjs({ _type: "character", name }, { caseInsensitive: true }); if (characters.length === 0) { errors.push(`Character with name "${name}" not found.`); continue; @@ -184,7 +208,7 @@ export function generateTargets(message: Roll20ChatMessage, targetOptions: strin const errors: string[] = []; for (const option of targetOptions) { - const [type, ...values] = option.split(/[, ]/).map(v => v.trim()).filter(v => v.length > 0); + const { type, values } = parseTargetOption(option); if (type === "sel" || type === "sel-noparty" || type === "sel-party") { const results = generateSelectedTargets(message, type); diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts index e3c04f7a66..140650cd52 100644 --- a/ChatSetAttr/src/modules/updates.ts +++ b/ChatSetAttr/src/modules/updates.ts @@ -1,6 +1,18 @@ -import { type AttributeRecord, type AttributeValue, type Command, type ObserverEvent } from "../types"; +import { type AttributeRecord, type AttributeValue, type Command } from "../types"; import { getConfig } from "./config"; import { notifyObservers } from "./observer"; +import { + captureDeletePriorState, + isLegacySheet, + isNewAttributeOrUser, + logicalAttributeKey, + mergeAttributeState, + resolveObserverAddObj, + resolveObserverKind, + resolveObserverObj, + toActualName, + toSnapshot, +} from "./observerPayload"; type UpdateOptions = { noCreate?: boolean; @@ -14,6 +26,12 @@ export type UpdateResult = { failed: string[]; }; +type LogicalGroup = { + target: string; + actualName: string; + keys: string[]; +}; + export function buildSetAttributeOptions(overrides: { noCreate?: boolean; setWithWorker?: boolean } = {}) { const { useWorkers = true } = getConfig() || {}; return { @@ -26,14 +44,62 @@ function failureKey(target: string, name: string): string { return `${target}:${name}`; }; -function observerEvent(operation: Command, priorValue: AttributeValue | undefined, isDelete: boolean): ObserverEvent { - if (isDelete) { - return "destroy"; +function collectLogicalGroups(results: Record): LogicalGroup[] { + const groups = new Map(); + + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const key = logicalAttributeKey(target, actualName); + const existing = groups.get(key); + if (existing) { + existing.keys.push(name); + } else { + groups.set(key, { target, actualName, keys: [name] }); + } + } + } + + return Array.from(groups.values()); +}; + +function groupHasFailure(group: LogicalGroup, failed: Set): boolean { + return group.keys.some(name => failed.has(failureKey(group.target, name))); +}; + +function shouldSkipPairedMaxDelete( + target: string, + actualName: string, + isMax: boolean, + priorValues: Record, + results: Record, +): boolean { + if (!isMax) { + return false; + } + + const maxKey = `${actualName}_max`; + const hasCompanionCurrent = Object.hasOwn(results[target], actualName); + + const character = getObj("character", target); + if (character?.sheetEnvironment === "legacy") { + return hasCompanionCurrent; + } + + // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. + if (hasCompanionCurrent) { + return true; } - if (operation === "setattr" && priorValue === undefined) { - return "add"; + + if (!hasPriorValue(priorValues[target]?.[maxKey])) { + return true; } - return "change"; + + return false; +}; + +function hasPriorValue(value: AttributeValue | undefined): boolean { + return value !== undefined && value !== null && value !== ""; }; export async function makeUpdate( @@ -45,17 +111,37 @@ export async function makeUpdate( const errors: string[] = []; const messages: string[] = []; const failed: string[] = []; + const failedSet = new Set(); - const { noCreate = false, priorValues = {}, operation: op = operation } = options || {}; + const { noCreate = false, priorValues = {} } = options || {}; const setOptions = buildSetAttributeOptions({ noCreate }); + const deleteKinds = new Map>>(); + const deleteStates = new Map>>(); + + if (!isSetting) { + for (const target in results) { + for (const name in results[target]) { + const { actualName } = toActualName(name); + const groupKey = logicalAttributeKey(target, actualName); + if (!deleteKinds.has(groupKey)) { + deleteKinds.set(groupKey, await resolveObserverKind(target, actualName)); + } + if (!deleteStates.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteStates.set( + groupKey, + await captureDeletePriorState(target, actualName, kind, priorValues), + ); + } + } + } + } for (const target in results) { for (const name in results[target]) { - const isMax = name.endsWith("_max"); + const { actualName, isMax } = toActualName(name); const type = isMax ? "max" : "current"; - const actualName = isMax ? name.slice(0, -4) : name; const key = failureKey(target, name); - const priorValue = priorValues[target]?.[name]; const newValue = results[target][name]; if (isSetting) { @@ -65,28 +151,30 @@ export async function makeUpdate( const ok = await libSmartAttributes.setAttribute(target, actualName, value, type, setOptions); if (!ok) { failed.push(key); + failedSet.add(key); errors.push(`Failed to set attribute '${name}' on target '${target}'.`); - continue; } - const event = observerEvent(op, priorValue, false); - notifyObservers(event, target, name, newValue, priorValue); } catch (error: unknown) { failed.push(key); + failedSet.add(key); errors.push(`Failed to set attribute '${name}' on target '${target}': ${String(error)}`); } } else { + if (shouldSkipPairedMaxDelete(target, actualName, isMax, priorValues, results)) { + continue; + } try { const ok = await libSmartAttributes.deleteAttribute(target, actualName, type); if (!ok) { failed.push(key); + failedSet.add(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}'.`); - continue; } - notifyObservers("destroy", target, name, newValue, priorValue); } catch (error: unknown) { failed.push(key); + failedSet.add(key); errors.push(`Failed to delete attribute '${actualName}' on target '${target}': ${String(error)}`); } @@ -94,5 +182,43 @@ export async function makeUpdate( } } + const groups = collectLogicalGroups(results); + + for (const group of groups) { + if (groupHasFailure(group, failedSet)) { + continue; + } + + const groupKey = logicalAttributeKey(group.target, group.actualName); + const state = isSetting + ? mergeAttributeState(group.target, group.actualName, priorValues, results, false) + : deleteStates.get(groupKey) ?? mergeAttributeState( + group.target, + group.actualName, + priorValues, + results, + true, + ); + const kind = isSetting + ? await resolveObserverKind(group.target, group.actualName) + : deleteKinds.get(logicalAttributeKey(group.target, group.actualName)) ?? await resolveObserverKind(group.target, group.actualName); + + if (isSetting) { + const prev = toSnapshot(group.target, group.actualName, kind, { + current: state.priorCurrent, + max: state.priorMax, + }); + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + + if (isNewAttributeOrUser(kind, state)) { + notifyObservers("add", resolveObserverAddObj(group.target, group.actualName, kind, state)); + } + notifyObservers("change", obj, prev); + } else { + const obj = resolveObserverObj(group.target, group.actualName, kind, state); + notifyObservers("destroy", obj); + } + } + return { errors, messages, failed }; }; diff --git a/ChatSetAttr/src/types.ts b/ChatSetAttr/src/types.ts index 0c15b34aa5..3409d50a41 100644 --- a/ChatSetAttr/src/types.ts +++ b/ChatSetAttr/src/types.ts @@ -178,7 +178,29 @@ export type VersionObject = { export type ObserverEvent = "add" | "change" | "destroy"; -export type ObserverCallback = (event: ObserverEvent, targetID: string, attribute: string, newValue: AttributeValue, oldValue: AttributeValue) => void; +export type ObserverAttributeKind = "attribute" | "computed" | "userAttribute"; + +export type ObserverAttributeSnapshot = { + _id: string; + _type: ObserverAttributeKind; + _characterid: string; + name: string; + current: string; + max: string; +}; + +export interface ObserverAttributeObject { + get(key: string): string | undefined; + set(keyOrProps: string | Partial, value?: string): ObserverAttributeObject; + toJSON(): ObserverAttributeSnapshot; +} + +export type ObserverCallbackTarget = Roll20Attribute | ObserverAttributeObject; + +export type ObserverCallback = ( + obj: ObserverCallbackTarget, + prev?: ObserverAttributeSnapshot +) => void; export type ObserverRecord = Record; diff --git a/libSmartAttributes/0.0.4/.libSmartAttributes.js.swp b/libSmartAttributes/0.0.4/.libSmartAttributes.js.swp deleted file mode 100644 index a9d953656364553000eaa19a06273a84aab35f85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeHN&2QX96ra)nEu~PE&r>zoM$JZAyxW3GNfd>uQL6|lP?NL=K9X5`cJ0J#FZMXw zRakoC!i7HoaX~6v;KGRuM^4&2riw5{I8CQTgtxXwqBV_71&hwLYb!umDe zbGy@MI1HxkL2mJGY_b`md_y5EH|37?| zvGc$ba2oja8OFW>1YiI!0LOsek2Cf;unJrN4B*eF8T%Rd3Ge{{aNyr#jNJw<1LuJX z@G@`&_~t3bz61i`EN~L|@hD?W;5u*_coX>N2y_Ftflq-o;0ka7I0f8&lCe9$Z@~A! zCQt*udxEhp@IG)2mk zQ$L!4hmL{4Om$`swTUN?DhfRAc$U1G4h`nw8Je|lOGnA)kEB2S-uQ5q;OOk;3p*_Ki*DcPwOG}rig6w1Ju+V(_84%TZNmdKXlNvp=m zz``n|Cw5|yXd`3jRW62Cn_zRS;k(K8mqmxM=ok>n?D zN&$!Abf=3OWY+&<+~CVn#nD3QWMm?1LnVb(jz_XoM*c7&rQuA?-k205?;Vwdk1=9V zVt~bw7%LdAYzwnb4jF;$_*;@%!|-*=z+9l{%wXod#s|3|y+Fp!#y|6pK;vs!IXTvB zUcpONNsQc_>GZtDT7(=cWK>l!9dHo zk&W|t)OVIPd;ygfNuEjX7G|V#_?UqdNsrE?7Yxt86c6vGb~3dh6U@0Ly~1_<-ijUg zy;7aa*9Op`yP z{vC4UEOn=>8Cbpyy=leeO$RLzy~_!)0|@IC-*fvcV^Jv6E?AkzmL$sYrLl61Y3xD9 zne;!1Sd-pjyeaYWaL2Rp-m!n+lPcLK+_YmPC0Z&x=%cO^U-}(Ybp?IJ;;Rh{Xum4V zt0-?zdyW=l@|Z`9;tnt>E3#cSU+&S_xZ@7G+cE0TyG|q7K-Rv7p`qkST6ZjSANyU} z)&<|w*iq1W=T}x2`HB=B{_!ZeZs2b@mJGR|gEk-AQ(5(wW(8 zLR^bJ_*n6Vh~Psjf-fS754MPCi!UvB1M3A}#QLIw`XC6F`2Xg%x5=f&)>6*EFPk}Y z=FB<&`Og2$IWw8w$3}M1_GGWXwo!-?nbwfu*;Sez>CNa+Nh)xf$QAd>mN&%&SQa~x76i^B%1(X6xfonn|1(X6x0i}RaKq;UUPzopolmbctrGQdE zDR31kpylw|ACvpjYfw47{}1N>=WZ6_Yw!U$23`cypaiyq3pWYzIrs>i1Sh~N;9+n( zSOqR@5aJ(j0=xx|gJa+*u)#s_IM@%igC4L3tO5co+$hA4;0!nk-UiPB6HI|gkO4j5 z7I1345M?02I2Z=McA{;-0%KqY*ao(OEnq!Z4L-d=he4oa!So19C#C=-_)`EAj{}wn7j)51zGvF|I5*z?mW+QARvk;#c#-|;Q zZWO3uON+GN!w$N0-q6ajMy5g8qGoACdY9Dy0&2ce`hXi4}YclGhEH z>!Eo;lr;_8spse?t*Yq`6{NGLD5W#v$a35*KkG44zQZ!za207LohgvSpv( z6yCD<`~^Q(g$93KsrJT(MtAMrJHB)H(Adtw;n7DQ*&jSR2@V_H5Re>)meg|SO{F>XiSjhQoyyq>s!Gb4#@!B8HNzptA97D{$ice2HJT#{x}*q3%%mTC1Q-A=>Z5220FMMjECuS0U0AL5Qx z2_xKIv_rBDBXylM)7^XuxHWW!2siE(OZLK?touoa|aV-b!3-31R zFbYV-?c-kNogdmt5Kt}2;pvTT(~xI*zww&qyG(SDv^1q|A8k68WI@Z$aix=%ESobj z4(syTQ$NI2MPjYzK<`05O2lepz~X|j=O6cT>b zgFUZe;VI*&C#n(4RxC>zPRwn2joa+mZ>!Y?vC7qrh|-!XCLcoV7x$Ugl(f=hPIGPT zfh0nBUzkq)jaEVf2-S+DFn4oi!DT{0bWulg?ZbQnNy8j+lNT$n%fEh@(Yu92=*VVu z96h<3b}#OwFW%2u2fi7tR6)mMNfT}-3p!gcz+s%#Dkax7v}H%QP|l`t%^lU2Ft3ZU zuCg`-Ga|dF<;-fdZLQR_a9#GmDsD9Fu&7N>V?akp`aRL+M+cj2f(Q4&PT{1|W-pbq zI-WUTxw`GT?q+sf5oLvK9r#)ZG4Ba$@j&w&Z;|j4o{h&-vxJ>`%5O>Q> z!`G(_vubdP=Wf15U_Ugs5tv($+~Pf`edr=_JodS!k&}+5m+XNwjmfNO<#q(N<6rCn zb{4OS-jP1Rdo80Q49OiOshfS+EjZ}oC^=)A_|)0pl2C`bpEMrw3JrBZfk8ojJcXkY z7vnx;RO(epEP{77v-936q1G+!#Y1B6G!6aHockhzRL^`y;l(5FMXD}R3g%_P+!g$MQ zNl#I%tj)%#Pw@PI26OfA0MGw}_xpcgj{hEb4IBX#kOAZ1L68R9z`fuu5CdIcBM{(s zl=p*Q?zi~;2Al)00$v~Z3G;QeDFu`QN&%&SQa~x76i^B%1(X7pQ2|UzgEt!=H?8!0 zE34srf#=qZ^JLy{oM0q`oSil;UXk?{ikfGsk#kd?_S9%ab5pGSnNE@49AO#Ps~S&N zgQ|bhQ1u|~R7BcoH_e~l^U879zNoaQHb>=)N{c!-O0N~1TZ_mOl_n$d$@Y4*U~Ec}UH>~;HR_->|g0lob_XSn3ixa1qWVfh!C6FD{j diff --git a/libSmartAttributes/src/.index.ts.swp b/libSmartAttributes/src/.index.ts.swp deleted file mode 100644 index e7009e29c239c94caa13c82150bf47623f2f483d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI2&u<$=6vrq0E>I{xB@V!gNsA*HJA_jmOjNZTOsGL`Pci|i32-W*s$BnE2?AbkL$b<+yYA#fY2Tr0=|~`4!9!7Uo!VTJ2Xdd3fm4g52uopLl^o9&t3?@ZZGh=D3K#{3 zP+%u}YVyfA)gQ}^(tY>7IK(?I9d0!9I&fKk9GU=%P47zK<1MggOMQNSp09V)@u}{Ej;1GBo%z(e4@n7H~_ye2==fKzC3!wJkQ(!(u0i%FXAXcC=#fWU&l9EE- zjVhoi6y3X0>UpW}S**7FK;peRk6cOXjK%W=LPmk7W_10f{Gy(a#@6LpOLWNdCDr}N zv&r|!YVd$tQUvJMKUeKeK@Dp--V#YaHEnTxiec6cAnL9Xn>p7ILRO_{=6fb7&|(yd zpg6Yf@uui376^-qy5kAEK*|#0|WXII2vR{Zi-wkpGjoI2&zyUJl2 ztyIX1T$lFji6_*J+JbQiMr8S3D7zyn#M|7FO6Vay(r%PWG~W;&$zToY1|%C|z?CZ% zPx-y2Wc#_N_A&_^stFEn;8C-Mt9AC3Nm!&gA!GTm-C84ZppM{nSKwq3t!5_iK(n5p zSLB{C+smBzzNNSW{$5^PS1fUBP1#!vMAJVi@;X+!UCK(tLTCKdiG?I0)$(PEjX(#{ z5gpZzUGC;g)mB-FZe?hhay*OLoN7;EtGf(U2(fR^)|SJ(iaixEX|N=NdbX_Y1fgU+ zT=OidN1i1eZ0$gW=uNdUg}77cEg@}V)3c|yLx$FAv`9|Qk%T(uXC{#1-8!~Z^ayLSHDS--cDaaBytEWf=zG((yuvAM^ z+d+|O@Ky_{k1a}rGF0U-R3%B30iBYzFGBBeNn+JO)j^SPLKISNKjC}s8m#Y-#iiAV z&FL?(*`^Q{y!P92Z8wIfcWYEkq#G8O*3EY)$@vZYq6#YVGbtrmAeL3VnoXh-!fg}g zYW;ZLGQxGm^V^B|&6>m08l$I}EFOhH($lPRAbxLN#8m#OCaxau4qz8Klfl-@37 zYmfCv$NrPMn^CG3Z8*b~)kB`)mpQQYQK#mjqttdXJyD_o@z_ tv_^BmPzI4kR6o^8=-w-lSZUujXiq{RhdE(=h-5 From edb471e3fe6e4574bf237ae2e38577ca428a0c7b Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 10 Jun 2026 17:06:28 -0500 Subject: [PATCH 18/38] Fixing Repeating Section RowID parsing --- ChatSetAttr/2.0/ChatSetAttr.js | 275 ++++++++++++++---- ChatSetAttr/ChatSetAttr.js | 275 ++++++++++++++---- .../integration/legacyAttributes.test.ts | 209 ++++++++++++- ChatSetAttr/src/__tests__/unit/chat.test.ts | 51 +++- .../src/__tests__/unit/message.test.ts | 10 + .../src/__tests__/unit/modifications.test.ts | 49 +++- .../src/__tests__/unit/repeating.test.ts | 265 +++++++++++++++-- ChatSetAttr/src/modules/chat.ts | 25 +- ChatSetAttr/src/modules/main.ts | 18 +- ChatSetAttr/src/modules/message.ts | 2 +- ChatSetAttr/src/modules/modifications.ts | 14 +- ChatSetAttr/src/modules/repeating.ts | 244 ++++++++++++++-- ChatSetAttr/src/templates/help.tsx | 38 +-- 13 files changed, 1292 insertions(+), 183 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index c7dcd623be..8c941e80db 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -672,6 +672,10 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); } + function whisperPrefix(playerID) { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; + } function normalizeCommandOutputOptions(options = {}) { return { mute: Boolean(options.mute), @@ -682,21 +686,24 @@ var ChatSetAttr = (function (exports) { const player = getObj("player", playerID); return player?.get("_displayname") || undefined; } - function sendMessages(playerID, header, messages, from = "ChatSetAttr", output) { + function sendMessages(playerID, header, messages, delivery, output) { if (output?.silent) { return; } + const from = delivery?.from ?? "ChatSetAttr"; const newMessage = createChatMessage(header, messages); - const player = getPlayerName(playerID); - sendChat(from, `/w "${player || "GM"}" ${newMessage}`); + const chatMessage = delivery?.public + ? newMessage + : `${whisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); } - function sendErrors(playerID, header, errors, from = "ChatSetAttr", output) { + function sendErrors(playerID, header, errors, from, output) { if (errors.length === 0 || output?.mute) { return; } + const sender = from ?? "ChatSetAttr"; const newMessage = createErrorMessage(header, errors); - const player = getPlayerName(playerID); - sendChat(from, `/w "${player || "GM"}" ${newMessage}`); + sendChat(sender, `${whisperPrefix(playerID)}${newMessage}`); } function sendDelayMessage(output) { if (output?.silent) { @@ -1113,14 +1120,14 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25|50 --xp|0|800")), + h("code", null, "!setattr --sel --hp|25|50 --hp|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"), + h("code", null, "hp"), " to 0 and ", h("code", null, "xp_max"), " to 800."), @@ -1132,12 +1139,12 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modattr --sel --hp|-5 --xp|100")), + h("code", null, "!modattr --sel --hp|-5 --hp|100")), h("p", null, "This subtracts 5 from ", h("code", null, "hp"), " and adds 100 to ", - h("code", null, "xp"), + h("code", null, "hp"), "."), h("h3", null, "!modbattr"), h("p", null, @@ -1147,12 +1154,12 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modbattr --sel --hp|-25 --xp|2500")), + h("code", null, "!modbattr --sel --hp|-25 --hp|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"), + h("code", null, "hp"), " by 25, but won't increase it above ", h("code", null, "mp_xp"), "."), @@ -1164,24 +1171,24 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!resetattr --sel --hp --xp")), + h("code", null, "!resetattr --sel --hp --hp")), h("p", null, "This resets ", h("code", null, "hp"), ", and ", - h("code", null, "xp"), + h("code", null, "hp"), " 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("code", null, "!delattr --sel --hp --hp")), h("p", null, "This removes the ", h("code", null, "hp"), " and ", - h("code", null, "xp"), + h("code", null, "hp"), " 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:"), @@ -1202,7 +1209,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --allgm --xp|150")), + h("code", null, "!setattr --allgm --hp|150")), h("h3", null, "--allplayers"), h("p", null, "Affects all characters with player controllers (typically PCs)."), h("p", null, @@ -1214,7 +1221,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --charid --xp|150")), + h("code", null, "!setattr --charid --hp|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, @@ -1226,7 +1233,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25 --xp|30")), + h("code", null, "!setattr --sel --hp|25 --hp|30")), h("h3", null, "--sel-party"), h("p", null, "Affects only party characters represented by currently selected tokens (characters with ", @@ -1318,12 +1325,12 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --nocreate --perception|20 --xp|15")), + h("code", null, "!setattr --sel --nocreate --perception|20 --hp|15")), h("p", null, "This will only update ", h("code", null, "perception"), " or ", - h("code", null, "xp"), + h("code", null, "hp"), " if it already exists."), h("h3", null, "--evaluate"), h("p", null, @@ -1450,21 +1457,21 @@ var ChatSetAttr = (function (exports) { h("h3", null, "Creating New Repeating Items"), h("p", null, "Use ", - h("code", null, "-CREATE"), + 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("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("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("code", null, "!delattr --sel --repeating_inventory_ID")), h("p", null, "Delete by index:"), h("pre", null, h("code", null, "!delattr --sel --repeating_inventory_$0")), @@ -1773,7 +1780,7 @@ var ChatSetAttr = (function (exports) { references.push(...currentMatches, ...maxMatches); } else { - const suspectedAttribute = part.replace(/[^a-zA-Z0-9_$]/g, ""); + const suspectedAttribute = part.replace(/[^-0-9A-Za-z_$]/g, ""); if (!suspectedAttribute) continue; changes.push({ name: suspectedAttribute }); @@ -1789,6 +1796,123 @@ var ChatSetAttr = (function (exports) { }; } + const REPEATING_INDEX_TOKEN = /^\$(\d+)$/i; + const REPEATING_CREATE_TOKEN = /^CREATE$/i; + const REPEATING_DASH_CREATE_TOKEN = /^-CREATE$/i; + function isRepeatingCreateToken(token) { + return REPEATING_CREATE_TOKEN.test(token) || REPEATING_DASH_CREATE_TOKEN.test(token); + } + function parseRepeatingIdentifierToken(token) { + if (!token) + return null; + const indexMatch = token.match(REPEATING_INDEX_TOKEN); + if (indexMatch) { + return { kind: "index", index: Number(indexMatch[1]) }; + } + if (isRepeatingCreateToken(token)) { + return { kind: "create" }; + } + return { kind: "rowId", rowId: token }; + } + function isRepeatingRowIdToken(token) { + const parsed = parseRepeatingIdentifierToken(token); + return parsed?.kind === "rowId"; + } + function resolveRowIdInRepOrder(repOrder, rowId) { + const rowIdLo = rowId.toLowerCase(); + const index = repOrder.findIndex(id => id.toLowerCase() === rowIdLo); + if (index === -1) + return null; + return repOrder[index]; + } + function parseRepeatingRowDeleteTarget(name) { + if (extractRepeatingParts(name)) { + return null; + } + const parts = name.split("_"); + if (parts.length !== 3) { + return null; + } + const [repeating, section, identifierToken] = parts; + if (repeating !== "repeating" || !section || !identifierToken) { + return null; + } + const parsed = parseRepeatingIdentifierToken(identifierToken); + if (!parsed || parsed.kind === "create") { + return null; + } + const sectionPrefix = `repeating_${section}`; + if (parsed.kind === "index") { + return { sectionPrefix, rowIndex: parsed.index }; + } + return { sectionPrefix, rowId: parsed.rowId }; + } + function getSectionFromRepeatingPrefix(sectionPrefix) { + const match = sectionPrefix.match(/^repeating_(.+)$/); + return match ? match[1] : null; + } + function resolveRepeatingRowId(target, repOrder) { + if (target.rowIndex !== undefined) { + if (target.rowIndex < 0 || target.rowIndex >= repOrder.length) { + return null; + } + return repOrder[target.rowIndex]; + } + if (target.rowId) { + return resolveRowIdInRepOrder(repOrder, target.rowId); + } + return null; + } + function findRepeatingRowAttributeNames(characterID, sectionPrefix, rowId) { + const prefix = `${sectionPrefix}_${rowId}_`.toUpperCase(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + const names = []; + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + if (name.toUpperCase().startsWith(prefix)) { + names.push(name); + } + } + return names; + } + function expandRepeatingRowDeletes(characterID, changes, repOrders, errors, characterName) { + const result = []; + for (const change of changes) { + if (!change.name) + continue; + const target = parseRepeatingRowDeleteTarget(change.name); + if (!target) { + result.push(change); + continue; + } + const section = getSectionFromRepeatingPrefix(target.sectionPrefix); + if (!section) { + result.push(change); + continue; + } + const repOrder = repOrders[section] || []; + const resolvedRowId = resolveRepeatingRowId(target, repOrder); + if (!resolvedRowId) { + if (target.rowIndex !== undefined) { + errors.push(`Repeating row number ${target.rowIndex} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + else { + errors.push(`Repeating row id ${target.rowId} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + continue; + } + const fieldNames = findRepeatingRowAttributeNames(characterID, target.sectionPrefix, resolvedRowId); + for (const name of fieldNames) { + result.push({ name }); + } + } + return result; + } function extractRepeatingParts(attributeName) { const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); if (repeating !== "repeating") { @@ -1804,40 +1928,76 @@ var ChatSetAttr = (function (exports) { 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; + return isRepeatingCreateToken(parts.identifier); } - const hasIndentifier = attributeName.toLowerCase().includes("create"); - return hasIndentifier; + return isRepeatingCreateToken(attributeName); + } + function hasIndexIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (!parts) + return false; + return REPEATING_INDEX_TOKEN.test(parts.identifier); } function convertRepOrderToArray(repOrder) { - return repOrder.split(",").map(id => id.trim()); + return repOrder.split(",").map(id => id.trim()).filter(Boolean); + } + function discoverRowIds(characterID, section) { + const rowIds = new Set(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + const parts = name.split("_"); + if (parts.length < 4) + continue; + if (parts[0] !== "repeating" || parts[1] !== section) + continue; + const identifier = parts[2]; + if (isRepeatingRowIdToken(identifier)) { + rowIds.add(identifier); + } + } + return Array.from(rowIds); + } + function mergeRepOrder(storedOrder, discoveredIds) { + const discoveredSet = new Set(discoveredIds); + const ordered = storedOrder.filter(id => discoveredSet.has(id)); + for (const id of discoveredIds) { + if (!ordered.includes(id)) { + ordered.push(id); + } + } + return ordered; } 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) { + for (const attr of attributes) { if (!attr.name) continue; const parts = extractRepeatingParts(attr.name); - if (!parts) + if (parts) { + sectionNames.add(parts.section); continue; - sectionNames.add(parts.section); + } + const rowDelete = parseRepeatingRowDeleteTarget(attr.name); + if (rowDelete) { + const section = getSectionFromRepeatingPrefix(rowDelete.sectionPrefix); + if (section) { + sectionNames.add(section); + } + } } return Array.from(sectionNames); } @@ -1845,12 +2005,11 @@ var ChatSetAttr = (function (exports) { 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] = []; - } + const stored = repOrderString && typeof repOrderString === "string" + ? convertRepOrderToArray(repOrderString) + : []; + const discovered = discoverRowIds(characterID, section); + repOrders[section] = mergeRepOrder(stored, discovered); } return repOrders; } @@ -1908,7 +2067,7 @@ var ChatSetAttr = (function (exports) { } return result; } - function processModifications(modifications, resolved, options, repOrders) { + function processModifications(modifications, resolved, options, repOrders, errors = [], characterName = "") { const processedModifications = []; const repeatingID = libUUID.generateRowID(); for (const mod of modifications) { @@ -1923,6 +2082,13 @@ var ChatSetAttr = (function (exports) { repeatingID: hasCreate ? repeatingID : parts.identifier, repOrder, }); + if (hasIndexIdentifier(mod.name)) { + const unresolvedIndex = processedName.match(/\$(\d+)/); + if (unresolvedIndex) { + errors.push(`Repeating row number ${unresolvedIndex[1]} invalid for character ${characterName} and repeating section repeating_${parts.section}.`); + continue; + } + } } let processedCurrent = undefined; if (mod.current !== "undefined") { @@ -2295,7 +2461,11 @@ var ChatSetAttr = (function (exports) { priorValues[target] = attrs; const sectionNames = getAllSectionNames(changes); const repOrders = await getAllRepOrders(target, sectionNames); - const modifications = processModifications(changes, attrs, options, repOrders); + let effectiveChanges = changes; + if (operation === "delattr") { + effectiveChanges = expandRepeatingRowDeletes(target, changes, repOrders, errors, getCharName(target)); + } + const modifications = processModifications(effectiveChanges, attrs, options, repOrders, errors, getCharName(target)); const response = await command(modifications, target, references, options.nocreate, feedback); if (response.errors.length > 0) { errors.push(...response.errors); @@ -2319,7 +2489,10 @@ var ChatSetAttr = (function (exports) { sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from, output); + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); } function errorOut(errorText, playerid, errors, output) { errors.push(errorText); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index c7dcd623be..8c941e80db 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -672,6 +672,10 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); } + function whisperPrefix(playerID) { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; + } function normalizeCommandOutputOptions(options = {}) { return { mute: Boolean(options.mute), @@ -682,21 +686,24 @@ var ChatSetAttr = (function (exports) { const player = getObj("player", playerID); return player?.get("_displayname") || undefined; } - function sendMessages(playerID, header, messages, from = "ChatSetAttr", output) { + function sendMessages(playerID, header, messages, delivery, output) { if (output?.silent) { return; } + const from = delivery?.from ?? "ChatSetAttr"; const newMessage = createChatMessage(header, messages); - const player = getPlayerName(playerID); - sendChat(from, `/w "${player || "GM"}" ${newMessage}`); + const chatMessage = delivery?.public + ? newMessage + : `${whisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); } - function sendErrors(playerID, header, errors, from = "ChatSetAttr", output) { + function sendErrors(playerID, header, errors, from, output) { if (errors.length === 0 || output?.mute) { return; } + const sender = from ?? "ChatSetAttr"; const newMessage = createErrorMessage(header, errors); - const player = getPlayerName(playerID); - sendChat(from, `/w "${player || "GM"}" ${newMessage}`); + sendChat(sender, `${whisperPrefix(playerID)}${newMessage}`); } function sendDelayMessage(output) { if (output?.silent) { @@ -1113,14 +1120,14 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25|50 --xp|0|800")), + h("code", null, "!setattr --sel --hp|25|50 --hp|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"), + h("code", null, "hp"), " to 0 and ", h("code", null, "xp_max"), " to 800."), @@ -1132,12 +1139,12 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modattr --sel --hp|-5 --xp|100")), + h("code", null, "!modattr --sel --hp|-5 --hp|100")), h("p", null, "This subtracts 5 from ", h("code", null, "hp"), " and adds 100 to ", - h("code", null, "xp"), + h("code", null, "hp"), "."), h("h3", null, "!modbattr"), h("p", null, @@ -1147,12 +1154,12 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modbattr --sel --hp|-25 --xp|2500")), + h("code", null, "!modbattr --sel --hp|-25 --hp|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"), + h("code", null, "hp"), " by 25, but won't increase it above ", h("code", null, "mp_xp"), "."), @@ -1164,24 +1171,24 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!resetattr --sel --hp --xp")), + h("code", null, "!resetattr --sel --hp --hp")), h("p", null, "This resets ", h("code", null, "hp"), ", and ", - h("code", null, "xp"), + h("code", null, "hp"), " 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("code", null, "!delattr --sel --hp --hp")), h("p", null, "This removes the ", h("code", null, "hp"), " and ", - h("code", null, "xp"), + h("code", null, "hp"), " 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:"), @@ -1202,7 +1209,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --allgm --xp|150")), + h("code", null, "!setattr --allgm --hp|150")), h("h3", null, "--allplayers"), h("p", null, "Affects all characters with player controllers (typically PCs)."), h("p", null, @@ -1214,7 +1221,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --charid --xp|150")), + h("code", null, "!setattr --charid --hp|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, @@ -1226,7 +1233,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25 --xp|30")), + h("code", null, "!setattr --sel --hp|25 --hp|30")), h("h3", null, "--sel-party"), h("p", null, "Affects only party characters represented by currently selected tokens (characters with ", @@ -1318,12 +1325,12 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --nocreate --perception|20 --xp|15")), + h("code", null, "!setattr --sel --nocreate --perception|20 --hp|15")), h("p", null, "This will only update ", h("code", null, "perception"), " or ", - h("code", null, "xp"), + h("code", null, "hp"), " if it already exists."), h("h3", null, "--evaluate"), h("p", null, @@ -1450,21 +1457,21 @@ var ChatSetAttr = (function (exports) { h("h3", null, "Creating New Repeating Items"), h("p", null, "Use ", - h("code", null, "-CREATE"), + 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("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("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("code", null, "!delattr --sel --repeating_inventory_ID")), h("p", null, "Delete by index:"), h("pre", null, h("code", null, "!delattr --sel --repeating_inventory_$0")), @@ -1773,7 +1780,7 @@ var ChatSetAttr = (function (exports) { references.push(...currentMatches, ...maxMatches); } else { - const suspectedAttribute = part.replace(/[^a-zA-Z0-9_$]/g, ""); + const suspectedAttribute = part.replace(/[^-0-9A-Za-z_$]/g, ""); if (!suspectedAttribute) continue; changes.push({ name: suspectedAttribute }); @@ -1789,6 +1796,123 @@ var ChatSetAttr = (function (exports) { }; } + const REPEATING_INDEX_TOKEN = /^\$(\d+)$/i; + const REPEATING_CREATE_TOKEN = /^CREATE$/i; + const REPEATING_DASH_CREATE_TOKEN = /^-CREATE$/i; + function isRepeatingCreateToken(token) { + return REPEATING_CREATE_TOKEN.test(token) || REPEATING_DASH_CREATE_TOKEN.test(token); + } + function parseRepeatingIdentifierToken(token) { + if (!token) + return null; + const indexMatch = token.match(REPEATING_INDEX_TOKEN); + if (indexMatch) { + return { kind: "index", index: Number(indexMatch[1]) }; + } + if (isRepeatingCreateToken(token)) { + return { kind: "create" }; + } + return { kind: "rowId", rowId: token }; + } + function isRepeatingRowIdToken(token) { + const parsed = parseRepeatingIdentifierToken(token); + return parsed?.kind === "rowId"; + } + function resolveRowIdInRepOrder(repOrder, rowId) { + const rowIdLo = rowId.toLowerCase(); + const index = repOrder.findIndex(id => id.toLowerCase() === rowIdLo); + if (index === -1) + return null; + return repOrder[index]; + } + function parseRepeatingRowDeleteTarget(name) { + if (extractRepeatingParts(name)) { + return null; + } + const parts = name.split("_"); + if (parts.length !== 3) { + return null; + } + const [repeating, section, identifierToken] = parts; + if (repeating !== "repeating" || !section || !identifierToken) { + return null; + } + const parsed = parseRepeatingIdentifierToken(identifierToken); + if (!parsed || parsed.kind === "create") { + return null; + } + const sectionPrefix = `repeating_${section}`; + if (parsed.kind === "index") { + return { sectionPrefix, rowIndex: parsed.index }; + } + return { sectionPrefix, rowId: parsed.rowId }; + } + function getSectionFromRepeatingPrefix(sectionPrefix) { + const match = sectionPrefix.match(/^repeating_(.+)$/); + return match ? match[1] : null; + } + function resolveRepeatingRowId(target, repOrder) { + if (target.rowIndex !== undefined) { + if (target.rowIndex < 0 || target.rowIndex >= repOrder.length) { + return null; + } + return repOrder[target.rowIndex]; + } + if (target.rowId) { + return resolveRowIdInRepOrder(repOrder, target.rowId); + } + return null; + } + function findRepeatingRowAttributeNames(characterID, sectionPrefix, rowId) { + const prefix = `${sectionPrefix}_${rowId}_`.toUpperCase(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + const names = []; + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + if (name.toUpperCase().startsWith(prefix)) { + names.push(name); + } + } + return names; + } + function expandRepeatingRowDeletes(characterID, changes, repOrders, errors, characterName) { + const result = []; + for (const change of changes) { + if (!change.name) + continue; + const target = parseRepeatingRowDeleteTarget(change.name); + if (!target) { + result.push(change); + continue; + } + const section = getSectionFromRepeatingPrefix(target.sectionPrefix); + if (!section) { + result.push(change); + continue; + } + const repOrder = repOrders[section] || []; + const resolvedRowId = resolveRepeatingRowId(target, repOrder); + if (!resolvedRowId) { + if (target.rowIndex !== undefined) { + errors.push(`Repeating row number ${target.rowIndex} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + else { + errors.push(`Repeating row id ${target.rowId} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`); + } + continue; + } + const fieldNames = findRepeatingRowAttributeNames(characterID, target.sectionPrefix, resolvedRowId); + for (const name of fieldNames) { + result.push({ name }); + } + } + return result; + } function extractRepeatingParts(attributeName) { const [repeating, section, identifier, ...fieldParts] = attributeName.split("_"); if (repeating !== "repeating") { @@ -1804,40 +1928,76 @@ var ChatSetAttr = (function (exports) { 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; + return isRepeatingCreateToken(parts.identifier); } - const hasIndentifier = attributeName.toLowerCase().includes("create"); - return hasIndentifier; + return isRepeatingCreateToken(attributeName); + } + function hasIndexIdentifier(attributeName) { + const parts = extractRepeatingParts(attributeName); + if (!parts) + return false; + return REPEATING_INDEX_TOKEN.test(parts.identifier); } function convertRepOrderToArray(repOrder) { - return repOrder.split(",").map(id => id.trim()); + return repOrder.split(",").map(id => id.trim()).filter(Boolean); + } + function discoverRowIds(characterID, section) { + const rowIds = new Set(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") + continue; + const parts = name.split("_"); + if (parts.length < 4) + continue; + if (parts[0] !== "repeating" || parts[1] !== section) + continue; + const identifier = parts[2]; + if (isRepeatingRowIdToken(identifier)) { + rowIds.add(identifier); + } + } + return Array.from(rowIds); + } + function mergeRepOrder(storedOrder, discoveredIds) { + const discoveredSet = new Set(discoveredIds); + const ordered = storedOrder.filter(id => discoveredSet.has(id)); + for (const id of discoveredIds) { + if (!ordered.includes(id)) { + ordered.push(id); + } + } + return ordered; } 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) { + for (const attr of attributes) { if (!attr.name) continue; const parts = extractRepeatingParts(attr.name); - if (!parts) + if (parts) { + sectionNames.add(parts.section); continue; - sectionNames.add(parts.section); + } + const rowDelete = parseRepeatingRowDeleteTarget(attr.name); + if (rowDelete) { + const section = getSectionFromRepeatingPrefix(rowDelete.sectionPrefix); + if (section) { + sectionNames.add(section); + } + } } return Array.from(sectionNames); } @@ -1845,12 +2005,11 @@ var ChatSetAttr = (function (exports) { 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] = []; - } + const stored = repOrderString && typeof repOrderString === "string" + ? convertRepOrderToArray(repOrderString) + : []; + const discovered = discoverRowIds(characterID, section); + repOrders[section] = mergeRepOrder(stored, discovered); } return repOrders; } @@ -1908,7 +2067,7 @@ var ChatSetAttr = (function (exports) { } return result; } - function processModifications(modifications, resolved, options, repOrders) { + function processModifications(modifications, resolved, options, repOrders, errors = [], characterName = "") { const processedModifications = []; const repeatingID = libUUID.generateRowID(); for (const mod of modifications) { @@ -1923,6 +2082,13 @@ var ChatSetAttr = (function (exports) { repeatingID: hasCreate ? repeatingID : parts.identifier, repOrder, }); + if (hasIndexIdentifier(mod.name)) { + const unresolvedIndex = processedName.match(/\$(\d+)/); + if (unresolvedIndex) { + errors.push(`Repeating row number ${unresolvedIndex[1]} invalid for character ${characterName} and repeating section repeating_${parts.section}.`); + continue; + } + } } let processedCurrent = undefined; if (mod.current !== "undefined") { @@ -2295,7 +2461,11 @@ var ChatSetAttr = (function (exports) { priorValues[target] = attrs; const sectionNames = getAllSectionNames(changes); const repOrders = await getAllRepOrders(target, sectionNames); - const modifications = processModifications(changes, attrs, options, repOrders); + let effectiveChanges = changes; + if (operation === "delattr") { + effectiveChanges = expandRepeatingRowDeletes(target, changes, repOrders, errors, getCharName(target)); + } + const modifications = processModifications(effectiveChanges, attrs, options, repOrders, errors, getCharName(target)); const response = await command(modifications, target, references, options.nocreate, feedback); if (response.errors.length > 0) { errors.push(...response.errors); @@ -2319,7 +2489,10 @@ var ChatSetAttr = (function (exports) { sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from, output); + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); } function errorOut(errorText, playerid, errors, output) { errors.push(errorText); diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index c18cd1129a..dce1fae274 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -683,17 +683,36 @@ describe("ChatSetAttr Integration Tests", () => { expect(attr).toBeDefined(); expect(attr).toBe("42"); - const mockCalls = vi.mocked(sendChat).mock.calls; - const feedbackCalls = mockCalls.filter(call => { + const feedbackCall = vi.mocked(sendChat).mock.calls.find(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; + return call[0] === "ChatSetAttr" && + typeof message === "string" && + !message.startsWith("/w ") && + message.includes("Setting Attribute"); }); - expect(feedbackCalls.length).toBeGreaterThan(0); + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should default feedback sender to ChatSetAttr without --fb-from", 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 --Attribute|42"); + + await vi.waitFor(async () => { + const attr = await libSmartAttributes.getAttribute("char1", "Attribute"); + expect(attr).toBe("42"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[0] === "ChatSetAttr" && + call[1] && typeof call[1] === "string" && + call[1].includes("Set attribute 'Attribute'") + ); + + expect(feedbackCall).toBeDefined(); }); }); @@ -786,8 +805,39 @@ describe("ChatSetAttr Integration Tests", () => { expect(attr).toBeDefined(); expect(attr).toBe("25"); - // Verify that sendChat was called (feedback message sent) - expect(sendChat).toHaveBeenCalled(); + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[0] === "Dungeon_Master" && + call[1] && typeof call[1] === "string" && + !call[1].startsWith("/w ") && + call[1].includes("Combat Stats Updated") && + call[1].includes("Character 1's health increased to 25!") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should whisper errors even when --fb-public is set", 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 --fb-public --nocreate --MissingAttr|5"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].startsWith("/w ") && + call[1].includes("MissingAttr") + ); + expect(errorCall).toBeDefined(); + + const publicSuccessCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + !call[1].startsWith("/w ") && + call[1].includes("Set attribute") + ); + expect(publicSuccessCall).toBeUndefined(); }); }); }); @@ -1207,6 +1257,54 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should delete all fields for a row by $0 index", async () => { + const { + token, + firstWeaponNameAttr, + firstWeaponDamageAttr, + secondWeaponNameAttr, + secondWeaponDamageAttr, + thirdWeaponNameAttr, + thirdWeaponDamageAttr, + } = createRepeatingObjects(); + const selected = [token.properties]; + + executeCommand("!delattr --sel --repeating_weapons_$0", { selected }); + + await vi.waitFor(() => { + expect(firstWeaponNameAttr.remove).toHaveBeenCalled(); + expect(firstWeaponDamageAttr.remove).toHaveBeenCalled(); + expect(secondWeaponNameAttr.remove).not.toHaveBeenCalled(); + expect(secondWeaponDamageAttr.remove).not.toHaveBeenCalled(); + expect(thirdWeaponNameAttr.remove).not.toHaveBeenCalled(); + expect(thirdWeaponDamageAttr.remove).not.toHaveBeenCalled(); + }); + }); + + it("should delete all fields for a row by row ID", async () => { + const { + token, + firstWeaponNameAttr, + firstWeaponDamageAttr, + secondWeaponNameAttr, + secondWeaponDamageAttr, + thirdWeaponNameAttr, + thirdWeaponDamageAttr, + } = createRepeatingObjects(); + const selected = [token.properties]; + + executeCommand("!delattr --sel --repeating_weapons_-def456", { selected }); + + await vi.waitFor(() => { + expect(firstWeaponNameAttr.remove).not.toHaveBeenCalled(); + expect(firstWeaponDamageAttr.remove).not.toHaveBeenCalled(); + expect(secondWeaponNameAttr.remove).toHaveBeenCalled(); + expect(secondWeaponDamageAttr.remove).toHaveBeenCalled(); + expect(thirdWeaponNameAttr.remove).not.toHaveBeenCalled(); + expect(thirdWeaponDamageAttr.remove).not.toHaveBeenCalled(); + }); + }); + it("should handle modifying repeating section attributes referenced by index", async () => { // arrange const { token } = createRepeatingObjects(); @@ -1249,6 +1347,97 @@ describe("ChatSetAttr Integration Tests", () => { expect(newlyCreated).toBe("5"); }); }); + + it("should update repeating row field by $0 index", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + createObj("attribute", { + _id: "inv-name", + _characterid: character.id, + name: "repeating_inventory_-row1_itemname", + current: "Old Item", + }); + createObj("attribute", { + _id: "inv-reporder", + _characterid: character.id, + name: "_reporder_repeating_inventory", + current: "-row1", + }); + + executeCommand('!setattr --sel --repeating_inventory_$0_itemname|"First Item"', { + selected: [token.properties], + }); + + await vi.waitFor(async () => { + const itemName = await libSmartAttributes.getAttribute("char1", "repeating_inventory_-row1_itemname"); + expect(itemName).toBe("First Item"); + + const literalIndexAttr = findObjs({ + _type: "attribute", + _characterid: "char1", + }).find(attr => attr.get("name")?.includes("_$0_")); + expect(literalIndexAttr).toBeUndefined(); + }); + }); + + it("should resolve $0 from discovered rows when _reporder_ is missing", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + createObj("attribute", { + _id: "inv-name-only", + _characterid: character.id, + name: "repeating_inventory_-row1_itemname", + current: "Old Item", + }); + + executeCommand('!setattr --sel --repeating_inventory_$0_itemname|"First Item"', { + selected: [token.properties], + }); + + await vi.waitFor(async () => { + const itemName = await libSmartAttributes.getAttribute("char1", "repeating_inventory_-row1_itemname"); + expect(itemName).toBe("First Item"); + }); + }); + + it("should error instead of creating literal $5 attribute when index is invalid", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + createObj("attribute", { + _id: "inv-name-error", + _characterid: character.id, + name: "repeating_inventory_-row1_itemname", + current: "Old Item", + }); + createObj("attribute", { + _id: "inv-reporder-error", + _characterid: character.id, + name: "_reporder_repeating_inventory", + current: "-row1", + }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --sel --repeating_inventory_$5_itemname|Bad", { + selected: [token.properties], + }); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Repeating row number 5") + ); + expect(errorCall).toBeDefined(); + + const literalIndexAttr = findObjs({ + _type: "attribute", + _characterid: "char1", + }).find(attr => attr.get("name") === "repeating_inventory_$5_itemname"); + expect(literalIndexAttr).toBeUndefined(); + }); + }); }); describe("Delayed Processing", () => { diff --git a/ChatSetAttr/src/__tests__/unit/chat.test.ts b/ChatSetAttr/src/__tests__/unit/chat.test.ts index f8a7499243..8a94ed217d 100644 --- a/ChatSetAttr/src/__tests__/unit/chat.test.ts +++ b/ChatSetAttr/src/__tests__/unit/chat.test.ts @@ -286,6 +286,53 @@ describe("chat", () => { }); }); + describe("feedback delivery", () => { + beforeEach(() => { + mockPlayer.get.mockReturnValue("Test Player"); + mockGetObj.mockReturnValue(mockPlayer); + mockCreateChatMessage.mockReturnValue("formatted-chat-message"); + mockCreateErrorMessage.mockReturnValue("formatted-error-message"); + }); + + it("should default sender to ChatSetAttr when delivery is omitted", () => { + sendMessages("player123", "Test Header", ["Message"]); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should send public feedback without a whisper prefix", () => { + sendMessages("player123", "Test Header", ["Message"], { public: true }); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "formatted-chat-message"); + expect(mockSendChat.mock.calls[0][1]).not.toMatch(/^\/w /); + }); + + it("should send public feedback from a custom sender", () => { + sendMessages("player123", "Test Header", ["Message"], { from: "Wizard", public: true }); + + expect(mockSendChat).toHaveBeenCalledWith("Wizard", "formatted-chat-message"); + expect(mockSendChat.mock.calls[0][1]).not.toMatch(/^\/w /); + }); + + it("should whisper feedback from a custom sender when not public", () => { + sendMessages("player123", "Test Header", ["Message"], { from: "Wizard" }); + + expect(mockSendChat).toHaveBeenCalledWith("Wizard", "/w \"Test Player\" formatted-chat-message"); + }); + + it("should default error sender to ChatSetAttr when from is undefined", () => { + sendErrors("player123", "Errors", ["Something went wrong"], undefined); + + expect(mockSendChat).toHaveBeenCalledWith("ChatSetAttr", "/w \"Test Player\" formatted-error-message"); + }); + + it("should always whisper errors even with a custom sender", () => { + sendErrors("player123", "Errors", ["Something went wrong"], "Wizard"); + + expect(mockSendChat).toHaveBeenCalledWith("Wizard", "/w \"Test Player\" formatted-error-message"); + }); + }); + describe("command output suppression", () => { beforeEach(() => { mockPlayer.get.mockReturnValue("Test Player"); @@ -310,14 +357,14 @@ describe("chat", () => { }); it("should suppress success messages when silent is set", () => { - sendMessages("player123", "Setting Attributes", ["Set attribute"], "ChatSetAttr", { mute: false, silent: true }); + sendMessages("player123", "Setting Attributes", ["Set attribute"], undefined, { mute: false, silent: true }); expect(mockCreateChatMessage).not.toHaveBeenCalled(); expect(mockSendChat).not.toHaveBeenCalled(); }); it("should suppress success messages when mute is set", () => { - sendMessages("player123", "Setting Attributes", ["Set attribute"], "ChatSetAttr", { mute: true, silent: true }); + sendMessages("player123", "Setting Attributes", ["Set attribute"], undefined, { mute: true, silent: true }); expect(mockCreateChatMessage).not.toHaveBeenCalled(); expect(mockSendChat).not.toHaveBeenCalled(); diff --git a/ChatSetAttr/src/__tests__/unit/message.test.ts b/ChatSetAttr/src/__tests__/unit/message.test.ts index c2c188869c..b6129e7794 100644 --- a/ChatSetAttr/src/__tests__/unit/message.test.ts +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -319,6 +319,16 @@ describe("message", () => { expect(result.changes[0]).toEqual({ name: "skill_1", current: "5" }); expect(result.changes[1]).toEqual({ name: "attr_test_2", current: "value" }); }); + + it("should preserve libUUID row ID characters in bare attribute names", () => { + const result = parse("!delattr --sel --repeating_weapons_-Def456"); + expect(result.changes).toEqual([{ name: "repeating_weapons_-Def456" }]); + }); + + it("should preserve mixed-case row ID tokens without leading hyphen", () => { + const result = parse("!delattr --sel --repeating_inventory_0ABCxyzZ"); + expect(result.changes).toEqual([{ name: "repeating_inventory_0ABCxyzZ" }]); + }); }); describe("referenced attributes parsing", () => { diff --git a/ChatSetAttr/src/__tests__/unit/modifications.test.ts b/ChatSetAttr/src/__tests__/unit/modifications.test.ts index 9d907f298d..b696aeab69 100644 --- a/ChatSetAttr/src/__tests__/unit/modifications.test.ts +++ b/ChatSetAttr/src/__tests__/unit/modifications.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect } from "vitest"; import { processModifierValue, processModifierName, + processModifications, type ProcessModifierNameOptions, } from "../../modules/modifications"; -import type { AttributeRecord } from "../../types"; +import type { Attribute, AttributeRecord, OptionsRecord } from "../../types"; describe("modifications", () => { describe("processModifierValue", () => { @@ -348,4 +349,50 @@ describe("modifications", () => { }); }); }); + + describe("processModifications", () => { + const options = {} as OptionsRecord; + + it("should error and omit modifications when row index cannot be resolved", () => { + const errors: string[] = []; + const changes: Attribute[] = [ + { name: "repeating_inventory_$0_itemname", current: "First Item" }, + ]; + + const result = processModifications( + changes, + {}, + options, + { inventory: [] }, + errors, + "Test Character", + ); + + expect(result).toEqual([]); + expect(errors).toEqual([ + "Repeating row number 0 invalid for character Test Character and repeating section repeating_inventory.", + ]); + }); + + it("should resolve row index when rep order is available", () => { + const errors: string[] = []; + const changes: Attribute[] = [ + { name: "repeating_inventory_$0_itemname", current: "First Item" }, + ]; + + const result = processModifications( + changes, + {}, + options, + { inventory: ["-row1"] }, + errors, + "Test Character", + ); + + expect(result).toEqual([ + { name: "repeating_inventory_-row1_itemname", current: "First Item" }, + ]); + expect(errors).toEqual([]); + }); + }); }); \ No newline at end of file diff --git a/ChatSetAttr/src/__tests__/unit/repeating.test.ts b/ChatSetAttr/src/__tests__/unit/repeating.test.ts index a706265c01..e9f68bfdef 100644 --- a/ChatSetAttr/src/__tests__/unit/repeating.test.ts +++ b/ChatSetAttr/src/__tests__/unit/repeating.test.ts @@ -6,12 +6,19 @@ import { hasCreateIdentifier, hasIndexIdentifier, convertRepOrderToArray, + discoverRowIds, + mergeRepOrder, getIDFromIndex, getRepOrderForSection, extractRepeatingAttributes, getAllSectionNames, getAllRepOrders, processRepeatingAttributes, + parseRepeatingIdentifierToken, + isRepeatingRowIdToken, + resolveRowIdInRepOrder, + parseRepeatingRowDeleteTarget, + expandRepeatingRowDeletes, type RepeatingParts } from "../../modules/repeating"; import type { Attribute } from "../../types"; @@ -200,12 +207,12 @@ describe("repeating", () => { it("should handle empty string", () => { const result = convertRepOrderToArray(""); - expect(result).toEqual([""]); + expect(result).toEqual([]); }); it("should handle string with only commas", () => { const result = convertRepOrderToArray(",,"); - expect(result).toEqual(["", "", ""]); + expect(result).toEqual([]); }); it("should handle mixed spacing", () => { @@ -214,18 +221,55 @@ describe("repeating", () => { }); }); + describe("mergeRepOrder", () => { + it("should preserve stored order for discovered IDs", () => { + const stored = ["-def456", "-abc123"]; + const discovered = ["-abc123", "-def456", "-ghi789"]; + expect(mergeRepOrder(stored, discovered)).toEqual(["-def456", "-abc123", "-ghi789"]); + }); + + it("should fall back to discovered IDs when stored order is empty", () => { + expect(mergeRepOrder([], ["-abc123", "-def456"])).toEqual(["-abc123", "-def456"]); + }); + + it("should omit stored IDs that are not on the character", () => { + expect(mergeRepOrder(["-missing", "-abc123"], ["-abc123"])).toEqual(["-abc123"]); + }); + }); + + describe("discoverRowIds", () => { + it("should collect unique row IDs from repeating attributes", () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_inventory_-abc123_itemname" : undefined }, + { get: (key: string) => key === "name" ? "repeating_inventory_-def456_itemcount" : undefined }, + { get: (key: string) => key === "name" ? "repeating_inventory_-abc123_itemweight" : undefined }, + ] as Roll20Object[]); + + expect(discoverRowIds("char1", "inventory")).toEqual(["-abc123", "-def456"]); + }); + + it("should ignore index and CREATE identifiers", () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_$0_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_CREATE_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + ] as Roll20Object[]); + + expect(discoverRowIds("char1", "weapons")).toEqual(["-abc123"]); + }); + }); + 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 row ID for valid 0-based index identifiers", () => { + expect(getIDFromIndex("repeating_weapons_$0_name", repOrder)).toBe("-abc123"); + expect(getIDFromIndex("repeating_weapons_$1_name", repOrder)).toBe("-def456"); + expect(getIDFromIndex("repeating_weapons_$2_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_$3_name", repOrder)).toBeNull(); expect(getIDFromIndex("repeating_weapons_$-1_name", repOrder)).toBeNull(); expect(getIDFromIndex("repeating_weapons_$999_name", repOrder)).toBeNull(); }); @@ -245,13 +289,12 @@ describe("repeating", () => { }); it("should handle empty repOrder array", () => { - expect(getIDFromIndex("repeating_weapons_$1_name", [])).toBeNull(); + expect(getIDFromIndex("repeating_weapons_$0_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"); + expect(getIDFromIndex("repeating_weapons_$00_name", repOrder)).toBe("-abc123"); + expect(getIDFromIndex("repeating_weapons_$01_name", repOrder)).toBe("-def456"); }); }); @@ -422,6 +465,15 @@ describe("repeating", () => { mockGetAttribute .mockResolvedValueOnce("-abc123,-def456") // weapons .mockResolvedValueOnce("-ghi789,-jkl101"); // spells + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + if (props._characterid !== "char123") return []; + return [ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + { get: (key: string) => key === "name" ? "repeating_spells_-ghi789_level" : undefined }, + { get: (key: string) => key === "name" ? "repeating_spells_-jkl101_level" : undefined }, + ] as Roll20Object[]; + }); const result = await getAllRepOrders("char123", ["weapons", "spells"]); @@ -437,6 +489,13 @@ describe("repeating", () => { mockGetAttribute .mockResolvedValueOnce("-abc123,-def456") // weapons .mockResolvedValueOnce(undefined); // spells - no reporder + vi.mocked(global.findObjs).mockImplementation((props: Record) => { + if (props._characterid !== "char123") return []; + return [ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + ] as Roll20Object[]; + }); const result = await getAllRepOrders("char123", ["weapons", "spells"]); @@ -446,6 +505,19 @@ describe("repeating", () => { }); }); + it("should discover row IDs when _reporder_ is missing", async () => { + mockGetAttribute.mockResolvedValue(undefined); + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_inventory_-row1_itemname" : undefined }, + ] as Roll20Object[]); + + const result = await getAllRepOrders("char123", ["inventory"]); + + expect(result).toEqual({ + inventory: ["-row1"], + }); + }); + it("should handle empty section names array", async () => { const result = await getAllRepOrders("char123", []); @@ -455,6 +527,9 @@ describe("repeating", () => { it("should handle single section", async () => { mockGetAttribute.mockResolvedValue("-abc123"); + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + ] as Roll20Object[]); const result = await getAllRepOrders("char123", ["weapons"]); @@ -467,6 +542,13 @@ describe("repeating", () => { describe("processRepeatingAttributes", () => { let mockGetAttribute: MockedFunction; + const mockWeaponRowDiscovery = () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + ] as Roll20Object[]); + }; + beforeEach(() => { mockGetAttribute = vi.fn(); libSmartAttributes.getAttribute = mockGetAttribute; @@ -511,15 +593,15 @@ describe("repeating", () => { 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" } + { name: "repeating_weapons_$0_name", current: "First Weapon" }, + { name: "repeating_weapons_$1_damage", current: "1d8" } ]; mockGetAttribute.mockResolvedValue("-abc123,-def456"); + mockWeaponRowDiscovery(); 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", @@ -533,15 +615,15 @@ describe("repeating", () => { 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 + { name: "repeating_weapons_$0_name", current: "First Weapon" }, + { name: "repeating_weapons_$5_name", current: "Invalid Index" } ]; mockGetAttribute.mockResolvedValue("-abc123,-def456"); + mockWeaponRowDiscovery(); 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", @@ -553,13 +635,21 @@ describe("repeating", () => { 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_weapons_$0_damage", current: "1d8" }, { name: "repeating_spells_CREATE_spell", current: "New Spell" } ]; mockGetAttribute .mockResolvedValueOnce("-abc123,-def456") // weapons .mockResolvedValueOnce("-ghi789"); // spells + vi.mocked(global.findObjs) + .mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_name" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_damage" : undefined }, + ] as Roll20Object[]) + .mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_spells_-ghi789_level" : undefined }, + ] as Roll20Object[]); const result = await processRepeatingAttributes("char123", attributes); @@ -623,4 +713,141 @@ describe("repeating", () => { expect(mockGetAttribute).not.toHaveBeenCalled(); }); }); + + describe("parseRepeatingIdentifierToken", () => { + it("should classify index tokens", () => { + expect(parseRepeatingIdentifierToken("$0")).toEqual({ kind: "index", index: 0 }); + expect(parseRepeatingIdentifierToken("$12")).toEqual({ kind: "index", index: 12 }); + }); + + it("should classify CREATE tokens case-insensitively", () => { + expect(parseRepeatingIdentifierToken("CREATE")).toEqual({ kind: "create" }); + expect(parseRepeatingIdentifierToken("create")).toEqual({ kind: "create" }); + }); + + it("should classify libUUID-style row ID tokens", () => { + expect(parseRepeatingIdentifierToken("-abc123")).toEqual({ kind: "rowId", rowId: "-abc123" }); + expect(parseRepeatingIdentifierToken("0ABCxyzZ")).toEqual({ kind: "rowId", rowId: "0ABCxyzZ" }); + }); + + it("should not treat libUUID tokens as CREATE", () => { + expect(parseRepeatingIdentifierToken("-CREATE123longtoken")).toEqual({ + kind: "rowId", + rowId: "-CREATE123longtoken", + }); + }); + }); + + describe("isRepeatingRowIdToken", () => { + it("should return true for row ID tokens only", () => { + expect(isRepeatingRowIdToken("-abc123")).toBe(true); + expect(isRepeatingRowIdToken("0ABCxyzZ")).toBe(true); + expect(isRepeatingRowIdToken("$0")).toBe(false); + expect(isRepeatingRowIdToken("CREATE")).toBe(false); + }); + }); + + describe("resolveRowIdInRepOrder", () => { + const repOrder = ["-abc123", "-Def456", "0GHIxyzZ"]; + + it("should resolve row IDs case-insensitively and return canonical casing", () => { + expect(resolveRowIdInRepOrder(repOrder, "-ABC123")).toBe("-abc123"); + expect(resolveRowIdInRepOrder(repOrder, "-def456")).toBe("-Def456"); + expect(resolveRowIdInRepOrder(repOrder, "0ghixyzz")).toBe("0GHIxyzZ"); + }); + + it("should return null for unknown row IDs", () => { + expect(resolveRowIdInRepOrder(repOrder, "-missing")).toBeNull(); + }); + }); + + describe("parseRepeatingRowDeleteTarget", () => { + it("should parse row-only index targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_$0")).toEqual({ + sectionPrefix: "repeating_weapons", + rowIndex: 0, + }); + }); + + it("should parse row-only row ID targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_-def456")).toEqual({ + sectionPrefix: "repeating_weapons", + rowId: "-def456", + }); + }); + + it("should reject field-specific targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_$1_weaponname")).toBeNull(); + expect(parseRepeatingRowDeleteTarget("repeating_weapons_-abc123_name")).toBeNull(); + }); + + it("should reject CREATE row-only targets", () => { + expect(parseRepeatingRowDeleteTarget("repeating_weapons_CREATE")).toBeNull(); + }); + }); + + describe("expandRepeatingRowDeletes", () => { + it("should expand a row-only target into field deletes", () => { + vi.mocked(global.findObjs).mockReturnValueOnce([ + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_weaponname" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-abc123_damage" : undefined }, + { get: (key: string) => key === "name" ? "repeating_weapons_-def456_weaponname" : undefined }, + ] as Roll20Object[]); + + const errors: string[] = []; + const result = expandRepeatingRowDeletes( + "char1", + [{ name: "repeating_weapons_$0" }], + { weapons: ["-abc123", "-def456"] }, + errors, + "Test Character", + ); + + expect(errors).toEqual([]); + expect(result).toEqual([ + { name: "repeating_weapons_-abc123_weaponname" }, + { name: "repeating_weapons_-abc123_damage" }, + ]); + }); + + it("should pass through non-row-delete targets unchanged", () => { + const errors: string[] = []; + const result = expandRepeatingRowDeletes( + "char1", + [{ name: "repeating_weapons_$1_weaponname" }], + { weapons: ["-abc123"] }, + errors, + "Test Character", + ); + + expect(result).toEqual([{ name: "repeating_weapons_$1_weaponname" }]); + }); + + it("should error on invalid row index", () => { + const errors: string[] = []; + const result = expandRepeatingRowDeletes( + "char1", + [{ name: "repeating_weapons_$5" }], + { weapons: ["-abc123"] }, + errors, + "Test Character", + ); + + expect(result).toEqual([]); + expect(errors).toEqual([ + "Repeating row number 5 invalid for character Test Character and repeating section repeating_weapons.", + ]); + }); + }); + + describe("getAllSectionNames row-delete targets", () => { + it("should extract section from row-only delete targets", () => { + const result = getAllSectionNames([ + { name: "repeating_weapons_$0" }, + { name: "repeating_inventory_-abc123" }, + ]); + + expect(result.sort()).toEqual(["inventory", "weapons"]); + }); + }); }); diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index a27cfd0a6b..f269795bd9 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -13,6 +13,16 @@ export type NormalizedCommandOutputOptions = { mute: boolean; }; +export type FeedbackDeliveryOptions = { + from?: string; + public?: boolean; +}; + +function whisperPrefix(playerID: string): string { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; +} + export function normalizeCommandOutputOptions( options: CommandOutputOptions = {}, ): NormalizedCommandOutputOptions { @@ -31,32 +41,35 @@ export function sendMessages( playerID: string, header: string, messages: string[], - from: string = "ChatSetAttr", + delivery?: FeedbackDeliveryOptions, output?: NormalizedCommandOutputOptions, ): void { if (output?.silent) { return; } + const from = delivery?.from ?? "ChatSetAttr"; const newMessage = createChatMessage(header, messages); - const player = getPlayerName(playerID); - sendChat(from, `/w "${player || "GM"}" ${newMessage}`); + const chatMessage = delivery?.public + ? newMessage + : `${whisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); }; export function sendErrors( playerID: string, header: string, errors: string[], - from: string = "ChatSetAttr", + from?: string, output?: NormalizedCommandOutputOptions, ): void { if (errors.length === 0 || output?.mute) { return; } + const sender = from ?? "ChatSetAttr"; const newMessage = createErrorMessage(header, errors); - const player = getPlayerName(playerID); - sendChat(from, `/w "${player || "GM"}" ${newMessage}`); + sendChat(sender, `${whisperPrefix(playerID)}${newMessage}`); }; export function sendDelayMessage(output?: NormalizedCommandOutputOptions): void { diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 2074f6267a..f2063872e0 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -5,10 +5,11 @@ import { sendDelayMessage, sendErrors, sendMessages, normalizeCommandOutputOptio import { handlers } from "./commands"; import { checkConfigMessage, getConfig, handleConfigCommand, hasFlag } from "./config"; import { checkHelpMessage, handleHelpCommand } from "./help"; +import { getCharName } from "./helpers"; import { extractMessageFromRollTemplate, parseMessage, validateMessage } from "./message"; import { processModifications } from "./modifications"; import { checkPermissions } from "./permissions"; -import { getAllRepOrders, getAllSectionNames } from "./repeating"; +import { expandRepeatingRowDeletes, getAllRepOrders, getAllSectionNames } from "./repeating"; import { generateTargets } from "./targets"; import { clearTimer, startTimer } from "./timer"; import { makeUpdate } from "./updates"; @@ -92,7 +93,15 @@ async function acceptMessage(msg: Roll20ChatMessage) { priorValues[target] = attrs; const sectionNames = getAllSectionNames(changes); const repOrders = await getAllRepOrders(target, sectionNames); - const modifications = processModifications(changes, attrs, options, repOrders); + let effectiveChanges = changes; + if (operation === "delattr") { + effectiveChanges = expandRepeatingRowDeletes( + target, changes, repOrders, errors, getCharName(target), + ); + } + const modifications = processModifications( + effectiveChanges, attrs, options, repOrders, errors, getCharName(target), + ); const response = await command(modifications, target, references, options.nocreate, feedback); @@ -126,7 +135,10 @@ async function acceptMessage(msg: Roll20ChatMessage) { sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, feedback?.from, output); + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); }; function errorOut( diff --git a/ChatSetAttr/src/modules/message.ts b/ChatSetAttr/src/modules/message.ts index 49cdcf8b2a..5c6d17834d 100644 --- a/ChatSetAttr/src/modules/message.ts +++ b/ChatSetAttr/src/modules/message.ts @@ -128,7 +128,7 @@ export function parseMessage(content: string) { } else { - const suspectedAttribute = part.replace(/[^a-zA-Z0-9_$]/g, ""); + const suspectedAttribute = part.replace(/[^-0-9A-Za-z_$]/g, ""); if (!suspectedAttribute) continue; changes.push({ name: suspectedAttribute }); } diff --git a/ChatSetAttr/src/modules/modifications.ts b/ChatSetAttr/src/modules/modifications.ts index db56f3727c..3f065dbf2a 100644 --- a/ChatSetAttr/src/modules/modifications.ts +++ b/ChatSetAttr/src/modules/modifications.ts @@ -1,5 +1,5 @@ import { ALIAS_CHARACTERS, type Attribute, type AttributeRecord, type OptionsRecord } from "../types"; -import { extractRepeatingParts, hasCreateIdentifier } from "./repeating"; +import { extractRepeatingParts, hasCreateIdentifier, hasIndexIdentifier } from "./repeating"; export type ProcessModifierOptions = { shouldEvaluate?: boolean; @@ -93,6 +93,8 @@ export function processModifications( resolved: AttributeRecord, options: OptionsRecord, repOrders: Record, + errors: string[] = [], + characterName = "", ): Attribute[] { const processedModifications: Attribute[] = []; const repeatingID = libUUID.generateRowID(); @@ -108,6 +110,16 @@ export function processModifications( repeatingID: hasCreate ? repeatingID : parts.identifier, repOrder, }); + + if (hasIndexIdentifier(mod.name)) { + const unresolvedIndex = processedName.match(/\$(\d+)/); + if (unresolvedIndex) { + errors.push( + `Repeating row number ${unresolvedIndex[1]} invalid for character ${characterName} and repeating section repeating_${parts.section}.`, + ); + continue; + } + } } let processedCurrent = undefined; diff --git a/ChatSetAttr/src/modules/repeating.ts b/ChatSetAttr/src/modules/repeating.ts index 8de6efb70a..16604a9c95 100644 --- a/ChatSetAttr/src/modules/repeating.ts +++ b/ChatSetAttr/src/modules/repeating.ts @@ -1,5 +1,168 @@ import type { Attribute } from "../types"; +export const REPEATING_INDEX_TOKEN = /^\$(\d+)$/i; +export const REPEATING_CREATE_TOKEN = /^CREATE$/i; +export const REPEATING_DASH_CREATE_TOKEN = /^-CREATE$/i; + +export function isRepeatingCreateToken(token: string): boolean { + return REPEATING_CREATE_TOKEN.test(token) || REPEATING_DASH_CREATE_TOKEN.test(token); +}; + +export type RepeatingIdentifierToken = + | { kind: "index"; index: number } + | { kind: "create" } + | { kind: "rowId"; rowId: string }; + +export function parseRepeatingIdentifierToken( + token: string, +): RepeatingIdentifierToken | null { + if (!token) return null; + const indexMatch = token.match(REPEATING_INDEX_TOKEN); + if (indexMatch) { + return { kind: "index", index: Number(indexMatch[1]) }; + } + if (isRepeatingCreateToken(token)) { + return { kind: "create" }; + } + return { kind: "rowId", rowId: token }; +}; + +export function isRepeatingRowIdToken(token: string): boolean { + const parsed = parseRepeatingIdentifierToken(token); + return parsed?.kind === "rowId"; +}; + +export function resolveRowIdInRepOrder( + repOrder: string[], + rowId: string, +): string | null { + const rowIdLo = rowId.toLowerCase(); + const index = repOrder.findIndex(id => id.toLowerCase() === rowIdLo); + if (index === -1) return null; + return repOrder[index]; +}; + +export type RepeatingRowDeleteTarget = { + sectionPrefix: string; + rowIndex?: number; + rowId?: string; +}; + +export function parseRepeatingRowDeleteTarget( + name: string, +): RepeatingRowDeleteTarget | null { + if (extractRepeatingParts(name)) { + return null; + } + const parts = name.split("_"); + if (parts.length !== 3) { + return null; + } + const [repeating, section, identifierToken] = parts; + if (repeating !== "repeating" || !section || !identifierToken) { + return null; + } + const parsed = parseRepeatingIdentifierToken(identifierToken); + if (!parsed || parsed.kind === "create") { + return null; + } + const sectionPrefix = `repeating_${section}`; + if (parsed.kind === "index") { + return { sectionPrefix, rowIndex: parsed.index }; + } + return { sectionPrefix, rowId: parsed.rowId }; +}; + +export function getSectionFromRepeatingPrefix( + sectionPrefix: string, +): string | null { + const match = sectionPrefix.match(/^repeating_(.+)$/); + return match ? match[1] : null; +}; + +export function resolveRepeatingRowId( + target: RepeatingRowDeleteTarget, + repOrder: string[], +): string | null { + if (target.rowIndex !== undefined) { + if (target.rowIndex < 0 || target.rowIndex >= repOrder.length) { + return null; + } + return repOrder[target.rowIndex]; + } + if (target.rowId) { + return resolveRowIdInRepOrder(repOrder, target.rowId); + } + return null; +}; + +export function findRepeatingRowAttributeNames( + characterID: string, + sectionPrefix: string, + rowId: string, +): string[] { + const prefix = `${sectionPrefix}_${rowId}_`.toUpperCase(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + const names: string[] = []; + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") continue; + if (name.toUpperCase().startsWith(prefix)) { + names.push(name); + } + } + return names; +}; + +export function expandRepeatingRowDeletes( + characterID: string, + changes: Attribute[], + repOrders: Record, + errors: string[], + characterName: string, +): Attribute[] { + const result: Attribute[] = []; + for (const change of changes) { + if (!change.name) continue; + const target = parseRepeatingRowDeleteTarget(change.name); + if (!target) { + result.push(change); + continue; + } + const section = getSectionFromRepeatingPrefix(target.sectionPrefix); + if (!section) { + result.push(change); + continue; + } + const repOrder = repOrders[section] || []; + const resolvedRowId = resolveRepeatingRowId(target, repOrder); + if (!resolvedRowId) { + if (target.rowIndex !== undefined) { + errors.push( + `Repeating row number ${target.rowIndex} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`, + ); + } else { + errors.push( + `Repeating row id ${target.rowId} invalid for character ${characterName} and repeating section ${target.sectionPrefix}.`, + ); + } + continue; + } + const fieldNames = findRepeatingRowAttributeNames( + characterID, + target.sectionPrefix, + resolvedRowId, + ); + for (const name of fieldNames) { + result.push({ name }); + } + } + return result; +}; + export type RepeatingParts = { section: string; identifier: string; @@ -45,11 +208,9 @@ export function hasCreateIdentifier( ): boolean { const parts = extractRepeatingParts(attributeName); if (parts) { - const hasIndentifier = parts.identifier.toLowerCase().includes("create"); - return hasIndentifier; + return isRepeatingCreateToken(parts.identifier); } - const hasIndentifier = attributeName.toLowerCase().includes("create"); - return hasIndentifier; + return isRepeatingCreateToken(attributeName); }; export function hasIndexIdentifier( @@ -57,14 +218,52 @@ export function hasIndexIdentifier( ): boolean { const parts = extractRepeatingParts(attributeName); if (!parts) return false; - const hasIndentifier = parts.identifier.match(/^\$(\d+)$/i) !== null; - return hasIndentifier; + return REPEATING_INDEX_TOKEN.test(parts.identifier); }; export function convertRepOrderToArray( repOrder: string ): string[] { - return repOrder.split(",").map(id => id.trim()); + return repOrder.split(",").map(id => id.trim()).filter(Boolean); +}; + +export function discoverRowIds( + characterID: string, + section: string, +): string[] { + const rowIds = new Set(); + const attributes = findObjs({ + _type: "attribute", + _characterid: characterID, + }); + + for (const attribute of attributes) { + const name = attribute.get("name"); + if (typeof name !== "string") continue; + const parts = name.split("_"); + if (parts.length < 4) continue; + if (parts[0] !== "repeating" || parts[1] !== section) continue; + const identifier = parts[2]; + if (isRepeatingRowIdToken(identifier)) { + rowIds.add(identifier); + } + } + + return Array.from(rowIds); +}; + +export function mergeRepOrder( + storedOrder: string[], + discoveredIds: string[], +): string[] { + const discoveredSet = new Set(discoveredIds); + const ordered = storedOrder.filter(id => discoveredSet.has(id)); + for (const id of discoveredIds) { + if (!ordered.includes(id)) { + ordered.push(id); + } + } + return ordered; }; export function getIDFromIndex( @@ -76,15 +275,14 @@ export function getIDFromIndex( 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) { + if (isNaN(index) || index < 0 || index >= repOrder.length) { return null; } - return repOrder[index - 1]; + return repOrder[index]; }; export async function getRepOrderForSection( @@ -106,12 +304,20 @@ export function getAllSectionNames( attributes: Attribute[] ): string[] { const sectionNames: Set = new Set(); - const repeatingAttributes = extractRepeatingAttributes(attributes); - for (const attr of repeatingAttributes) { + for (const attr of attributes) { if (!attr.name) continue; const parts = extractRepeatingParts(attr.name); - if (!parts) continue; - sectionNames.add(parts.section); + if (parts) { + sectionNames.add(parts.section); + continue; + } + const rowDelete = parseRepeatingRowDeleteTarget(attr.name); + if (rowDelete) { + const section = getSectionFromRepeatingPrefix(rowDelete.sectionPrefix); + if (section) { + sectionNames.add(section); + } + } } return Array.from(sectionNames); }; @@ -123,11 +329,11 @@ export async function getAllRepOrders( 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] = []; - } + const stored = repOrderString && typeof repOrderString === "string" + ? convertRepOrderToArray(repOrderString) + : []; + const discovered = discoverRowIds(characterID, section); + repOrders[section] = mergeRepOrder(stored, discovered); } return repOrders; }; diff --git a/ChatSetAttr/src/templates/help.tsx b/ChatSetAttr/src/templates/help.tsx index 3d5c08adcd..b361967d6b 100644 --- a/ChatSetAttr/src/templates/help.tsx +++ b/ChatSetAttr/src/templates/help.tsx @@ -61,45 +61,45 @@ export function createHelpHandout(handoutID: string): string {

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
+
!setattr --sel --hp|25|50 --hp|0|800
-

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

+

This would set hp to 25, hp_max to 50, hp 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
+
!modattr --sel --hp|-5 --hp|100
-

This subtracts 5 from hp and adds 100 to xp.

+

This subtracts 5 from hp and adds 100 to hp.

!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
+
!modbattr --sel --hp|-25 --hp|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.

+

This subtracts 5 from hp but won't reduce it below 0 and increase hp 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
+
!resetattr --sel --hp --hp
-

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

+

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

!delattr

Deletes the specified attributes.

Example:

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

This removes the hp and xp attributes.

+

This removes the hp and hp attributes.

Target Selection

@@ -117,7 +117,7 @@ export function createHelpHandout(handoutID: string): string {

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

Example:

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

--allplayers

@@ -131,7 +131,7 @@ export function createHelpHandout(handoutID: string): string {

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

Example:

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

--name

@@ -145,7 +145,7 @@ export function createHelpHandout(handoutID: string): string {

Affects characters represented by currently selected tokens.

Example:

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

--sel-party

@@ -220,9 +220,9 @@ export function createHelpHandout(handoutID: string): string {

Prevents creation of new attributes, only updates existing ones.

Example:

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

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

+

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

--evaluate

@@ -348,15 +348,15 @@ export function createHelpHandout(handoutID: string): string {

Creating New Repeating Items

-

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

+

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

-
!setattr --sel --repeating_inventory_-CREATE_itemname|"Magic Sword" --repeating_inventory_-CREATE_itemweight|2
+
!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"
+
!setattr --sel --repeating_inventory_ID_itemname|"Enchanted Magic Sword"

Access by index (starts at 0):

@@ -366,7 +366,7 @@ export function createHelpHandout(handoutID: string): string {

Delete by row ID:

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

Delete by index:

From 80481d369d7ed1862f5bccb85b760e02af26b614 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 11 Jun 2026 12:09:06 -0500 Subject: [PATCH 19/38] Inline Roll handling --- ChatSetAttr/2.0/ChatSetAttr.js | 33 +++++- ChatSetAttr/ChatSetAttr.js | 33 +++++- .../src/__mocks__/eventHandling.mock.ts | 52 +++++---- .../integration/legacyAttributes.test.ts | 88 ++++++++++++++- .../src/__tests__/unit/inlinerolls.test.ts | 104 ++++++++++++++++++ .../src/__tests__/unit/modifications.test.ts | 19 ++++ ChatSetAttr/src/modules/inlinerolls.ts | 42 +++++++ ChatSetAttr/src/modules/main.ts | 2 + ChatSetAttr/src/modules/modifications.ts | 6 +- 9 files changed, 350 insertions(+), 29 deletions(-) create mode 100644 ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts create mode 100644 ChatSetAttr/src/modules/inlinerolls.ts diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 8c941e80db..6400ddc8ea 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -1589,6 +1589,31 @@ var ChatSetAttr = (function (exports) { }); } + function inlineRollValue(roll) { + const tableItems = roll.results.rolls.reduce((names, subRoll) => { + const tableSubRoll = subRoll; + if (!Object.prototype.hasOwnProperty.call(tableSubRoll, "table")) { + return names; + } + const subNames = (tableSubRoll.results ?? []) + .map(result => result.tableItem?.name ?? "") + .filter(Boolean); + if (subNames.length) { + names.push(subNames.join(", ")); + } + return names; + }, []); + const tableText = tableItems.filter(Boolean).join(", "); + return (tableText.length && tableText) || roll.results.total || 0; + } + function processInlinerolls(msg) { + if (!msg.inlinerolls?.length) { + return msg.content; + } + const values = msg.inlinerolls.map(roll => String(inlineRollValue(roll))); + return values.reduce((content, value, index) => content.replace(`$[[${index}]]`, value), msg.content); + } + // #region Commands const COMMAND_TYPE = [ "setattr", @@ -2055,7 +2080,12 @@ var ChatSetAttr = (function (exports) { let result = name; const hasCreate = result.includes("CREATE"); if (hasCreate && repeatingID) { - result = result.replace("CREATE", repeatingID); + if (/-CREATE/i.test(result)) { + result = result.replace(/-CREATE/i, repeatingID); + } + else { + result = result.replace(/CREATE/i, repeatingID); + } } const rowIndexMatch = result.match(/\$(\d+)/); if (rowIndexMatch && repOrder) { @@ -2527,6 +2557,7 @@ var ChatSetAttr = (function (exports) { return; msg.content = inlineMessage; } + msg.content = processInlinerolls(msg); const debugReset = msg.content.startsWith("!setattrs-debugreset"); if (debugReset) { log("ChatSetAttr: Debug - resetting state."); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 8c941e80db..6400ddc8ea 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1589,6 +1589,31 @@ var ChatSetAttr = (function (exports) { }); } + function inlineRollValue(roll) { + const tableItems = roll.results.rolls.reduce((names, subRoll) => { + const tableSubRoll = subRoll; + if (!Object.prototype.hasOwnProperty.call(tableSubRoll, "table")) { + return names; + } + const subNames = (tableSubRoll.results ?? []) + .map(result => result.tableItem?.name ?? "") + .filter(Boolean); + if (subNames.length) { + names.push(subNames.join(", ")); + } + return names; + }, []); + const tableText = tableItems.filter(Boolean).join(", "); + return (tableText.length && tableText) || roll.results.total || 0; + } + function processInlinerolls(msg) { + if (!msg.inlinerolls?.length) { + return msg.content; + } + const values = msg.inlinerolls.map(roll => String(inlineRollValue(roll))); + return values.reduce((content, value, index) => content.replace(`$[[${index}]]`, value), msg.content); + } + // #region Commands const COMMAND_TYPE = [ "setattr", @@ -2055,7 +2080,12 @@ var ChatSetAttr = (function (exports) { let result = name; const hasCreate = result.includes("CREATE"); if (hasCreate && repeatingID) { - result = result.replace("CREATE", repeatingID); + if (/-CREATE/i.test(result)) { + result = result.replace(/-CREATE/i, repeatingID); + } + else { + result = result.replace(/CREATE/i, repeatingID); + } } const rowIndexMatch = result.match(/\$(\d+)/); if (rowIndexMatch && repOrder) { @@ -2527,6 +2557,7 @@ var ChatSetAttr = (function (exports) { return; msg.content = inlineMessage; } + msg.content = processInlinerolls(msg); const debugReset = msg.content.startsWith("!setattrs-debugreset"); if (debugReset) { log("ChatSetAttr: Debug - resetting state."); diff --git a/ChatSetAttr/src/__mocks__/eventHandling.mock.ts b/ChatSetAttr/src/__mocks__/eventHandling.mock.ts index a4cc03bebf..ea066febb6 100644 --- a/ChatSetAttr/src/__mocks__/eventHandling.mock.ts +++ b/ChatSetAttr/src/__mocks__/eventHandling.mock.ts @@ -67,18 +67,6 @@ export function simulateChatMessage(message: string, options?: SimulationMessage 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); @@ -90,18 +78,34 @@ export function simulateChatMessage(message: string, options?: SimulationMessage 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}`); - } - }); + // When Roll20 has already resolved inline rolls, content uses $[[N]] placeholders + // and inlinerolls carries the results — do not pre-evaluate [[...]] in that case. + if (!inlinerolls?.length) { + // 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()); + }); + + // 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, diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index dce1fae274..193b47c2bb 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -165,13 +165,13 @@ describe("ChatSetAttr Integration Tests", () => { await vi.waitFor(async () => { expect(sendChat).toHaveBeenCalled(); - const repeatingRowId = "-unique-rowid-1234"; + 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"); + const itemContent = await libSmartAttributes.getAttribute("char1", `user.repeating_inventory_unique-rowid-1234_itemcontent`); expect(itemName).toBe("Cloak of Excellence"); expect(itemCount).toBe("1"); @@ -182,6 +182,27 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should not double the leading hyphen when using -CREATE with a row ID that starts with -", async () => { + vi.mocked(libUUID.generateRowID).mockReturnValueOnce("-Ounn8umZgulvFN0kH0Q"); + + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: "char1", _subtype: "token" }); + + executeCommand( + "!setattr --sel --repeating_inventory_-CREATE_itemname|taco sword", + { selected: [token.properties] }, + ); + + await vi.waitFor(async () => { + const itemName = await libSmartAttributes.getAttribute( + "char1", + "user.repeating_inventory_-Ounn8umZgulvFN0kH0Q_itemname", + ); + expect(itemName).toBe("taco sword"); + }); + }); + 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 }); @@ -199,6 +220,69 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should substitute inline roll placeholders in attribute values", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: "char1", _subtype: "token" }); + + executeCommand("!setattr --sel --hp|$[[0]]", { + selected: [token.properties], + content: "!setattr --sel --hp|$[[0]]", + inlinerolls: [{ + expression: "3d6", + results: { + resultType: "sum", + total: 11, + type: "V", + rolls: [{ + dice: 3, + sides: 6, + type: "R", + results: [{ v: 4 }, { v: 3 }, { v: 4 }], + }], + }, + rollid: "roll-hp", + signature: "sig-hp", + }], + }); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "hp"); + expect(hp).toBe("11"); + }); + }); + + it("should substitute rollable table inline roll placeholders in attribute values", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: "char1", _subtype: "token" }); + + executeCommand("!setattr --sel --treasure|$[[0]]", { + selected: [token.properties], + content: "!setattr --sel --treasure|$[[0]]", + inlinerolls: [{ + expression: "1d100", + results: { + resultType: "sum", + total: 42, + type: "V", + rolls: [{ + table: "table-id", + type: "R", + results: [{ tableItem: { name: "Magic Sword" } }], + }], + }, + rollid: "roll-table", + signature: "sig-table", + }], + }); + + await vi.waitFor(async () => { + const treasure = await libSmartAttributes.getAttribute("char1", "treasure"); + expect(treasure).toBe("Magic Sword"); + }); + }); + 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 }); diff --git a/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts b/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts new file mode 100644 index 0000000000..2a3d01a32a --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { processInlinerolls } from "../../modules/inlinerolls"; + +function makeDiceRoll(total: number): RollData { + return { + expression: "3d6", + results: { + resultType: "sum", + total, + type: "V", + rolls: [{ + dice: 3, + sides: 6, + type: "R", + results: [{ v: 4 }, { v: 1 }, { v: 3 }], + }], + }, + rollid: "roll-1", + signature: "sig-1", + }; +} + +describe("processInlinerolls", () => { + it("should replace a simple inline roll placeholder with the roll total", () => { + const result = processInlinerolls({ + content: "!setattr --sel --hp|$[[0]]", + inlinerolls: [makeDiceRoll(11)], + }); + + expect(result).toBe("!setattr --sel --hp|11"); + }); + + it("should return content unchanged when inlinerolls are missing", () => { + const content = "!setattr --sel --hp|$[[0]]"; + expect(processInlinerolls({ content })).toBe(content); + expect(processInlinerolls({ content, inlinerolls: [] })).toBe(content); + }); + + it("should replace nested roll placeholders using the correct index", () => { + const result = processInlinerolls({ + content: "!! test $[[1]]", + inlinerolls: [ + { + expression: "1d2+4", + results: { + resultType: "sum", + total: 6, + type: "V", + rolls: [], + }, + rollid: "inner-roll", + signature: "sig-inner", + }, + makeDiceRoll(8), + ], + }); + + expect(result).toBe("!! test 8"); + }); + + it("should replace multiple placeholders in one command", () => { + const result = processInlinerolls({ + content: "!setattr --sel --hp|$[[0]] --temp|$[[1]]", + inlinerolls: [makeDiceRoll(11), makeDiceRoll(5)], + }); + + expect(result).toBe("!setattr --sel --hp|11 --temp|5"); + }); + + it("should use rollable table item names when present", () => { + const result = processInlinerolls({ + content: "!setattr --sel --result|$[[0]]", + inlinerolls: [{ + expression: "1d100", + results: { + resultType: "sum", + total: 42, + type: "V", + rolls: [{ + table: "table-id", + type: "R", + results: [ + { tableItem: { name: "Magic Sword" } }, + { tableItem: { name: "Healing Potion" } }, + ], + }], + }, + rollid: "table-roll", + signature: "sig-table", + }], + }); + + expect(result).toBe("!setattr --sel --result|Magic Sword, Healing Potion"); + }); + + it("should preserve a zero roll total", () => { + const result = processInlinerolls({ + content: "!setattr --sel --hp|$[[0]]", + inlinerolls: [makeDiceRoll(0)], + }); + + expect(result).toBe("!setattr --sel --hp|0"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/modifications.test.ts b/ChatSetAttr/src/__tests__/unit/modifications.test.ts index b696aeab69..8e8f03e98b 100644 --- a/ChatSetAttr/src/__tests__/unit/modifications.test.ts +++ b/ChatSetAttr/src/__tests__/unit/modifications.test.ts @@ -200,6 +200,25 @@ describe("modifications", () => { expect(result).toBe("repeating_skills_row123_name"); }); + it("should replace -CREATE without doubling a leading hyphen in the row ID", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "-Ounn8umZgulvFN0kH0Q", + repOrder: [""], + }; + const result = processModifierName("repeating_inventory_-CREATE_itemname", options); + expect(result).toBe("repeating_inventory_-Ounn8umZgulvFN0kH0Q_itemname"); + expect(result).not.toMatch(/inventory_--/); + }); + + it("should replace -CREATE with row IDs that do not start with a hyphen", () => { + const options: ProcessModifierNameOptions = { + repeatingID: "unique-rowid-1234", + repOrder: [""], + }; + const result = processModifierName("repeating_inventory_-CREATE_itemname", options); + expect(result).toBe("repeating_inventory_unique-rowid-1234_itemname"); + }); + it("should not replace CREATE when repeatingID is not provided", () => { const options: ProcessModifierNameOptions = { repOrder: [""], diff --git a/ChatSetAttr/src/modules/inlinerolls.ts b/ChatSetAttr/src/modules/inlinerolls.ts new file mode 100644 index 0000000000..6ca44cb1e6 --- /dev/null +++ b/ChatSetAttr/src/modules/inlinerolls.ts @@ -0,0 +1,42 @@ +type TableRollResult = { + tableItem?: { + name?: string; + }; +}; + +type TableRoll = { + table?: unknown; + results?: TableRollResult[]; +}; + +function inlineRollValue(roll: RollData): string | number { + const tableItems = roll.results.rolls.reduce((names, subRoll) => { + const tableSubRoll = subRoll as TableRoll; + if (!Object.prototype.hasOwnProperty.call(tableSubRoll, "table")) { + return names; + } + const subNames = (tableSubRoll.results ?? []) + .map(result => result.tableItem?.name ?? "") + .filter(Boolean); + if (subNames.length) { + names.push(subNames.join(", ")); + } + return names; + }, []); + const tableText = tableItems.filter(Boolean).join(", "); + return (tableText.length && tableText) || roll.results.total || 0; +} + +export function processInlinerolls( + msg: Pick, +): string { + if (!msg.inlinerolls?.length) { + return msg.content; + } + + const values = msg.inlinerolls.map(roll => String(inlineRollValue(roll))); + return values.reduce( + (content, value, index) => content.replace(`$[[${index}]]`, value), + msg.content, + ); +} diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index f2063872e0..1771a22b13 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -6,6 +6,7 @@ import { handlers } from "./commands"; import { checkConfigMessage, getConfig, handleConfigCommand, hasFlag } from "./config"; import { checkHelpMessage, handleHelpCommand } from "./help"; import { getCharName } from "./helpers"; +import { processInlinerolls } from "./inlinerolls"; import { extractMessageFromRollTemplate, parseMessage, validateMessage } from "./message"; import { processModifications } from "./modifications"; import { checkPermissions } from "./permissions"; @@ -185,6 +186,7 @@ export function registerHandlers() { if (!inlineMessage) return; msg.content = inlineMessage; } + msg.content = processInlinerolls(msg); const debugReset = msg.content.startsWith("!setattrs-debugreset"); if (debugReset) { log("ChatSetAttr: Debug - resetting state."); diff --git a/ChatSetAttr/src/modules/modifications.ts b/ChatSetAttr/src/modules/modifications.ts index 3f065dbf2a..ba50d7012a 100644 --- a/ChatSetAttr/src/modules/modifications.ts +++ b/ChatSetAttr/src/modules/modifications.ts @@ -74,7 +74,11 @@ export function processModifierName( let result = name; const hasCreate = result.includes("CREATE"); if (hasCreate && repeatingID) { - result = result.replace("CREATE", repeatingID); + if (/-CREATE/i.test(result)) { + result = result.replace(/-CREATE/i, repeatingID); + } else { + result = result.replace(/CREATE/i, repeatingID); + } } const rowIndexMatch = result.match(/\$(\d+)/); From 5c5e295ec90d30adbbe9e1ec123acb0e1af0841b Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 11 Jun 2026 14:42:00 -0500 Subject: [PATCH 20/38] Help Doc cleanup, _MAX0_ bug fix. --- ChatSetAttr/2.0/ChatSetAttr.js | 342 ++++++++++-------- ChatSetAttr/ChatSetAttr.js | 342 ++++++++++-------- .../integration/legacyAttributes.test.ts | 26 ++ .../src/__tests__/unit/commands.test.ts | 20 + .../src/__tests__/unit/feedback.test.ts | 21 +- ChatSetAttr/src/modules/chat.ts | 6 +- ChatSetAttr/src/modules/config.ts | 10 +- ChatSetAttr/src/modules/feedback.ts | 43 ++- ChatSetAttr/src/modules/main.ts | 2 +- ChatSetAttr/src/templates/help.tsx | 57 +-- 10 files changed, 506 insertions(+), 363 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 6400ddc8ea..c1cdadbdaa 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -59,6 +59,125 @@ var ChatSetAttr = (function (exports) { marginBottom: "0.5em", }; + const DELAY_WRAPPER_STYLE = s(frameStyleBase); + const DELAY_HEADER_STYLE = s(headerStyleBase); + function createDelayMessage() { + return (h("div", { style: DELAY_WRAPPER_STYLE }, + h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), + h("div", null, "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."))); + } + + const CHAT_WRAPPER_STYLE = s(frameStyleBase); + const CHAT_HEADER_STYLE = s(headerStyleBase); + const CHAT_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + const ERROR_WRAPPER_STYLE = s({ + ...frameStyleBase, + ...frameStyleError, + }); + const ERROR_HEADER_STYLE = s(headerStyleBase); + const ERROR_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + // #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(frameStyleBase); + const NOTIFY_HEADER_STYLE = s(headerStyleBase); + function createNotifyMessage(title, content) { + return (h("div", { style: NOTIFY_WRAPPER_STYLE }, + h("div", { style: NOTIFY_HEADER_STYLE }, title), + h("div", null, content))); + } + + function createWelcomeMessage() { + const buttonStyle = s(buttonStyleBase); + return (h("div", null, + h("p", null, "Thank you for installing ChatSetAttr."), + h("p", null, + "To get started, use the command ", + h("code", null, "!setattr-config"), + " to configure the script to your needs."), + h("p", null, + "For detailed documentation and examples, please use the ", + h("code", null, "!setattr-help"), + " command or click the button below:"), + h("p", null, + h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); + } + + function getWhisperPrefix(playerID) { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; + } + function normalizeCommandOutputOptions(options = {}) { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; + } + function getPlayerName(playerID) { + const player = getObj("player", playerID); + return player?.get("_displayname") || undefined; + } + function sendMessages(playerID, header, messages, delivery, output) { + if (output?.silent) { + return; + } + const from = delivery?.from ?? "ChatSetAttr"; + const newMessage = createChatMessage(header, messages); + const chatMessage = delivery?.public + ? newMessage + : `${getWhisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); + } + function sendErrors(playerID, header, errors, from, output) { + if (errors.length === 0 || output?.mute) { + return; + } + const sender = from ?? "ChatSetAttr"; + const newMessage = createErrorMessage(header, errors); + sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); + } + function sendDelayMessage(output) { + if (output?.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 = createWelcomeMessage(); + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); + } + const CONFIG_WRAPPER_STYLE = s(frameStyleBase); const CONFIG_HEADER_STYLE = s(headerStyleBase); const CONFIG_TABLE_STYLE = s({ @@ -154,7 +273,7 @@ var ChatSetAttr = (function (exports) { "--players-can-target-party": "playersCanTargetParty", "--use-workers": "useWorkers", }; - function handleConfigCommand(message) { + function handleConfigCommand(message, playerID) { message = message.replace("!setattr-config", "").trim(); const args = message.split(/\s+/); const newConfig = {}; @@ -168,7 +287,7 @@ var ChatSetAttr = (function (exports) { } setConfig(newConfig); const configMessage = createConfigMessage(); - sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${configMessage}`, undefined, { noarchive: true }); } const observers = {}; @@ -602,125 +721,6 @@ var ChatSetAttr = (function (exports) { return attributes; } - const DELAY_WRAPPER_STYLE = s(frameStyleBase); - const DELAY_HEADER_STYLE = s(headerStyleBase); - function createDelayMessage() { - return (h("div", { style: DELAY_WRAPPER_STYLE }, - h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), - h("div", null, "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."))); - } - - const CHAT_WRAPPER_STYLE = s(frameStyleBase); - const CHAT_HEADER_STYLE = s(headerStyleBase); - const CHAT_BODY_STYLE = s({ - fontSize: "14px", - lineHeight: "1.4", - }); - const ERROR_WRAPPER_STYLE = s({ - ...frameStyleBase, - ...frameStyleError, - }); - const ERROR_HEADER_STYLE = s(headerStyleBase); - const ERROR_BODY_STYLE = s({ - fontSize: "14px", - lineHeight: "1.4", - }); - // #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(frameStyleBase); - const NOTIFY_HEADER_STYLE = s(headerStyleBase); - function createNotifyMessage(title, content) { - return (h("div", { style: NOTIFY_WRAPPER_STYLE }, - h("div", { style: NOTIFY_HEADER_STYLE }, title), - h("div", null, content))); - } - - function createWelcomeMessage() { - const buttonStyle = s(buttonStyleBase); - return (h("div", null, - h("p", null, "Thank you for installing ChatSetAttr."), - h("p", null, - "To get started, use the command ", - h("code", null, "!setattr-config"), - " to configure the script to your needs."), - h("p", null, - "For detailed documentation and examples, please use the ", - h("code", null, "!setattr-help"), - " command or click the button below:"), - h("p", null, - h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); - } - - function whisperPrefix(playerID) { - const player = getPlayerName(playerID); - return `/w "${player || "GM"}" `; - } - function normalizeCommandOutputOptions(options = {}) { - return { - mute: Boolean(options.mute), - silent: Boolean(options.silent || options.mute), - }; - } - function getPlayerName(playerID) { - const player = getObj("player", playerID); - return player?.get("_displayname") || undefined; - } - function sendMessages(playerID, header, messages, delivery, output) { - if (output?.silent) { - return; - } - const from = delivery?.from ?? "ChatSetAttr"; - const newMessage = createChatMessage(header, messages); - const chatMessage = delivery?.public - ? newMessage - : `${whisperPrefix(playerID)}${newMessage}`; - sendChat(from, chatMessage); - } - function sendErrors(playerID, header, errors, from, output) { - if (errors.length === 0 || output?.mute) { - return; - } - const sender = from ?? "ChatSetAttr"; - const newMessage = createErrorMessage(header, errors); - sendChat(sender, `${whisperPrefix(playerID)}${newMessage}`); - } - function sendDelayMessage(output) { - if (output?.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 = createWelcomeMessage(); - sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); - } - function createFeedbackMessage(characterName, feedback, startingValues, targetValues) { let message = feedback?.content ?? ""; // _NAMEJ_: will insert the attribute name. @@ -729,34 +729,50 @@ var ChatSetAttr = (function (exports) { // _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")); + const targetValueKeys = getChangedAttributeNames(targetValues); 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`]; + const sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; switch (key) { case "NAME": return attributeName; case "TCUR": - return `${targetCurrent}`; + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; case "TMAX": - return `${targetMax}`; - case "CUR": - return `${startingCurrent}`; - case "MAX": - return `${startingMax}`; + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } default: return ""; } }); return message; } + function getChangedAttributeNames(targetValues) { + const seen = new Set(); + const names = []; + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; + } function cleanValue(value) { return value.trim().replace(/^['"](.*)['"]$/g, "$1"); @@ -1062,6 +1078,7 @@ var ChatSetAttr = (function (exports) { const contents = [ "Basic Usage", "Available Commands", + "Beacon Computed Values", "Target Selection", "Attribute Syntax", "Modifier Options", @@ -1120,17 +1137,15 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25|50 --hp|0|800")), + h("code", null, "!setattr --sel --hp|25|50 --hp_temp|8")), h("p", null, "This would set ", h("code", null, "hp"), " to 25, ", h("code", null, "hp_max"), " to 50, ", - h("code", null, "hp"), - " to 0 and ", - h("code", null, "xp_max"), - " to 800."), + h("code", null, "hp_temp"), + " to 8."), h("h3", null, "!modattr"), h("p", null, "Adds to existing attribute values (works only with numeric values). Shorthand for ", @@ -1139,11 +1154,11 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modattr --sel --hp|-5 --hp|100")), + h("code", null, "!modattr --sel --hp_temp|-5 --hp|6")), h("p", null, "This subtracts 5 from ", - h("code", null, "hp"), - " and adds 100 to ", + h("code", null, "hp_temp"), + " and adds 6 to ", h("code", null, "hp"), "."), h("h3", null, "!modbattr"), @@ -1154,10 +1169,10 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modbattr --sel --hp|-25 --hp|2500")), + h("code", null, "!modbattr --sel --hp_temp|-5 --hp|25")), h("p", null, "This subtracts 5 from ", - h("code", null, "hp"), + h("code", null, "hp_temp"), " but won't reduce it below 0 and increase ", h("code", null, "hp"), " by 25, but won't increase it above ", @@ -1171,25 +1186,32 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!resetattr --sel --hp --hp")), + h("code", null, "!resetattr --sel --hp")), h("p", null, "This resets ", h("code", null, "hp"), - ", and ", - h("code", null, "hp"), - " to their respective maximum values."), + " to its maximum value."), 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 --hp")), + h("code", null, "!delattr --sel --hp --hp_temp")), h("p", null, "This removes the ", h("code", null, "hp"), " and ", - h("code", null, "hp"), + h("code", null, "hp_temp"), " attributes."), + h("h2", { id: "beacon-computed-values" }, "Beacon Computed Values"), + h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet existing when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), + h("p", null, "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message."), + h("p", null, + "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with ", + h("code", null, "user."), + " like ", + h("code", null, "user.spellpoints"), + ". They function like attributes and can be created, removed, set, reset, and modified as desired."), 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"), @@ -1200,7 +1222,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --all --hp|15")), + h("code", null, "!resetattr --all --hp")), h("h3", null, "--allgm"), h("p", null, "Affects all characters without player controllers (typically NPCs). ", @@ -1209,19 +1231,19 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --allgm --hp|150")), + h("code", null, "!setattr --allgm --reset --hp")), 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("code", null, "!setattr --allplayers --mod --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 --hp|150")), + h("code", null, "!setattr --charid <ID1> <ID2> --hp|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, @@ -1233,7 +1255,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25 --hp|30")), + h("code", null, "!setattr --sel --hp|25 --hp_temp|8")), h("h3", null, "--sel-party"), h("p", null, "Affects only party characters represented by currently selected tokens (characters with ", @@ -1348,8 +1370,8 @@ var ChatSetAttr = (function (exports) { 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("li", null, "; becomes ?"), h("li", null, "` becomes @")), @@ -1357,7 +1379,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"")), + 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:"), @@ -1379,19 +1401,19 @@ var ChatSetAttr = (function (exports) { 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("h3", null, "--fb-from <NAME>"), 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("h3", null, "--fb-header <STRING>"), 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("h3", null, "--fb-content <STRING>"), h("p", null, "Customizes the content of the output message."), h("p", null, h("strong", null, "Example:")), @@ -2582,7 +2604,7 @@ var ChatSetAttr = (function (exports) { if (!playerIsGM(msg.playerid)) { return; } - handleConfigCommand(msg.content); + handleConfigCommand(msg.content, msg.playerid); return; } const validMessage = validateMessage(msg.content); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 6400ddc8ea..c1cdadbdaa 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -59,6 +59,125 @@ var ChatSetAttr = (function (exports) { marginBottom: "0.5em", }; + const DELAY_WRAPPER_STYLE = s(frameStyleBase); + const DELAY_HEADER_STYLE = s(headerStyleBase); + function createDelayMessage() { + return (h("div", { style: DELAY_WRAPPER_STYLE }, + h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), + h("div", null, "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."))); + } + + const CHAT_WRAPPER_STYLE = s(frameStyleBase); + const CHAT_HEADER_STYLE = s(headerStyleBase); + const CHAT_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + const ERROR_WRAPPER_STYLE = s({ + ...frameStyleBase, + ...frameStyleError, + }); + const ERROR_HEADER_STYLE = s(headerStyleBase); + const ERROR_BODY_STYLE = s({ + fontSize: "14px", + lineHeight: "1.4", + }); + // #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(frameStyleBase); + const NOTIFY_HEADER_STYLE = s(headerStyleBase); + function createNotifyMessage(title, content) { + return (h("div", { style: NOTIFY_WRAPPER_STYLE }, + h("div", { style: NOTIFY_HEADER_STYLE }, title), + h("div", null, content))); + } + + function createWelcomeMessage() { + const buttonStyle = s(buttonStyleBase); + return (h("div", null, + h("p", null, "Thank you for installing ChatSetAttr."), + h("p", null, + "To get started, use the command ", + h("code", null, "!setattr-config"), + " to configure the script to your needs."), + h("p", null, + "For detailed documentation and examples, please use the ", + h("code", null, "!setattr-help"), + " command or click the button below:"), + h("p", null, + h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); + } + + function getWhisperPrefix(playerID) { + const player = getPlayerName(playerID); + return `/w "${player || "GM"}" `; + } + function normalizeCommandOutputOptions(options = {}) { + return { + mute: Boolean(options.mute), + silent: Boolean(options.silent || options.mute), + }; + } + function getPlayerName(playerID) { + const player = getObj("player", playerID); + return player?.get("_displayname") || undefined; + } + function sendMessages(playerID, header, messages, delivery, output) { + if (output?.silent) { + return; + } + const from = delivery?.from ?? "ChatSetAttr"; + const newMessage = createChatMessage(header, messages); + const chatMessage = delivery?.public + ? newMessage + : `${getWhisperPrefix(playerID)}${newMessage}`; + sendChat(from, chatMessage); + } + function sendErrors(playerID, header, errors, from, output) { + if (errors.length === 0 || output?.mute) { + return; + } + const sender = from ?? "ChatSetAttr"; + const newMessage = createErrorMessage(header, errors); + sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); + } + function sendDelayMessage(output) { + if (output?.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 = createWelcomeMessage(); + sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); + } + const CONFIG_WRAPPER_STYLE = s(frameStyleBase); const CONFIG_HEADER_STYLE = s(headerStyleBase); const CONFIG_TABLE_STYLE = s({ @@ -154,7 +273,7 @@ var ChatSetAttr = (function (exports) { "--players-can-target-party": "playersCanTargetParty", "--use-workers": "useWorkers", }; - function handleConfigCommand(message) { + function handleConfigCommand(message, playerID) { message = message.replace("!setattr-config", "").trim(); const args = message.split(/\s+/); const newConfig = {}; @@ -168,7 +287,7 @@ var ChatSetAttr = (function (exports) { } setConfig(newConfig); const configMessage = createConfigMessage(); - sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${configMessage}`, undefined, { noarchive: true }); } const observers = {}; @@ -602,125 +721,6 @@ var ChatSetAttr = (function (exports) { return attributes; } - const DELAY_WRAPPER_STYLE = s(frameStyleBase); - const DELAY_HEADER_STYLE = s(headerStyleBase); - function createDelayMessage() { - return (h("div", { style: DELAY_WRAPPER_STYLE }, - h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), - h("div", null, "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."))); - } - - const CHAT_WRAPPER_STYLE = s(frameStyleBase); - const CHAT_HEADER_STYLE = s(headerStyleBase); - const CHAT_BODY_STYLE = s({ - fontSize: "14px", - lineHeight: "1.4", - }); - const ERROR_WRAPPER_STYLE = s({ - ...frameStyleBase, - ...frameStyleError, - }); - const ERROR_HEADER_STYLE = s(headerStyleBase); - const ERROR_BODY_STYLE = s({ - fontSize: "14px", - lineHeight: "1.4", - }); - // #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(frameStyleBase); - const NOTIFY_HEADER_STYLE = s(headerStyleBase); - function createNotifyMessage(title, content) { - return (h("div", { style: NOTIFY_WRAPPER_STYLE }, - h("div", { style: NOTIFY_HEADER_STYLE }, title), - h("div", null, content))); - } - - function createWelcomeMessage() { - const buttonStyle = s(buttonStyleBase); - return (h("div", null, - h("p", null, "Thank you for installing ChatSetAttr."), - h("p", null, - "To get started, use the command ", - h("code", null, "!setattr-config"), - " to configure the script to your needs."), - h("p", null, - "For detailed documentation and examples, please use the ", - h("code", null, "!setattr-help"), - " command or click the button below:"), - h("p", null, - h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); - } - - function whisperPrefix(playerID) { - const player = getPlayerName(playerID); - return `/w "${player || "GM"}" `; - } - function normalizeCommandOutputOptions(options = {}) { - return { - mute: Boolean(options.mute), - silent: Boolean(options.silent || options.mute), - }; - } - function getPlayerName(playerID) { - const player = getObj("player", playerID); - return player?.get("_displayname") || undefined; - } - function sendMessages(playerID, header, messages, delivery, output) { - if (output?.silent) { - return; - } - const from = delivery?.from ?? "ChatSetAttr"; - const newMessage = createChatMessage(header, messages); - const chatMessage = delivery?.public - ? newMessage - : `${whisperPrefix(playerID)}${newMessage}`; - sendChat(from, chatMessage); - } - function sendErrors(playerID, header, errors, from, output) { - if (errors.length === 0 || output?.mute) { - return; - } - const sender = from ?? "ChatSetAttr"; - const newMessage = createErrorMessage(header, errors); - sendChat(sender, `${whisperPrefix(playerID)}${newMessage}`); - } - function sendDelayMessage(output) { - if (output?.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 = createWelcomeMessage(); - sendNotification("Welcome to ChatSetAttr!", welcomeMessage, false); - } - function createFeedbackMessage(characterName, feedback, startingValues, targetValues) { let message = feedback?.content ?? ""; // _NAMEJ_: will insert the attribute name. @@ -729,34 +729,50 @@ var ChatSetAttr = (function (exports) { // _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")); + const targetValueKeys = getChangedAttributeNames(targetValues); 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`]; + const sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; switch (key) { case "NAME": return attributeName; case "TCUR": - return `${targetCurrent}`; + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; case "TMAX": - return `${targetMax}`; - case "CUR": - return `${startingCurrent}`; - case "MAX": - return `${startingMax}`; + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } default: return ""; } }); return message; } + function getChangedAttributeNames(targetValues) { + const seen = new Set(); + const names = []; + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; + } function cleanValue(value) { return value.trim().replace(/^['"](.*)['"]$/g, "$1"); @@ -1062,6 +1078,7 @@ var ChatSetAttr = (function (exports) { const contents = [ "Basic Usage", "Available Commands", + "Beacon Computed Values", "Target Selection", "Attribute Syntax", "Modifier Options", @@ -1120,17 +1137,15 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25|50 --hp|0|800")), + h("code", null, "!setattr --sel --hp|25|50 --hp_temp|8")), h("p", null, "This would set ", h("code", null, "hp"), " to 25, ", h("code", null, "hp_max"), " to 50, ", - h("code", null, "hp"), - " to 0 and ", - h("code", null, "xp_max"), - " to 800."), + h("code", null, "hp_temp"), + " to 8."), h("h3", null, "!modattr"), h("p", null, "Adds to existing attribute values (works only with numeric values). Shorthand for ", @@ -1139,11 +1154,11 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modattr --sel --hp|-5 --hp|100")), + h("code", null, "!modattr --sel --hp_temp|-5 --hp|6")), h("p", null, "This subtracts 5 from ", - h("code", null, "hp"), - " and adds 100 to ", + h("code", null, "hp_temp"), + " and adds 6 to ", h("code", null, "hp"), "."), h("h3", null, "!modbattr"), @@ -1154,10 +1169,10 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!modbattr --sel --hp|-25 --hp|2500")), + h("code", null, "!modbattr --sel --hp_temp|-5 --hp|25")), h("p", null, "This subtracts 5 from ", - h("code", null, "hp"), + h("code", null, "hp_temp"), " but won't reduce it below 0 and increase ", h("code", null, "hp"), " by 25, but won't increase it above ", @@ -1171,25 +1186,32 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!resetattr --sel --hp --hp")), + h("code", null, "!resetattr --sel --hp")), h("p", null, "This resets ", h("code", null, "hp"), - ", and ", - h("code", null, "hp"), - " to their respective maximum values."), + " to its maximum value."), 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 --hp")), + h("code", null, "!delattr --sel --hp --hp_temp")), h("p", null, "This removes the ", h("code", null, "hp"), " and ", - h("code", null, "hp"), + h("code", null, "hp_temp"), " attributes."), + h("h2", { id: "beacon-computed-values" }, "Beacon Computed Values"), + h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet existing when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), + h("p", null, "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message."), + h("p", null, + "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with ", + h("code", null, "user."), + " like ", + h("code", null, "user.spellpoints"), + ". They function like attributes and can be created, removed, set, reset, and modified as desired."), 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"), @@ -1200,7 +1222,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --all --hp|15")), + h("code", null, "!resetattr --all --hp")), h("h3", null, "--allgm"), h("p", null, "Affects all characters without player controllers (typically NPCs). ", @@ -1209,19 +1231,19 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --allgm --hp|150")), + h("code", null, "!setattr --allgm --reset --hp")), 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("code", null, "!setattr --allplayers --mod --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 --hp|150")), + h("code", null, "!setattr --charid <ID1> <ID2> --hp|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, @@ -1233,7 +1255,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --hp|25 --hp|30")), + h("code", null, "!setattr --sel --hp|25 --hp_temp|8")), h("h3", null, "--sel-party"), h("p", null, "Affects only party characters represented by currently selected tokens (characters with ", @@ -1348,8 +1370,8 @@ var ChatSetAttr = (function (exports) { 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("li", null, "; becomes ?"), h("li", null, "` becomes @")), @@ -1357,7 +1379,7 @@ var ChatSetAttr = (function (exports) { h("p", null, h("strong", null, "Example:")), h("pre", null, - h("code", null, "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"")), + 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:"), @@ -1379,19 +1401,19 @@ var ChatSetAttr = (function (exports) { 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("h3", null, "--fb-from <NAME>"), 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("h3", null, "--fb-header <STRING>"), 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("h3", null, "--fb-content <STRING>"), h("p", null, "Customizes the content of the output message."), h("p", null, h("strong", null, "Example:")), @@ -2582,7 +2604,7 @@ var ChatSetAttr = (function (exports) { if (!playerIsGM(msg.playerid)) { return; } - handleConfigCommand(msg.content); + handleConfigCommand(msg.content, msg.playerid); return; } const validMessage = validateMessage(msg.content); diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index 193b47c2bb..f7d8e3ee44 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -713,14 +713,40 @@ describe("ChatSetAttr Integration Tests", () => { executeCommand("!setattr-config --players-can-modify", { playerid: "example-player-id" }); expect(global.state.ChatSetAttr.playersCanModify).toBeFalsy(); expect(sendChat).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendChat).mock.calls[0][1]).toMatch(/^\/w "Test Player" /); + expect(vi.mocked(sendChat).mock.calls[0][1]).toContain("ChatSetAttr Configuration"); executeCommand("!setattr-config --players-can-evaluate", { playerid: "example-player-id" }); expect(global.state.ChatSetAttr.playersCanEvaluate).toBeFalsy(); expect(sendChat).toHaveBeenCalledTimes(2); + expect(vi.mocked(sendChat).mock.calls[1][1]).toMatch(/^\/w "Test Player" /); executeCommand("!setattr-config --use-workers", { playerid: "example-player-id" }); expect(global.state.ChatSetAttr.useWorkers).toBeFalsy(); expect(sendChat).toHaveBeenCalledTimes(3); + expect(vi.mocked(sendChat).mock.calls[2][1]).toMatch(/^\/w "Test Player" /); + }); + + it("should whisper config output to the executing GM", () => { + vi.mocked(global.playerIsGM).mockReturnValue(true); + createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + + executeCommand("!setattr-config", { playerid: "example-player-id" }); + + expect(sendChat).toHaveBeenCalledTimes(1); + const message = vi.mocked(sendChat).mock.calls[0][1]; + expect(message).toMatch(/^\/w "Test Player" /); + expect(message).toContain("ChatSetAttr Configuration"); + }); + + it("should not send config output for non-GM players", () => { + vi.mocked(global.playerIsGM).mockReturnValue(false); + createObj("player", { _id: "player123", _displayname: "Regular Player" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr-config", { playerid: "player123" }); + + expect(sendChat).not.toHaveBeenCalled(); }); it("should respect player permissions", async () => { diff --git a/ChatSetAttr/src/__tests__/unit/commands.test.ts b/ChatSetAttr/src/__tests__/unit/commands.test.ts index e293e4cf82..5a2133d57e 100644 --- a/ChatSetAttr/src/__tests__/unit/commands.test.ts +++ b/ChatSetAttr/src/__tests__/unit/commands.test.ts @@ -125,6 +125,26 @@ describe("commands", () => { }); }); + it("should substitute _MAX0_ from sheet when only current is modified", async () => { + mockGetAttributes.mockResolvedValue({ + hp: 7, + hp_max: 119, + }); + + const changes: Attribute[] = [ + { name: "hp", current: "+3" }, + ]; + const feedback = { + public: false, + content: "_NAME0_ was _TCUR0_/_TMAX0_ now _CUR0_/_MAX0_ for _CHARNAME_", + }; + + const result = await modattr(changes, "char1", [], false, feedback); + + expect(result.messagesByKey.hp).toBe("hp was 7/119 now 10/119 for ID: char1"); + expect(result.messagesByKey.hp).not.toContain("undefined"); + }); + it("should modify current values with subtraction", async () => { const changes: Attribute[] = [ { name: "hp", current: "-3" }, diff --git a/ChatSetAttr/src/__tests__/unit/feedback.test.ts b/ChatSetAttr/src/__tests__/unit/feedback.test.ts index ff071e05f6..27b2278df3 100644 --- a/ChatSetAttr/src/__tests__/unit/feedback.test.ts +++ b/ChatSetAttr/src/__tests__/unit/feedback.test.ts @@ -85,12 +85,31 @@ describe("createFeedbackMessage", () => { expect(result).toBe(""); }); + it("should fall back to starting max when max was not modified", () => { + const startingValues: AttributeRecord = { hp: 7, hp_max: 119 }; + const targetValues: AttributeRecord = { hp: 10 }; + const feedback: FeedbackObject = { + public: false, + content: "_NAME0_ was _TCUR0_/_TMAX0_ now _CUR0_/_MAX0_ for _CHARNAME_", + }; + const result = createFeedbackMessage("The Aaron 2014", feedback, startingValues, targetValues); + expect(result).toBe("hp was 7/119 now 10/119 for The Aaron 2014"); + }); + + it("should fall back to starting current when current was not modified", () => { + const startingValues: AttributeRecord = { hp: 7, hp_max: 119 }; + const targetValues: AttributeRecord = { hp_max: 125 }; + const feedback: FeedbackObject = { public: false, content: "_CUR0_/_MAX0_" }; + const result = createFeedbackMessage("John", feedback, startingValues, targetValues); + expect(result).toBe("7/125"); + }); + 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"); + expect(result).toBe(" to "); }); it("should handle empty target values", () => { diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index f269795bd9..43b3a02d12 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -18,7 +18,7 @@ export type FeedbackDeliveryOptions = { public?: boolean; }; -function whisperPrefix(playerID: string): string { +export function getWhisperPrefix(playerID: string): string { const player = getPlayerName(playerID); return `/w "${player || "GM"}" `; } @@ -52,7 +52,7 @@ export function sendMessages( const newMessage = createChatMessage(header, messages); const chatMessage = delivery?.public ? newMessage - : `${whisperPrefix(playerID)}${newMessage}`; + : `${getWhisperPrefix(playerID)}${newMessage}`; sendChat(from, chatMessage); }; @@ -69,7 +69,7 @@ export function sendErrors( const sender = from ?? "ChatSetAttr"; const newMessage = createErrorMessage(header, errors); - sendChat(sender, `${whisperPrefix(playerID)}${newMessage}`); + sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); }; export function sendDelayMessage(output?: NormalizedCommandOutputOptions): void { diff --git a/ChatSetAttr/src/modules/config.ts b/ChatSetAttr/src/modules/config.ts index 77948be1cc..8c3dbdb213 100644 --- a/ChatSetAttr/src/modules/config.ts +++ b/ChatSetAttr/src/modules/config.ts @@ -1,3 +1,4 @@ +import { getWhisperPrefix } from "./chat"; import { createConfigMessage } from "../templates/config"; type ScriptConfig = { @@ -69,7 +70,7 @@ const FLAG_MAP: Record = { "--use-workers": "useWorkers", } as const; -export function handleConfigCommand(message: string) { +export function handleConfigCommand(message: string, playerID: string) { message = message.replace("!setattr-config", "").trim(); const args = message.split(/\s+/); const newConfig: Record = {}; @@ -83,5 +84,10 @@ export function handleConfigCommand(message: string) { } setConfig(newConfig); const configMessage = createConfigMessage(); - sendChat("ChatSetAttr", configMessage, undefined, { noarchive: true }); + sendChat( + "ChatSetAttr", + `${getWhisperPrefix(playerID)}${configMessage}`, + undefined, + { noarchive: true }, + ); }; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/feedback.ts b/ChatSetAttr/src/modules/feedback.ts index 5b506fba7a..d17deb1dfb 100644 --- a/ChatSetAttr/src/modules/feedback.ts +++ b/ChatSetAttr/src/modules/feedback.ts @@ -14,7 +14,7 @@ export function createFeedbackMessage( // _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")); + const targetValueKeys = getChangedAttributeNames(targetValues); message = message.replace("_CHARNAME_", characterName); message = message.replace(/_(NAME|TCUR|TMAX|CUR|MAX)(\d+)_/g, (_, key: string, num: string) => { @@ -23,26 +23,45 @@ export function createFeedbackMessage( if (!attributeName) return ""; - const targetCurrent = startingValues[attributeName]; - const targetMax = startingValues[`${attributeName}_max`]; - const startingCurrent = targetValues[attributeName]; - const startingMax = targetValues[`${attributeName}_max`]; + const sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; switch (key) { case "NAME": return attributeName; case "TCUR": - return `${targetCurrent}`; + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; case "TMAX": - return `${targetMax}`; - case "CUR": - return `${startingCurrent}`; - case "MAX": - return `${startingMax}`; + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } default: return ""; } }); return message; -}; \ No newline at end of file +}; + +function getChangedAttributeNames(targetValues: AttributeRecord): string[] { + const seen = new Set(); + const names: string[] = []; + + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + + return names; +} \ No newline at end of file diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 1771a22b13..768e65875a 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -210,7 +210,7 @@ export function registerHandlers() { if (!playerIsGM(msg.playerid)) { return; } - handleConfigCommand(msg.content); + handleConfigCommand(msg.content, msg.playerid); return; } const validMessage = validateMessage(msg.content); diff --git a/ChatSetAttr/src/templates/help.tsx b/ChatSetAttr/src/templates/help.tsx index b361967d6b..2de00c1b03 100644 --- a/ChatSetAttr/src/templates/help.tsx +++ b/ChatSetAttr/src/templates/help.tsx @@ -3,6 +3,7 @@ export function createHelpHandout(handoutID: string): string { const contents = [ "Basic Usage", "Available Commands", + "Beacon Computed Values", "Target Selection", "Attribute Syntax", "Modifier Options", @@ -61,45 +62,53 @@ export function createHelpHandout(handoutID: string): string {

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 --hp|0|800
+
!setattr --sel --hp|25|50 --hp_temp|8
-

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

+

This would set hp to 25, hp_max to 50, hp_temp to 8.

!modattr

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

Example:

-
!modattr --sel --hp|-5 --hp|100
+
!modattr --sel --hp_temp|-5 --hp|6
-

This subtracts 5 from hp and adds 100 to hp.

+

This subtracts 5 from hp_temp and adds 6 to hp.

!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 --hp|2500
+
!modbattr --sel --hp_temp|-5 --hp|25
-

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

+

This subtracts 5 from hp_temp but won't reduce it below 0 and increase hp 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 --hp
+
!resetattr --sel --hp
-

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

+

This resets hp to its maximum value.

!delattr

Deletes the specified attributes.

Example:

-
!delattr --sel --hp --hp
+
!delattr --sel --hp --hp_temp
-

This removes the hp and hp attributes.

+

This removes the hp and hp_temp attributes.

+ +

Beacon Computed Values

+ +

Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet existing when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.

+ +

Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.

+ +

For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with user. like user.spellpoints. They function like attributes and can be created, removed, set, reset, and modified as desired.

Target Selection

@@ -110,28 +119,28 @@ export function createHelpHandout(handoutID: string): string {

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

Example:

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

--allgm

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

Example:

-
!setattr --allgm --hp|150
+
!setattr --allgm --reset --hp

--allplayers

Affects all characters with player controllers (typically PCs).

Example:

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

--charid

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

Example:

-
!setattr --charid <ID1> <ID2> --hp|150
+
!setattr --charid &lt;ID1&gt; &lt;ID2&gt; --hp|150

--name

@@ -145,7 +154,7 @@ export function createHelpHandout(handoutID: string): string {

Affects characters represented by currently selected tokens.

Example:

-
!setattr --sel --hp|25 --hp|30
+
!setattr --sel --hp|25 --hp_temp|8

--sel-party

@@ -237,8 +246,8 @@ export function createHelpHandout(handoutID: string): string {

Replaces special characters to prevent Roll20 from evaluating them:

    -
  • < becomes [
  • -
  • > becomes ]
  • +
  • &lt; becomes [
  • +
  • &gt; becomes ]
  • ~ becomes -
  • ; becomes ?
  • ` becomes @
  • @@ -247,7 +256,7 @@ export function createHelpHandout(handoutID: string): string {

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

    Example:

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

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

    @@ -276,21 +285,21 @@ export function createHelpHandout(handoutID: string): string {

    Example:

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

    --fb-from <NAME>

    +

    --fb-from &lt;NAME&gt;

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

    Example:

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

    --fb-header <STRING>

    +

    --fb-header &lt;STRING&gt;

    Customizes the header of the output message.

    Example:

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

    --fb-content <STRING>

    +

    --fb-content &lt;STRING&gt;

    Customizes the content of the output message.

    @@ -328,7 +337,7 @@ export function createHelpHandout(handoutID: string): string {

    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}}
    +
    &&lcub;template:default&rcub; &lcub;&lcub;name=Fireball Damage&rcub;&rcub; !setattr --name @&lcub;target|character_name&rcub; --silent --hp|-&lcub;&lcub;damage=[[8d6]]&rcub;&rcub;!!! &lcub;&lcub;effect=Fire damage&rcub;&rcub;

    Using Inline Rolls in Values

    @@ -340,7 +349,7 @@ export function createHelpHandout(handoutID: string): string {

    Roll queries can determine attribute values:

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

    Repeating Section Support

    @@ -438,7 +447,7 @@ export function createHelpHandout(handoutID: string): string {

    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}}
    +
    &&lcub;template:default&rcub; &lcub;&lcub;name=Web Spell&rcub;&rcub; &lcub;&lcub;effect=Slows movement&rcub;&rcub; !setattr --name @&lcub;target|character_name&rcub; --silent --speed|-15 --status|"Restrained"!!! &lcub;&lcub;duration=1d4 rounds&rcub;&rcub;

    Party Management Examples

    From 7d162ddfb4fc5e54531c68f22fa53b7e659a178e Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 11 Jun 2026 15:57:20 -0500 Subject: [PATCH 21/38] Help doc updates --- ChatSetAttr/src/templates/help.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ChatSetAttr/src/templates/help.tsx b/ChatSetAttr/src/templates/help.tsx index 2de00c1b03..67efac49a2 100644 --- a/ChatSetAttr/src/templates/help.tsx +++ b/ChatSetAttr/src/templates/help.tsx @@ -104,12 +104,16 @@ export function createHelpHandout(handoutID: string): string {

    Beacon Computed Values

    -

    Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet existing when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.

    +

    Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.

    Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.

    For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with user. like user.spellpoints. They function like attributes and can be created, removed, set, reset, and modified as desired.

    +

    Example:

    +
    !setattr --sel --spellpoints|18
    +

    This will create the user.spellpoints User Attribute, which can be referenced as either @{selected|user.spellpoints} or @{selected|spellpoints} and operates like an attribute.

    +

    Target Selection

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

    @@ -381,6 +385,8 @@ export function createHelpHandout(handoutID: string): string {
    !delattr --sel --repeating_inventory_$0
    +

    Note: repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.

    +

    Special Value Expressions

    Attribute References

    @@ -395,6 +401,8 @@ export function createHelpHandout(handoutID: string): string {
    !setattr --sel --hp|%hp_max%
    +

    +

    Global Configuration

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

    From c6a77c871420a0169e673e72e8bd17097c399e62 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 11 Jun 2026 18:12:49 -0500 Subject: [PATCH 22/38] globalconfig support --- ChatSetAttr/2.0/ChatSetAttr.js | 92 +++++++- ChatSetAttr/ChatSetAttr.js | 92 +++++++- ChatSetAttr/script.json | 8 +- ChatSetAttr/src/__tests__/unit/config.test.ts | 218 ++++++++++++++---- ChatSetAttr/src/env.d.ts | 5 + ChatSetAttr/src/index.ts | 2 + ChatSetAttr/src/modules/config.ts | 106 ++++++++- 7 files changed, 457 insertions(+), 66 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index c1cdadbdaa..8f7ef2648f 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -224,18 +224,82 @@ var ChatSetAttr = (function (exports) { h("div", { style: CONFIG_CLEAR_FIX_STYLE })))); } + const GLOBAL_CONFIG_OPTIONS = [ + { + label: "Players can modify all characters", + key: "playersCanModify", + value: "playersCanModify", + }, + { + label: "Players can use --evaluate", + key: "playersCanEvaluate", + value: "playersCanEvaluate", + }, + { + label: "Trigger sheet workers when setting attributes", + key: "useWorkers", + value: "useWorkers", + }, + { + label: "Players can target party members", + key: "playersCanTargetParty", + value: "playersCanTargetParty", + }, + ]; const SCHEMA_VERSION = "2.0"; const DEFAULT_CONFIG = { version: SCHEMA_VERSION, globalconfigCache: { - lastsaved: 0 + lastsaved: 0, }, playersCanTargetParty: true, playersCanModify: false, playersCanEvaluate: false, useWorkers: true, - flags: [] + flags: [], }; + function parseGlobalConfigCheckbox(g, label, valueField) { + return g[label] === valueField; + } + function buildCacheSnapshot(g) { + const cache = { lastsaved: g.lastsaved ?? 0 }; + for (const option of GLOBAL_CONFIG_OPTIONS) { + cache[option.label] = `${g[option.label] ?? ""}`; + } + return cache; + } + function checkGlobalConfig() { + const g = globalconfig?.chatsetattr; + if (!g?.lastsaved) { + return []; + } + state.ChatSetAttr = state.ChatSetAttr || {}; + const cache = (state.ChatSetAttr.globalconfigCache || { lastsaved: 0 }); + if (g.lastsaved <= cache.lastsaved) { + return []; + } + const changes = []; + for (const option of GLOBAL_CONFIG_OPTIONS) { + const newRaw = `${g[option.label] ?? ""}`; + const oldRaw = `${cache[option.label] ?? ""}`; + if (newRaw === oldRaw) { + continue; + } + const newValue = parseGlobalConfigCheckbox(g, option.label, option.value); + const oldValue = getConfig()[option.key]; + if (newValue === oldValue) { + continue; + } + state.ChatSetAttr[option.key] = newValue; + changes.push(`${option.key}: ${String(oldValue)} → ${String(newValue)}`); + } + state.ChatSetAttr.globalconfigCache = buildCacheSnapshot(g); + if (changes.length > 0) { + log(`ChatSetAttr: Imported Global Config settings: ${changes.join(", ")}`); + sendNotification("ChatSetAttr Global Config", `

    New settings imported from Global Config:

      ${changes.map(change => `
    • ${change}
    • `).join("")}
    `, false); + } + return changes; + } function getConfig() { const stateConfig = state?.ChatSetAttr || {}; return { @@ -248,9 +312,6 @@ var ChatSetAttr = (function (exports) { state.ChatSetAttr = { ...stateConfig, ...newConfig, - globalconfigCache: { - lastsaved: Date.now() - } }; } function hasFlag(flag) { @@ -1204,7 +1265,7 @@ var ChatSetAttr = (function (exports) { h("code", null, "hp_temp"), " attributes."), h("h2", { id: "beacon-computed-values" }, "Beacon Computed Values"), - h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet existing when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), + h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), h("p", null, "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message."), h("p", null, "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with ", @@ -1212,6 +1273,18 @@ var ChatSetAttr = (function (exports) { " like ", h("code", null, "user.spellpoints"), ". They function like attributes and can be created, removed, set, reset, and modified as desired."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --spellpoints|18")), + h("p", null, + "This will create the ", + h("code", null, "user.spellpoints"), + " User Attribute, which can be referenced as either ", + h("code", null, "@{selected|user.spellpoints}"), + " or ", + h("code", null, "@{selected|spellpoints}"), + " and operates like an attribute."), 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"), @@ -1497,6 +1570,10 @@ var ChatSetAttr = (function (exports) { h("p", null, "Delete by index:"), h("pre", null, h("code", null, "!delattr --sel --repeating_inventory_$0")), + h("p", null, + h("em", null, + h("strong", null, "Note:"), + " repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.")), h("h2", { id: "special-value-expressions" }, "Special Value Expressions"), h("h3", null, "Attribute References"), h("p", null, @@ -1509,6 +1586,8 @@ var ChatSetAttr = (function (exports) { h("p", null, "Reset an attribute to its maximum value:"), h("pre", null, h("code", null, "!setattr --sel --hp|%hp_max%")), + h("p", null, + h("em", null)), h("h2", { id: "global-configuration" }, "Global Configuration"), h("p", null, "The script has four global configuration options that can be toggled with ", @@ -2745,6 +2824,7 @@ var ChatSetAttr = (function (exports) { } on("ready", () => { + checkGlobalConfig(); registerHandlers(); update(); welcome(); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index c1cdadbdaa..8f7ef2648f 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -224,18 +224,82 @@ var ChatSetAttr = (function (exports) { h("div", { style: CONFIG_CLEAR_FIX_STYLE })))); } + const GLOBAL_CONFIG_OPTIONS = [ + { + label: "Players can modify all characters", + key: "playersCanModify", + value: "playersCanModify", + }, + { + label: "Players can use --evaluate", + key: "playersCanEvaluate", + value: "playersCanEvaluate", + }, + { + label: "Trigger sheet workers when setting attributes", + key: "useWorkers", + value: "useWorkers", + }, + { + label: "Players can target party members", + key: "playersCanTargetParty", + value: "playersCanTargetParty", + }, + ]; const SCHEMA_VERSION = "2.0"; const DEFAULT_CONFIG = { version: SCHEMA_VERSION, globalconfigCache: { - lastsaved: 0 + lastsaved: 0, }, playersCanTargetParty: true, playersCanModify: false, playersCanEvaluate: false, useWorkers: true, - flags: [] + flags: [], }; + function parseGlobalConfigCheckbox(g, label, valueField) { + return g[label] === valueField; + } + function buildCacheSnapshot(g) { + const cache = { lastsaved: g.lastsaved ?? 0 }; + for (const option of GLOBAL_CONFIG_OPTIONS) { + cache[option.label] = `${g[option.label] ?? ""}`; + } + return cache; + } + function checkGlobalConfig() { + const g = globalconfig?.chatsetattr; + if (!g?.lastsaved) { + return []; + } + state.ChatSetAttr = state.ChatSetAttr || {}; + const cache = (state.ChatSetAttr.globalconfigCache || { lastsaved: 0 }); + if (g.lastsaved <= cache.lastsaved) { + return []; + } + const changes = []; + for (const option of GLOBAL_CONFIG_OPTIONS) { + const newRaw = `${g[option.label] ?? ""}`; + const oldRaw = `${cache[option.label] ?? ""}`; + if (newRaw === oldRaw) { + continue; + } + const newValue = parseGlobalConfigCheckbox(g, option.label, option.value); + const oldValue = getConfig()[option.key]; + if (newValue === oldValue) { + continue; + } + state.ChatSetAttr[option.key] = newValue; + changes.push(`${option.key}: ${String(oldValue)} → ${String(newValue)}`); + } + state.ChatSetAttr.globalconfigCache = buildCacheSnapshot(g); + if (changes.length > 0) { + log(`ChatSetAttr: Imported Global Config settings: ${changes.join(", ")}`); + sendNotification("ChatSetAttr Global Config", `

    New settings imported from Global Config:

      ${changes.map(change => `
    • ${change}
    • `).join("")}
    `, false); + } + return changes; + } function getConfig() { const stateConfig = state?.ChatSetAttr || {}; return { @@ -248,9 +312,6 @@ var ChatSetAttr = (function (exports) { state.ChatSetAttr = { ...stateConfig, ...newConfig, - globalconfigCache: { - lastsaved: Date.now() - } }; } function hasFlag(flag) { @@ -1204,7 +1265,7 @@ var ChatSetAttr = (function (exports) { h("code", null, "hp_temp"), " attributes."), h("h2", { id: "beacon-computed-values" }, "Beacon Computed Values"), - h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet existing when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), + h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), h("p", null, "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message."), h("p", null, "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with ", @@ -1212,6 +1273,18 @@ var ChatSetAttr = (function (exports) { " like ", h("code", null, "user.spellpoints"), ". They function like attributes and can be created, removed, set, reset, and modified as desired."), + h("p", null, + h("strong", null, "Example:")), + h("pre", null, + h("code", null, "!setattr --sel --spellpoints|18")), + h("p", null, + "This will create the ", + h("code", null, "user.spellpoints"), + " User Attribute, which can be referenced as either ", + h("code", null, "@{selected|user.spellpoints}"), + " or ", + h("code", null, "@{selected|spellpoints}"), + " and operates like an attribute."), 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"), @@ -1497,6 +1570,10 @@ var ChatSetAttr = (function (exports) { h("p", null, "Delete by index:"), h("pre", null, h("code", null, "!delattr --sel --repeating_inventory_$0")), + h("p", null, + h("em", null, + h("strong", null, "Note:"), + " repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.")), h("h2", { id: "special-value-expressions" }, "Special Value Expressions"), h("h3", null, "Attribute References"), h("p", null, @@ -1509,6 +1586,8 @@ var ChatSetAttr = (function (exports) { h("p", null, "Reset an attribute to its maximum value:"), h("pre", null, h("code", null, "!setattr --sel --hp|%hp_max%")), + h("p", null, + h("em", null)), h("h2", { id: "global-configuration" }, "Global Configuration"), h("p", null, "The script has four global configuration options that can be toggled with ", @@ -2745,6 +2824,7 @@ var ChatSetAttr = (function (exports) { } on("ready", () => { + checkGlobalConfig(); registerHandlers(); update(); welcome(); diff --git a/ChatSetAttr/script.json b/ChatSetAttr/script.json index 921407cd82..60bee52410 100644 --- a/ChatSetAttr/script.json +++ b/ChatSetAttr/script.json @@ -2,7 +2,7 @@ "name": "ChatSetAttr", "script": "ChatSetAttr.js", "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).", + "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 four global configuration options, _playersCanModify_, _playersCanEvaluate_, _useWorkers_, and _playersCanTargetParty_, which can be toggled either on this page or by entering **!setattr-config** in chat. The first 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 _useWorkers_ option will determine if the script triggers sheet workers on use, and should normally be toggled on. The _playersCanTargetParty_ option allows players to use the **--party** target option.\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", "GUD Team" @@ -27,6 +27,12 @@ "description": "Select this option to have ChatSetAttr trigger sheet workers when setting attributes.", "value": "useWorkers", "checked": "checked" + }, + { + "name": "Players can target party members", + "type": "checkbox", + "description": "Select this option to allow players to use the --party target option.", + "value": "playersCanTargetParty" } ], "dependencies": [ diff --git a/ChatSetAttr/src/__tests__/unit/config.test.ts b/ChatSetAttr/src/__tests__/unit/config.test.ts index 1109da6bc0..169152d419 100644 --- a/ChatSetAttr/src/__tests__/unit/config.test.ts +++ b/ChatSetAttr/src/__tests__/unit/config.test.ts @@ -1,11 +1,30 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { getConfig, setConfig } from "../../modules/config"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { checkGlobalConfig, getConfig, setConfig } from "../../modules/config"; + +const GLOBAL_CONFIG_LABELS = { + playersCanModify: "Players can modify all characters", + playersCanEvaluate: "Players can use --evaluate", + useWorkers: "Trigger sheet workers when setting attributes", + playersCanTargetParty: "Players can target party members", +} as const; + +function buildGlobalConfig( + options: Partial>, + lastsaved = 2000, +) { + const chatsetattr: Record = { lastsaved }; + for (const [key, label] of Object.entries(GLOBAL_CONFIG_LABELS)) { + chatsetattr[label] = options[key as keyof typeof GLOBAL_CONFIG_LABELS] ? key : ""; + } + return { chatsetattr }; +} describe("config", () => { beforeEach(() => { - // Reset state before each test + vi.clearAllMocks(); + global.globalconfig = {}; global.state = { - ChatSetAttr: {} + ChatSetAttr: {}, }; }); @@ -166,29 +185,25 @@ describe("config", () => { 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"); + expect(global.state.ChatSetAttr.globalconfigCache).toBeUndefined(); }); 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"); + expect(global.state.ChatSetAttr.globalconfigCache).toBeUndefined(); }); it("should merge new config with existing config", () => { global.state.ChatSetAttr = { playersCanModify: true, - version: 2 + version: 2, }; setConfig({ playersCanEvaluate: true }); @@ -196,47 +211,37 @@ describe("config", () => { 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 + useWorkers: true, }; setConfig({ playersCanModify: true, - useWorkers: false + 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", () => { + it("should not modify globalconfigCache unless explicitly provided", () => { global.state.ChatSetAttr = { globalconfigCache: { - lastsaved: 1000 - } + lastsaved: 1000, + }, }; - setConfig({ - globalconfigCache: { - lastsaved: 2000, - newProperty: "test" - } - }); + setConfig({ playersCanModify: true }); - // 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); + expect(global.state.ChatSetAttr.globalconfigCache).toEqual({ + lastsaved: 1000, + }); }); it("should preserve other ChatSetAttr properties", () => { @@ -253,40 +258,39 @@ describe("config", () => { 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 + playersCanModify: true, + globalconfigCache: { + lastsaved: 500, + }, }; setConfig({}); expect(global.state.ChatSetAttr.playersCanModify).toBe(true); - expect(global.state.ChatSetAttr.globalconfigCache).toBeDefined(); - expect(typeof global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe("number"); + expect(global.state.ChatSetAttr.globalconfigCache).toEqual({ lastsaved: 500 }); }); it("should handle null and undefined values", () => { global.state.ChatSetAttr = { - playersCanModify: true + playersCanModify: true, }; setConfig({ playersCanEvaluate: null, useWorkers: undefined, - version: 0 + 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", () => { @@ -297,7 +301,7 @@ describe("config", () => { arrayProp: [1, 2, 3], objectProp: { nested: "value" }, nullProp: null, - undefinedProp: undefined + undefinedProp: undefined, }); expect(global.state.ChatSetAttr.stringProp).toBe("test"); @@ -307,8 +311,137 @@ describe("config", () => { 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("checkGlobalConfig", () => { + it("should do nothing when globalconfig is missing", () => { + global.globalconfig = {}; + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([]); + expect(global.sendChat).not.toHaveBeenCalled(); + }); + + it("should do nothing when globalconfig timestamp is stale", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 2000, + [GLOBAL_CONFIG_LABELS.useWorkers]: "useWorkers", + }, + playersCanModify: true, + }; + global.globalconfig = buildGlobalConfig({ useWorkers: true }, 1000); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.sendChat).not.toHaveBeenCalled(); + }); + + it("should update cache but not runtime when script is re-saved without option changes", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000, + [GLOBAL_CONFIG_LABELS.playersCanModify]: "", + [GLOBAL_CONFIG_LABELS.playersCanEvaluate]: "", + [GLOBAL_CONFIG_LABELS.useWorkers]: "useWorkers", + [GLOBAL_CONFIG_LABELS.playersCanTargetParty]: "", + }, + playersCanModify: true, + }; + global.globalconfig = buildGlobalConfig({ useWorkers: true }, 2000); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(2000); + expect(global.sendChat).not.toHaveBeenCalled(); + }); + + it("should import changed page values and notify the GM", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000, + [GLOBAL_CONFIG_LABELS.playersCanModify]: "", + [GLOBAL_CONFIG_LABELS.playersCanEvaluate]: "", + [GLOBAL_CONFIG_LABELS.useWorkers]: "useWorkers", + [GLOBAL_CONFIG_LABELS.playersCanTargetParty]: "", + }, + }; + global.globalconfig = buildGlobalConfig( + { playersCanModify: true, useWorkers: true }, + 2000, + ); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual(["playersCanModify: false → true"]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(2000); + expect(global.sendChat).toHaveBeenCalledTimes(1); + expect(vi.mocked(global.sendChat).mock.calls[0][1]).toContain("/w gm "); + expect(vi.mocked(global.sendChat).mock.calls[0][1]).toContain("playersCanModify: false → true"); + }); + + it("should import all changed options on first load", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 0, + }, + }; + global.globalconfig = buildGlobalConfig( + { + playersCanModify: true, + playersCanEvaluate: true, + useWorkers: true, + playersCanTargetParty: true, + }, + 1500, + ); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual([ + "playersCanModify: false → true", + "playersCanEvaluate: false → true", + ]); + expect(global.state.ChatSetAttr.playersCanModify).toBe(true); + expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(getConfig().playersCanTargetParty).toBe(true); + expect(getConfig().useWorkers).toBe(true); + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(1500); + }); + + it("should import the fourth globalconfig option", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1000, + [GLOBAL_CONFIG_LABELS.playersCanTargetParty]: "", + }, + playersCanTargetParty: false, + }; + global.globalconfig = buildGlobalConfig({ playersCanTargetParty: true }, 2000); + + const changes = checkGlobalConfig(); + + expect(changes).toEqual(["playersCanTargetParty: false → true"]); + expect(global.state.ChatSetAttr.playersCanTargetParty).toBe(true); + }); + + it("should not modify globalconfigCache.lastsaved when using setConfig", () => { + global.state.ChatSetAttr = { + globalconfigCache: { + lastsaved: 1234, + }, + }; + + setConfig({ playersCanModify: true }); + + expect(global.state.ChatSetAttr.globalconfigCache.lastsaved).toBe(1234); }); }); @@ -332,8 +465,7 @@ describe("config", () => { 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"); + expect(global.state.ChatSetAttr.globalconfigCache).toBeUndefined(); }); it("should handle multiple setConfig calls", () => { diff --git a/ChatSetAttr/src/env.d.ts b/ChatSetAttr/src/env.d.ts index a1da5a1ee1..8e3a3172a4 100644 --- a/ChatSetAttr/src/env.d.ts +++ b/ChatSetAttr/src/env.d.ts @@ -13,6 +13,11 @@ declare global { ChatSetAttr?: Record & { version?: string }; [key: string]: unknown; }; + /** Roll20 One-Click script page configuration. */ + var globalconfig: { + chatsetattr?: Record & { lastsaved?: number }; + [key: string]: unknown; + }; function h( tagName: string, diff --git a/ChatSetAttr/src/index.ts b/ChatSetAttr/src/index.ts index ef0cc04a33..fe3b20388e 100644 --- a/ChatSetAttr/src/index.ts +++ b/ChatSetAttr/src/index.ts @@ -1,8 +1,10 @@ import { registerHandlers } from "./modules/main"; +import { checkGlobalConfig } from "./modules/config"; import { update, welcome } from "./modules/versioning"; import "./utils/chat"; on("ready", () => { + checkGlobalConfig(); registerHandlers(); update(); welcome(); diff --git a/ChatSetAttr/src/modules/config.ts b/ChatSetAttr/src/modules/config.ts index 8c3dbdb213..9f7951fb43 100644 --- a/ChatSetAttr/src/modules/config.ts +++ b/ChatSetAttr/src/modules/config.ts @@ -1,11 +1,9 @@ -import { getWhisperPrefix } from "./chat"; +import { getWhisperPrefix, sendNotification } from "./chat"; import { createConfigMessage } from "../templates/config"; type ScriptConfig = { version: number | string; - globalconfigCache: { - lastsaved: number; - }; + globalconfigCache: GlobalConfigCache; playersCanTargetParty: boolean; playersCanModify: boolean; playersCanEvaluate: boolean; @@ -13,18 +11,109 @@ type ScriptConfig = { flags: string[]; }; +type GlobalConfigCache = Record & { + lastsaved: number; +}; + +type GlobalConfigRecord = Record & { + lastsaved?: number; +}; + +export const GLOBAL_CONFIG_OPTIONS = [ + { + label: "Players can modify all characters", + key: "playersCanModify", + value: "playersCanModify", + }, + { + label: "Players can use --evaluate", + key: "playersCanEvaluate", + value: "playersCanEvaluate", + }, + { + label: "Trigger sheet workers when setting attributes", + key: "useWorkers", + value: "useWorkers", + }, + { + label: "Players can target party members", + key: "playersCanTargetParty", + value: "playersCanTargetParty", + }, +] as const; + const SCHEMA_VERSION = "2.0"; const DEFAULT_CONFIG: ScriptConfig = { version: SCHEMA_VERSION, globalconfigCache: { - lastsaved: 0 + lastsaved: 0, }, playersCanTargetParty: true, playersCanModify: false, playersCanEvaluate: false, useWorkers: true, - flags: [] + flags: [], +}; + +export function parseGlobalConfigCheckbox( + g: GlobalConfigRecord, + label: string, + valueField: string, +): boolean { + return g[label] === valueField; +}; + +function buildCacheSnapshot(g: GlobalConfigRecord): GlobalConfigCache { + const cache: GlobalConfigCache = { lastsaved: g.lastsaved ?? 0 }; + for (const option of GLOBAL_CONFIG_OPTIONS) { + cache[option.label] = `${g[option.label] ?? ""}`; + } + return cache; +}; + +export function checkGlobalConfig(): string[] { + const g = globalconfig?.chatsetattr as GlobalConfigRecord | undefined; + if (!g?.lastsaved) { + return []; + } + + state.ChatSetAttr = state.ChatSetAttr || {}; + const cache = (state.ChatSetAttr.globalconfigCache || { lastsaved: 0 }) as GlobalConfigCache; + if (g.lastsaved <= cache.lastsaved) { + return []; + } + + const changes: string[] = []; + for (const option of GLOBAL_CONFIG_OPTIONS) { + const newRaw = `${g[option.label] ?? ""}`; + const oldRaw = `${cache[option.label] ?? ""}`; + if (newRaw === oldRaw) { + continue; + } + + const newValue = parseGlobalConfigCheckbox(g, option.label, option.value); + const oldValue = getConfig()[option.key]; + if (newValue === oldValue) { + continue; + } + + state.ChatSetAttr[option.key] = newValue; + changes.push(`${option.key}: ${String(oldValue)} → ${String(newValue)}`); + } + + state.ChatSetAttr.globalconfigCache = buildCacheSnapshot(g); + + if (changes.length > 0) { + log(`ChatSetAttr: Imported Global Config settings: ${changes.join(", ")}`); + sendNotification( + "ChatSetAttr Global Config", + `

    New settings imported from Global Config:

      ${changes.map(change => `
    • ${change}
    • `).join("")}
    `, + false, + ); + } + + return changes; }; export function getConfig() { @@ -40,9 +129,6 @@ export function setConfig(newConfig: Record) { state.ChatSetAttr = { ...stateConfig, ...newConfig, - globalconfigCache: { - lastsaved: Date.now() - } }; }; @@ -90,4 +176,4 @@ export function handleConfigCommand(message: string, playerID: string) { undefined, { noarchive: true }, ); -}; \ No newline at end of file +}; From b196d2e640c249ab424c2c83804b92587beef246 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Thu, 11 Jun 2026 19:36:56 -0500 Subject: [PATCH 23/38] globalconfig support --- ChatSetAttr/vitest.setup.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ChatSetAttr/vitest.setup.ts b/ChatSetAttr/vitest.setup.ts index 4767ce0ced..de7c83a0fe 100644 --- a/ChatSetAttr/vitest.setup.ts +++ b/ChatSetAttr/vitest.setup.ts @@ -37,6 +37,9 @@ global.state = { } }; +// region Global Config +global.globalconfig = {}; + // region Objects global.getObj = vi.fn(mockGetObj); // eslint-disable-next-line @typescript-eslint/no-explicit-any From 9d92e8d2a8fa13af5d192cfee4e0e4a720b0ec7d Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 12 Jun 2026 09:05:32 -0500 Subject: [PATCH 24/38] Document generation pipeline. --- ChatSetAttr/2.0/ChatSetAttr.js | 1848 ++++++++++++----- ChatSetAttr/ChatSetAttr.js | 1848 ++++++++++++----- ChatSetAttr/README.md | 175 +- ChatSetAttr/docs/help/README.md | 75 + ChatSetAttr/docs/help/content.json | 1179 +++++++++++ ChatSetAttr/docs/help/content.schema.json | 132 ++ ChatSetAttr/package.json | 3 + ChatSetAttr/script.json | 4 +- ChatSetAttr/scripts/generate-docs.ts | 77 + .../src/__tests__/templates/messages.test.ts | 8 +- .../src/__tests__/unit/helpContent.test.ts | 116 ++ ChatSetAttr/src/__tests__/utils/chat.test.ts | 90 +- ChatSetAttr/src/env.d.ts | 6 +- ChatSetAttr/src/modules/help.ts | 2 +- ChatSetAttr/src/templates/config.tsx | 2 +- ChatSetAttr/src/templates/delay.tsx | 2 +- ChatSetAttr/src/templates/help.tsx | 494 ----- ChatSetAttr/src/templates/help/index.ts | 6 + .../src/templates/help/inlineMarkdown.ts | 45 + ChatSetAttr/src/templates/help/loadContent.ts | 6 + ChatSetAttr/src/templates/help/renderHtml.ts | 99 + .../src/templates/help/renderMarkdown.ts | 91 + ChatSetAttr/src/templates/help/types.ts | 63 + ChatSetAttr/src/templates/messages.tsx | 2 +- ChatSetAttr/src/templates/notification.tsx | 2 +- ChatSetAttr/src/templates/versions/2.0.0.tsx | 4 +- ChatSetAttr/src/templates/welcome.tsx | 2 +- ChatSetAttr/src/utils/chat.ts | 39 +- ChatSetAttr/tsconfig.json | 1 + ChatSetAttr/tsconfig.script.json | 3 +- ChatSetAttr/tsconfig.vitest.json | 1 + 31 files changed, 4710 insertions(+), 1715 deletions(-) create mode 100644 ChatSetAttr/docs/help/README.md create mode 100644 ChatSetAttr/docs/help/content.json create mode 100644 ChatSetAttr/docs/help/content.schema.json create mode 100644 ChatSetAttr/scripts/generate-docs.ts create mode 100644 ChatSetAttr/src/__tests__/unit/helpContent.test.ts delete mode 100644 ChatSetAttr/src/templates/help.tsx create mode 100644 ChatSetAttr/src/templates/help/index.ts create mode 100644 ChatSetAttr/src/templates/help/inlineMarkdown.ts create mode 100644 ChatSetAttr/src/templates/help/loadContent.ts create mode 100644 ChatSetAttr/src/templates/help/renderHtml.ts create mode 100644 ChatSetAttr/src/templates/help/renderMarkdown.ts create mode 100644 ChatSetAttr/src/templates/help/types.ts diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 8f7ef2648f..849c20fcf4 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -25,14 +25,35 @@ var ChatSetAttr = (function (exports) { } return style; } + function escapeHtml$1(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + class SafeHtml { + html; + constructor(html) { + this.html = html; + } + } + function renderChild(child) { + if (child instanceof SafeHtml) { + return child.html; + } + if (typeof child === "string") { + return escapeHtml$1(child); + } + return ""; + } function h(tagName, attributes = {}, ...children) { const attrs = Object.entries(attributes ?? {}) - .map(([key, value]) => ` ${key}="${value}"`) + .map(([key, value]) => ` ${key}="${escapeHtml$1(String(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 childrenContent = flattenedChildren.map(renderChild).join(""); + return new SafeHtml(`<${tagName}${attrs}>${childrenContent}`); } const buttonStyleBase = { @@ -64,7 +85,7 @@ var ChatSetAttr = (function (exports) { function createDelayMessage() { return (h("div", { style: DELAY_WRAPPER_STYLE }, h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), - h("div", null, "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."))); + h("div", null, "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."))).html; } const CHAT_WRAPPER_STYLE = s(frameStyleBase); @@ -86,7 +107,7 @@ var ChatSetAttr = (function (exports) { 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))))); + h("div", { style: styles.body }, messages.map(message => h("p", null, message))))).html; } // #region Chat Message Function function createChatMessage(header, messages) { @@ -110,7 +131,7 @@ var ChatSetAttr = (function (exports) { function createNotifyMessage(title, content) { return (h("div", { style: NOTIFY_WRAPPER_STYLE }, h("div", { style: NOTIFY_HEADER_STYLE }, title), - h("div", null, content))); + h("div", null, content))).html; } function createWelcomeMessage() { @@ -126,7 +147,7 @@ var ChatSetAttr = (function (exports) { h("code", null, "!setattr-help"), " command or click the button below:"), h("p", null, - h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); + h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))).html; } function getWhisperPrefix(playerID) { @@ -221,7 +242,7 @@ var ChatSetAttr = (function (exports) { ":")), 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 })))); + h("div", { style: CONFIG_CLEAR_FIX_STYLE })))).html; } const GLOBAL_CONFIG_OPTIONS = [ @@ -1135,539 +1156,1282 @@ var ChatSetAttr = (function (exports) { return Math.max(Math.min(currentValue, maxValue), 0); } - function createHelpHandout(handoutID) { - const contents = [ - "Basic Usage", - "Available Commands", - "Beacon Computed Values", - "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)))))); + var $schema = "./content.schema.json"; + var title = "ChatSetAttr"; + var introduction = "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."; + var sections = [ + { + id: "basic-usage", + title: "Basic Usage", + blocks: [ + { + type: "paragraph", + text: "The script provides several command formats:" + }, + { + type: "unorderedList", + items: [ + "`!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" + ] + }, + { + type: "paragraph", + text: "Each command requires a target selection option and one or more attributes to modify." + }, + { + type: "paragraph", + text: "**Basic structure:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2" + ] + } + ] + }, + { + id: "available-commands", + title: "Available Commands", + subsections: [ + { + id: "setattr", + title: "!setattr", + blocks: [ + { + type: "paragraph", + text: "Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25|50 --hp_temp|8" + ] + }, + { + type: "paragraph", + text: "This would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8." + } + ] + }, + { + id: "modattr", + title: "!modattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --hp_temp|-5 --hp|6" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` and adds 6 to `hp`." + } + ] + }, + { + id: "modbattr", + title: "!modbattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modbattr --sel --hp_temp|-5 --hp|25" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`." + } + ] + }, + { + id: "resetattr", + title: "!resetattr", + blocks: [ + { + type: "paragraph", + text: "Resets attributes to their maximum value. Shorthand for `!setattr --reset`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --sel --hp" + ] + }, + { + type: "paragraph", + text: "This resets `hp` to its maximum value." + } + ] + }, + { + id: "delattr", + title: "!delattr", + blocks: [ + { + type: "paragraph", + text: "Deletes the specified attributes." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --hp --hp_temp" + ] + }, + { + type: "paragraph", + text: "This removes the `hp` and `hp_temp` attributes." + } + ] + } + ] + }, + { + id: "beacon-computed-values", + title: "Beacon Computed Values", + blocks: [ + { + type: "paragraph", + text: "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try." + }, + { + type: "paragraph", + text: "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message." + }, + { + type: "paragraph", + text: "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --spellpoints|18" + ] + }, + { + type: "paragraph", + text: "This will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute." + } + ] + }, + { + id: "target-selection", + title: "Target Selection", + blocks: [ + { + type: "paragraph", + text: "One of these options must be specified to determine which characters will be affected:" + } + ], + subsections: [ + { + id: "all", + title: "--all", + blocks: [ + { + type: "paragraph", + text: "Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --all --hp" + ] + } + ] + }, + { + id: "allgm", + title: "--allgm", + blocks: [ + { + type: "paragraph", + text: "Affects all characters without player controllers (typically NPCs). **GM only**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allgm --reset --hp" + ] + } + ] + }, + { + id: "allplayers", + title: "--allplayers", + blocks: [ + { + type: "paragraph", + text: "Affects all characters with player controllers (typically PCs)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allplayers --mod --hp|-15" + ] + } + ] + }, + { + id: "charid", + title: "--charid", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --charid --hp|150" + ] + } + ] + }, + { + id: "name", + title: "--name", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified names. Non-GM players can only affect characters they control." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"" + ] + } + ] + }, + { + id: "sel", + title: "--sel", + blocks: [ + { + type: "paragraph", + text: "Affects characters represented by currently selected tokens." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25 --hp_temp|8" + ] + } + ] + }, + { + id: "sel-party", + title: "--sel-party", + blocks: [ + { + type: "paragraph", + text: "Affects only party characters represented by currently selected tokens (characters with `inParty` set to true)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --inspiration|1" + ] + } + ] + }, + { + id: "sel-noparty", + title: "--sel-noparty", + blocks: [ + { + type: "paragraph", + text: "Affects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --npc_status|\"Hostile\"" + ] + } + ] + }, + { + id: "party", + title: "--party", + blocks: [ + { + type: "paragraph", + text: "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." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --rest_complete|1" + ] + } + ] + } + ] + }, + { + id: "attribute-syntax", + title: "Attribute Syntax", + blocks: [ + { + type: "paragraph", + text: "The syntax for specifying attributes is:" + }, + { + type: "codeBlock", + lines: [ + "--attributeName|currentValue|maxValue" + ] + }, + { + type: "unorderedList", + items: [ + "`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)" + ] + } + ], + subsections: [ + { + id: "examples", + title: "Examples:", + blocks: [ + { + type: "orderedList", + items: [ + { + text: "Set current value only:", + codeBlock: { + lines: [ + "--strength|15" + ] + } + }, + { + text: "Set both current and maximum values:", + codeBlock: { + lines: [ + "--hp|27|35" + ] + } + }, + { + text: "Set only the maximum value (leave current unchanged):", + codeBlock: { + lines: [ + "--hp||50" + ] + } + }, + { + text: "Create empty attribute or set to empty:", + codeBlock: { + lines: [ + "--notes|" + ] + } + }, + { + text: "Use `#` instead of `|` (useful in roll queries):", + codeBlock: { + lines: [ + "--strength#15" + ] + } + } + ] + } + ] + } + ] + }, + { + id: "modifier-options", + title: "Modifier Options", + blocks: [ + { + type: "paragraph", + text: "These options change how attributes are processed:" + } + ], + subsections: [ + { + id: "mod", + title: "--mod", + blocks: [ + { + type: "paragraph", + text: "See `!modattr` command." + } + ] + }, + { + id: "modb", + title: "--modb", + blocks: [ + { + type: "paragraph", + text: "See `!modbattr` command." + } + ] + }, + { + id: "reset", + title: "--reset", + blocks: [ + { + type: "paragraph", + text: "See `!resetattr` command." + } + ] + }, + { + id: "nocreate", + title: "--nocreate", + blocks: [ + { + type: "paragraph", + text: "Prevents creation of new attributes, only updates existing ones." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --nocreate --perception|20 --hp|15" + ] + }, + { + type: "paragraph", + text: "This will only update `perception` or `hp` if it already exists." + } + ] + }, + { + id: "evaluate", + title: "--evaluate", + blocks: [ + { + type: "paragraph", + text: "Evaluates JavaScript expressions in attribute values. **GM only by default**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --hp|2 * 3" + ] + }, + { + type: "paragraph", + text: "This will set the `hp` attribute to 6." + } + ] + }, + { + id: "replace", + title: "--replace", + blocks: [ + { + type: "paragraph", + text: "Replaces special characters to prevent Roll20 from evaluating them:" + }, + { + type: "unorderedList", + items: [ + "< becomes [", + "> becomes ]", + "~ becomes -", + "; becomes ?", + "` becomes @" + ] + }, + { + type: "paragraph", + text: "Also supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"" + ] + }, + { + type: "paragraph", + text: "This stores \"Roll [[1d6]] to succeed\" without evaluating the roll." + } + ] + } + ] + }, + { + id: "output-control-options", + title: "Output Control Options", + blocks: [ + { + type: "paragraph", + text: "These options control the feedback messages generated by the script:" + } + ], + subsections: [ + { + id: "silent", + title: "--silent", + blocks: [ + { + type: "paragraph", + text: "Suppresses normal output messages (error messages will still appear)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --silent --stealth|20" + ] + } + ] + }, + { + id: "mute", + title: "--mute", + blocks: [ + { + type: "paragraph", + text: "Suppresses all output messages, including errors." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --mute --nocreate --new_value|42" + ] + } + ] + }, + { + id: "fb-public", + title: "--fb-public", + blocks: [ + { + type: "paragraph", + text: "Sends output publicly to the chat instead of whispering to the command sender." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"" + ] + } + ] + }, + { + id: "fb-from", + title: "--fb-from ", + blocks: [ + { + type: "paragraph", + text: "Changes the name of the sender for output messages (default is \"ChatSetAttr\")." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-from \"Healing Potion\" --hp|25" + ] + } + ] + }, + { + id: "fb-header", + title: "--fb-header ", + blocks: [ + { + type: "paragraph", + text: "Customizes the header of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5" + ] + } + ] + }, + { + id: "fb-content", + title: "--fb-content ", + blocks: [ + { + type: "paragraph", + text: "Customizes the content of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10" + ] + } + ] + }, + { + id: "special-placeholders", + title: "Special Placeholders", + blocks: [ + { + type: "paragraph", + text: "For use in `--fb-header` and `--fb-content`:" + }, + { + type: "unorderedList", + items: [ + "`_NAMEJ_` - Name of the Jth attribute being changed", + "`_TCURJ_` - Target current value of the Jth attribute", + "`_TMAXJ_` - Target maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "For use in `--fb-content` only:" + }, + { + type: "unorderedList", + items: [ + "`_CHARNAME_` - Name of the character", + "`_CURJ_` - Final current value of the Jth attribute", + "`_MAXJ_` - Final maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "**Important:** The Jth index starts with 0 at the first item." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10" + ] + } + ] + } + ] + }, + { + id: "inline-roll-integration", + title: "Inline Roll Integration", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr can be used within roll templates or combined with inline rolls:" + } + ], + subsections: [ + { + id: "within-roll-templates", + title: "Within Roll Templates", + blocks: [ + { + type: "paragraph", + text: "Place the command between roll template properties and end it with `!!!`:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + ] + } + ] + }, + { + id: "using-inline-rolls-in-values", + title: "Using Inline Rolls in Values", + blocks: [ + { + type: "paragraph", + text: "Inline rolls can be used for attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|[[2d6+5]]" + ] + } + ] + }, + { + id: "roll-queries", + title: "Roll Queries", + blocks: [ + { + type: "paragraph", + text: "Roll queries can determine attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|?{Set strength to what value?|100}" + ] + } + ] + } + ] + }, + { + id: "repeating-section-support", + title: "Repeating Section Support", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr supports working with repeating sections:" + } + ], + subsections: [ + { + id: "creating-new-repeating-items", + title: "Creating New Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Use `CREATE` to create a new row in a repeating section:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2" + ] + } + ] + }, + { + id: "modifying-existing-repeating-items", + title: "Modifying Existing Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Access by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"" + ] + }, + { + type: "paragraph", + text: "Access by index (starts at 0):" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"" + ] + } + ] + }, + { + id: "deleting-repeating-rows", + title: "Deleting Repeating Rows", + blocks: [ + { + type: "paragraph", + text: "Delete by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_ID" + ] + }, + { + type: "paragraph", + text: "Delete by index:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_$0" + ] + }, + { + type: "note", + text: "repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.", + emphasis: true + } + ] + } + ] + }, + { + id: "special-value-expressions", + title: "Special Value Expressions", + subsections: [ + { + id: "attribute-references", + title: "Attribute References", + blocks: [ + { + type: "paragraph", + text: "Reference other attribute values using `%attribute_name%`:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --temp_hp|%hp% / 2" + ] + } + ] + }, + { + id: "resetting-to-maximum", + title: "Resetting to Maximum", + blocks: [ + { + type: "paragraph", + text: "Reset an attribute to its maximum value:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|%hp_max%" + ] + } + ] + } + ] + }, + { + id: "global-configuration", + title: "Global Configuration", + blocks: [ + { + type: "paragraph", + text: "The script has four global configuration options that can be toggled with `!setattr-config`:" + } + ], + subsections: [ + { + id: "players-can-modify", + title: "--players-can-modify", + blocks: [ + { + type: "paragraph", + text: "Allows players to modify attributes on characters they don't control." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-modify" + ] + } + ] + }, + { + id: "players-can-evaluate", + title: "--players-can-evaluate", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--evaluate` option." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-evaluate" + ] + } + ] + }, + { + id: "players-can-target-party", + title: "--players-can-target-party", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--party` target option. **GM only by default**." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-target-party" + ] + } + ] + }, + { + id: "use-workers", + title: "--use-workers", + blocks: [ + { + type: "paragraph", + text: "Toggles whether the script triggers sheet workers when setting attributes." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --use-workers" + ] + } + ] + } + ] + }, + { + id: "complete-examples", + title: "Complete Examples", + subsections: [ + { + id: "basic-combat-example", + title: "Basic Combat Example", + blocks: [ + { + type: "paragraph", + text: "Reduce a character's HP and status after taking damage:" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"" + ] + } + ] + }, + { + id: "leveling-up-a-character", + title: "Leveling Up a Character", + blocks: [ + { + type: "paragraph", + text: "Update multiple stats when a character gains a level:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public" + ] + } + ] + }, + { + id: "create-new-item-in-inventory", + title: "Create New Item in Inventory", + blocks: [ + { + type: "paragraph", + text: "Add a new item to a character's inventory:" + }, + { + type: "codeBlock", + lines: [ + "!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\"" + ] + } + ] + }, + { + id: "apply-status-effects-during-combat", + title: "Apply Status Effects During Combat", + blocks: [ + { + type: "paragraph", + text: "Apply a debuff to selected enemies in the middle of combat:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}" + ] + } + ] + }, + { + id: "party-management-examples", + title: "Party Management Examples", + blocks: [ + { + type: "paragraph", + text: "Give inspiration to all party members after a great roleplay moment:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"" + ] + }, + { + type: "paragraph", + text: "Apply a long rest to only party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"" + ] + }, + { + type: "paragraph", + text: "Set hostile status for non-party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"" + ] + } + ] + } + ] + }, + { + id: "for-developers", + title: "For Developers", + subsections: [ + { + id: "registering-observers", + title: "Registering Observers", + blocks: [ + { + type: "paragraph", + text: "If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:" + }, + { + type: "codeBlock", + lines: [ + "ChatSetAttr.registerObserver(event, observer);" + ] + }, + { + type: "paragraph", + text: "Where `event` is one of:" + }, + { + type: "unorderedList", + items: [ + "`\"add\"` - Called when attributes are created", + "`\"change\"` - Called when attributes are modified", + "`\"destroy\"` - Called when attributes are deleted" + ] + }, + { + type: "paragraph", + text: "And `observer` is an event handler function similar to Roll20's built-in event handlers." + }, + { + type: "paragraph", + text: "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." + } + ] + } + ] + } + ]; + var helpContent = { + $schema: $schema, + title: title, + introduction: introduction, + sections: sections + }; + + function loadHelpDocument() { + return helpContent; + } + + const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`)/g; + function renderInlineHtml(text) { + const parts = []; + let lastIndex = 0; + let match; + INLINE_PATTERN.lastIndex = 0; + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, match.index))); + } + const token = match[0]; + if (token.startsWith("**")) { + parts.push(`${escapeHtml(token.slice(2, -2))}`); + } + else { + parts.push(`${escapeHtml(token.slice(1, -1))}`); + } + lastIndex = match.index + token.length; } - 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 --hp_temp|8")), - h("p", null, - "This would set ", - h("code", null, "hp"), - " to 25, ", - h("code", null, "hp_max"), - " to 50, ", - h("code", null, "hp_temp"), - " to 8."), - 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_temp|-5 --hp|6")), - h("p", null, - "This subtracts 5 from ", - h("code", null, "hp_temp"), - " and adds 6 to ", - h("code", null, "hp"), - "."), - 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_temp|-5 --hp|25")), - h("p", null, - "This subtracts 5 from ", - h("code", null, "hp_temp"), - " but won't reduce it below 0 and increase ", - h("code", null, "hp"), - " 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")), - h("p", null, - "This resets ", - h("code", null, "hp"), - " to its maximum value."), - 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 --hp_temp")), - h("p", null, - "This removes the ", - h("code", null, "hp"), - " and ", - h("code", null, "hp_temp"), - " attributes."), - h("h2", { id: "beacon-computed-values" }, "Beacon Computed Values"), - h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), - h("p", null, "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message."), - h("p", null, - "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with ", - h("code", null, "user."), - " like ", - h("code", null, "user.spellpoints"), - ". They function like attributes and can be created, removed, set, reset, and modified as desired."), - h("p", null, - h("strong", null, "Example:")), - h("pre", null, - h("code", null, "!setattr --sel --spellpoints|18")), - h("p", null, - "This will create the ", - h("code", null, "user.spellpoints"), - " User Attribute, which can be referenced as either ", - h("code", null, "@{selected|user.spellpoints}"), - " or ", - h("code", null, "@{selected|spellpoints}"), - " and operates like an attribute."), - 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, "!resetattr --all --hp")), - 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 --reset --hp")), - 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 --mod --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 <ID1> <ID2> --hp|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 --hp_temp|8")), - 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 --hp|15")), - h("p", null, - "This will only update ", - h("code", null, "perception"), - " or ", - h("code", null, "hp"), - " 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 <NAME>"), - 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 <STRING>"), - 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 <STRING>"), - 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("p", null, - h("em", null, - h("strong", null, "Note:"), - " repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.")), - 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("p", null, - h("em", null)), - 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."))); + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + return new SafeHtml(parts.join("")); + } + function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function joinCodeLines(lines) { + return lines.join("\n"); + } + + function concatHtml(...parts) { + return new SafeHtml(parts.map(part => part.html).join("")); + } + function renderBlocks(blocks) { + if (!blocks) + return []; + const parts = []; + for (const block of blocks) { + switch (block.type) { + case "paragraph": + parts.push(h("p", {}, renderInlineHtml(block.text))); + break; + case "codeBlock": + parts.push(h("pre", {}, h("code", {}, joinCodeLines(block.lines)))); + break; + case "unorderedList": + parts.push(h("ul", {}, ...block.items.map(item => h("li", {}, renderInlineHtml(item))))); + break; + case "orderedList": + parts.push(h("ol", {}, ...block.items.map(item => { + const children = [renderInlineHtml(item.text)]; + if (item.codeBlock) { + children.push(h("pre", {}, h("code", {}, joinCodeLines(item.codeBlock.lines)))); + } + return h("li", {}, ...children); + }))); + break; + case "note": + parts.push(block.emphasis + ? h("p", {}, h("em", {}, h("strong", {}, "Note:"), " ", renderInlineHtml(block.text))) + : h("p", {}, renderInlineHtml(block.text))); + break; + } + } + return parts; + } + function renderSubsection(subsection) { + return concatHtml(h("h3", {}, subsection.title), ...renderBlocks(subsection.blocks)); + } + function renderSection(section) { + return concatHtml(h("h2", { id: section.id }, section.title), ...renderBlocks(section.blocks), ...(section.subsections?.map(renderSubsection) ?? [])); + } + function renderTableOfContents(doc, handoutID) { + return h("ol", {}, ...doc.sections.map(section => h("li", {}, h("a", { + href: `http://journal.roll20.net/handout/${handoutID}/#${section.title.replace(/\s+/g, "%20")}`, + }, section.title)))); + } + function renderHelpHtml(doc, handoutID) { + return concatHtml(h("h1", {}, doc.title), h("p", {}, doc.introduction), h("h2", {}, "Table of Contents"), renderTableOfContents(doc, handoutID), ...doc.sections.map(section => renderSection(section))).html; + } + + function createHelpHandout(handoutID) { + return renderHelpHtml(loadHelpDocument(), handoutID); } function checkHelpMessage(msg) { @@ -2733,7 +3497,7 @@ var ChatSetAttr = (function (exports) { "If you want to create a handout with the updated documentation, use the command ", h("code", null, "!setattrs-help"), " or click the button below"), - h("a", { href: "!setattrs-help" }, "Create Help Handout")))); + h("a", { href: "!setattrs-help" }, "Create Help Handout")))).html; } const v2_0 = { diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 8f7ef2648f..849c20fcf4 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -25,14 +25,35 @@ var ChatSetAttr = (function (exports) { } return style; } + function escapeHtml$1(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + class SafeHtml { + html; + constructor(html) { + this.html = html; + } + } + function renderChild(child) { + if (child instanceof SafeHtml) { + return child.html; + } + if (typeof child === "string") { + return escapeHtml$1(child); + } + return ""; + } function h(tagName, attributes = {}, ...children) { const attrs = Object.entries(attributes ?? {}) - .map(([key, value]) => ` ${key}="${value}"`) + .map(([key, value]) => ` ${key}="${escapeHtml$1(String(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 childrenContent = flattenedChildren.map(renderChild).join(""); + return new SafeHtml(`<${tagName}${attrs}>${childrenContent}`); } const buttonStyleBase = { @@ -64,7 +85,7 @@ var ChatSetAttr = (function (exports) { function createDelayMessage() { return (h("div", { style: DELAY_WRAPPER_STYLE }, h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), - h("div", null, "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."))); + h("div", null, "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."))).html; } const CHAT_WRAPPER_STYLE = s(frameStyleBase); @@ -86,7 +107,7 @@ var ChatSetAttr = (function (exports) { 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))))); + h("div", { style: styles.body }, messages.map(message => h("p", null, message))))).html; } // #region Chat Message Function function createChatMessage(header, messages) { @@ -110,7 +131,7 @@ var ChatSetAttr = (function (exports) { function createNotifyMessage(title, content) { return (h("div", { style: NOTIFY_WRAPPER_STYLE }, h("div", { style: NOTIFY_HEADER_STYLE }, title), - h("div", null, content))); + h("div", null, content))).html; } function createWelcomeMessage() { @@ -126,7 +147,7 @@ var ChatSetAttr = (function (exports) { h("code", null, "!setattr-help"), " command or click the button below:"), h("p", null, - h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))); + h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))).html; } function getWhisperPrefix(playerID) { @@ -221,7 +242,7 @@ var ChatSetAttr = (function (exports) { ":")), 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 })))); + h("div", { style: CONFIG_CLEAR_FIX_STYLE })))).html; } const GLOBAL_CONFIG_OPTIONS = [ @@ -1135,539 +1156,1282 @@ var ChatSetAttr = (function (exports) { return Math.max(Math.min(currentValue, maxValue), 0); } - function createHelpHandout(handoutID) { - const contents = [ - "Basic Usage", - "Available Commands", - "Beacon Computed Values", - "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)))))); + var $schema = "./content.schema.json"; + var title = "ChatSetAttr"; + var introduction = "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."; + var sections = [ + { + id: "basic-usage", + title: "Basic Usage", + blocks: [ + { + type: "paragraph", + text: "The script provides several command formats:" + }, + { + type: "unorderedList", + items: [ + "`!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" + ] + }, + { + type: "paragraph", + text: "Each command requires a target selection option and one or more attributes to modify." + }, + { + type: "paragraph", + text: "**Basic structure:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2" + ] + } + ] + }, + { + id: "available-commands", + title: "Available Commands", + subsections: [ + { + id: "setattr", + title: "!setattr", + blocks: [ + { + type: "paragraph", + text: "Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25|50 --hp_temp|8" + ] + }, + { + type: "paragraph", + text: "This would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8." + } + ] + }, + { + id: "modattr", + title: "!modattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --hp_temp|-5 --hp|6" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` and adds 6 to `hp`." + } + ] + }, + { + id: "modbattr", + title: "!modbattr", + blocks: [ + { + type: "paragraph", + text: "Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!modbattr --sel --hp_temp|-5 --hp|25" + ] + }, + { + type: "paragraph", + text: "This subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`." + } + ] + }, + { + id: "resetattr", + title: "!resetattr", + blocks: [ + { + type: "paragraph", + text: "Resets attributes to their maximum value. Shorthand for `!setattr --reset`." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --sel --hp" + ] + }, + { + type: "paragraph", + text: "This resets `hp` to its maximum value." + } + ] + }, + { + id: "delattr", + title: "!delattr", + blocks: [ + { + type: "paragraph", + text: "Deletes the specified attributes." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --hp --hp_temp" + ] + }, + { + type: "paragraph", + text: "This removes the `hp` and `hp_temp` attributes." + } + ] + } + ] + }, + { + id: "beacon-computed-values", + title: "Beacon Computed Values", + blocks: [ + { + type: "paragraph", + text: "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try." + }, + { + type: "paragraph", + text: "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message." + }, + { + type: "paragraph", + text: "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --spellpoints|18" + ] + }, + { + type: "paragraph", + text: "This will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute." + } + ] + }, + { + id: "target-selection", + title: "Target Selection", + blocks: [ + { + type: "paragraph", + text: "One of these options must be specified to determine which characters will be affected:" + } + ], + subsections: [ + { + id: "all", + title: "--all", + blocks: [ + { + type: "paragraph", + text: "Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!resetattr --all --hp" + ] + } + ] + }, + { + id: "allgm", + title: "--allgm", + blocks: [ + { + type: "paragraph", + text: "Affects all characters without player controllers (typically NPCs). **GM only**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allgm --reset --hp" + ] + } + ] + }, + { + id: "allplayers", + title: "--allplayers", + blocks: [ + { + type: "paragraph", + text: "Affects all characters with player controllers (typically PCs)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --allplayers --mod --hp|-15" + ] + } + ] + }, + { + id: "charid", + title: "--charid", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --charid --hp|150" + ] + } + ] + }, + { + id: "name", + title: "--name", + blocks: [ + { + type: "paragraph", + text: "Affects characters with the specified names. Non-GM players can only affect characters they control." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"" + ] + } + ] + }, + { + id: "sel", + title: "--sel", + blocks: [ + { + type: "paragraph", + text: "Affects characters represented by currently selected tokens." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|25 --hp_temp|8" + ] + } + ] + }, + { + id: "sel-party", + title: "--sel-party", + blocks: [ + { + type: "paragraph", + text: "Affects only party characters represented by currently selected tokens (characters with `inParty` set to true)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --inspiration|1" + ] + } + ] + }, + { + id: "sel-noparty", + title: "--sel-noparty", + blocks: [ + { + type: "paragraph", + text: "Affects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --npc_status|\"Hostile\"" + ] + } + ] + }, + { + id: "party", + title: "--party", + blocks: [ + { + type: "paragraph", + text: "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." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --rest_complete|1" + ] + } + ] + } + ] + }, + { + id: "attribute-syntax", + title: "Attribute Syntax", + blocks: [ + { + type: "paragraph", + text: "The syntax for specifying attributes is:" + }, + { + type: "codeBlock", + lines: [ + "--attributeName|currentValue|maxValue" + ] + }, + { + type: "unorderedList", + items: [ + "`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)" + ] + } + ], + subsections: [ + { + id: "examples", + title: "Examples:", + blocks: [ + { + type: "orderedList", + items: [ + { + text: "Set current value only:", + codeBlock: { + lines: [ + "--strength|15" + ] + } + }, + { + text: "Set both current and maximum values:", + codeBlock: { + lines: [ + "--hp|27|35" + ] + } + }, + { + text: "Set only the maximum value (leave current unchanged):", + codeBlock: { + lines: [ + "--hp||50" + ] + } + }, + { + text: "Create empty attribute or set to empty:", + codeBlock: { + lines: [ + "--notes|" + ] + } + }, + { + text: "Use `#` instead of `|` (useful in roll queries):", + codeBlock: { + lines: [ + "--strength#15" + ] + } + } + ] + } + ] + } + ] + }, + { + id: "modifier-options", + title: "Modifier Options", + blocks: [ + { + type: "paragraph", + text: "These options change how attributes are processed:" + } + ], + subsections: [ + { + id: "mod", + title: "--mod", + blocks: [ + { + type: "paragraph", + text: "See `!modattr` command." + } + ] + }, + { + id: "modb", + title: "--modb", + blocks: [ + { + type: "paragraph", + text: "See `!modbattr` command." + } + ] + }, + { + id: "reset", + title: "--reset", + blocks: [ + { + type: "paragraph", + text: "See `!resetattr` command." + } + ] + }, + { + id: "nocreate", + title: "--nocreate", + blocks: [ + { + type: "paragraph", + text: "Prevents creation of new attributes, only updates existing ones." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --nocreate --perception|20 --hp|15" + ] + }, + { + type: "paragraph", + text: "This will only update `perception` or `hp` if it already exists." + } + ] + }, + { + id: "evaluate", + title: "--evaluate", + blocks: [ + { + type: "paragraph", + text: "Evaluates JavaScript expressions in attribute values. **GM only by default**." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --hp|2 * 3" + ] + }, + { + type: "paragraph", + text: "This will set the `hp` attribute to 6." + } + ] + }, + { + id: "replace", + title: "--replace", + blocks: [ + { + type: "paragraph", + text: "Replaces special characters to prevent Roll20 from evaluating them:" + }, + { + type: "unorderedList", + items: [ + "< becomes [", + "> becomes ]", + "~ becomes -", + "; becomes ?", + "` becomes @" + ] + }, + { + type: "paragraph", + text: "Also supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"" + ] + }, + { + type: "paragraph", + text: "This stores \"Roll [[1d6]] to succeed\" without evaluating the roll." + } + ] + } + ] + }, + { + id: "output-control-options", + title: "Output Control Options", + blocks: [ + { + type: "paragraph", + text: "These options control the feedback messages generated by the script:" + } + ], + subsections: [ + { + id: "silent", + title: "--silent", + blocks: [ + { + type: "paragraph", + text: "Suppresses normal output messages (error messages will still appear)." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --silent --stealth|20" + ] + } + ] + }, + { + id: "mute", + title: "--mute", + blocks: [ + { + type: "paragraph", + text: "Suppresses all output messages, including errors." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --mute --nocreate --new_value|42" + ] + } + ] + }, + { + id: "fb-public", + title: "--fb-public", + blocks: [ + { + type: "paragraph", + text: "Sends output publicly to the chat instead of whispering to the command sender." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"" + ] + } + ] + }, + { + id: "fb-from", + title: "--fb-from ", + blocks: [ + { + type: "paragraph", + text: "Changes the name of the sender for output messages (default is \"ChatSetAttr\")." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-from \"Healing Potion\" --hp|25" + ] + } + ] + }, + { + id: "fb-header", + title: "--fb-header ", + blocks: [ + { + type: "paragraph", + text: "Customizes the header of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5" + ] + } + ] + }, + { + id: "fb-content", + title: "--fb-content ", + blocks: [ + { + type: "paragraph", + text: "Customizes the content of the output message." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10" + ] + } + ] + }, + { + id: "special-placeholders", + title: "Special Placeholders", + blocks: [ + { + type: "paragraph", + text: "For use in `--fb-header` and `--fb-content`:" + }, + { + type: "unorderedList", + items: [ + "`_NAMEJ_` - Name of the Jth attribute being changed", + "`_TCURJ_` - Target current value of the Jth attribute", + "`_TMAXJ_` - Target maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "For use in `--fb-content` only:" + }, + { + type: "unorderedList", + items: [ + "`_CHARNAME_` - Name of the character", + "`_CURJ_` - Final current value of the Jth attribute", + "`_MAXJ_` - Final maximum value of the Jth attribute" + ] + }, + { + type: "paragraph", + text: "**Important:** The Jth index starts with 0 at the first item." + }, + { + type: "paragraph", + text: "**Example:**" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10" + ] + } + ] + } + ] + }, + { + id: "inline-roll-integration", + title: "Inline Roll Integration", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr can be used within roll templates or combined with inline rolls:" + } + ], + subsections: [ + { + id: "within-roll-templates", + title: "Within Roll Templates", + blocks: [ + { + type: "paragraph", + text: "Place the command between roll template properties and end it with `!!!`:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + ] + } + ] + }, + { + id: "using-inline-rolls-in-values", + title: "Using Inline Rolls in Values", + blocks: [ + { + type: "paragraph", + text: "Inline rolls can be used for attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|[[2d6+5]]" + ] + } + ] + }, + { + id: "roll-queries", + title: "Roll Queries", + blocks: [ + { + type: "paragraph", + text: "Roll queries can determine attribute values:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|?{Set strength to what value?|100}" + ] + } + ] + } + ] + }, + { + id: "repeating-section-support", + title: "Repeating Section Support", + blocks: [ + { + type: "paragraph", + text: "ChatSetAttr supports working with repeating sections:" + } + ], + subsections: [ + { + id: "creating-new-repeating-items", + title: "Creating New Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Use `CREATE` to create a new row in a repeating section:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2" + ] + } + ] + }, + { + id: "modifying-existing-repeating-items", + title: "Modifying Existing Repeating Items", + blocks: [ + { + type: "paragraph", + text: "Access by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"" + ] + }, + { + type: "paragraph", + text: "Access by index (starts at 0):" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"" + ] + } + ] + }, + { + id: "deleting-repeating-rows", + title: "Deleting Repeating Rows", + blocks: [ + { + type: "paragraph", + text: "Delete by row ID:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_ID" + ] + }, + { + type: "paragraph", + text: "Delete by index:" + }, + { + type: "codeBlock", + lines: [ + "!delattr --sel --repeating_inventory_$0" + ] + }, + { + type: "note", + text: "repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.", + emphasis: true + } + ] + } + ] + }, + { + id: "special-value-expressions", + title: "Special Value Expressions", + subsections: [ + { + id: "attribute-references", + title: "Attribute References", + blocks: [ + { + type: "paragraph", + text: "Reference other attribute values using `%attribute_name%`:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --evaluate --temp_hp|%hp% / 2" + ] + } + ] + }, + { + id: "resetting-to-maximum", + title: "Resetting to Maximum", + blocks: [ + { + type: "paragraph", + text: "Reset an attribute to its maximum value:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --hp|%hp_max%" + ] + } + ] + } + ] + }, + { + id: "global-configuration", + title: "Global Configuration", + blocks: [ + { + type: "paragraph", + text: "The script has four global configuration options that can be toggled with `!setattr-config`:" + } + ], + subsections: [ + { + id: "players-can-modify", + title: "--players-can-modify", + blocks: [ + { + type: "paragraph", + text: "Allows players to modify attributes on characters they don't control." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-modify" + ] + } + ] + }, + { + id: "players-can-evaluate", + title: "--players-can-evaluate", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--evaluate` option." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-evaluate" + ] + } + ] + }, + { + id: "players-can-target-party", + title: "--players-can-target-party", + blocks: [ + { + type: "paragraph", + text: "Allows players to use the `--party` target option. **GM only by default**." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --players-can-target-party" + ] + } + ] + }, + { + id: "use-workers", + title: "--use-workers", + blocks: [ + { + type: "paragraph", + text: "Toggles whether the script triggers sheet workers when setting attributes." + }, + { + type: "codeBlock", + lines: [ + "!setattr-config --use-workers" + ] + } + ] + } + ] + }, + { + id: "complete-examples", + title: "Complete Examples", + subsections: [ + { + id: "basic-combat-example", + title: "Basic Combat Example", + blocks: [ + { + type: "paragraph", + text: "Reduce a character's HP and status after taking damage:" + }, + { + type: "codeBlock", + lines: [ + "!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"" + ] + } + ] + }, + { + id: "leveling-up-a-character", + title: "Leveling Up a Character", + blocks: [ + { + type: "paragraph", + text: "Update multiple stats when a character gains a level:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public" + ] + } + ] + }, + { + id: "create-new-item-in-inventory", + title: "Create New Item in Inventory", + blocks: [ + { + type: "paragraph", + text: "Add a new item to a character's inventory:" + }, + { + type: "codeBlock", + lines: [ + "!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\"" + ] + } + ] + }, + { + id: "apply-status-effects-during-combat", + title: "Apply Status Effects During Combat", + blocks: [ + { + type: "paragraph", + text: "Apply a debuff to selected enemies in the middle of combat:" + }, + { + type: "codeBlock", + lines: [ + "&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}" + ] + } + ] + }, + { + id: "party-management-examples", + title: "Party Management Examples", + blocks: [ + { + type: "paragraph", + text: "Give inspiration to all party members after a great roleplay moment:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"" + ] + }, + { + type: "paragraph", + text: "Apply a long rest to only party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"" + ] + }, + { + type: "paragraph", + text: "Set hostile status for non-party characters among selected tokens:" + }, + { + type: "codeBlock", + lines: [ + "!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"" + ] + } + ] + } + ] + }, + { + id: "for-developers", + title: "For Developers", + subsections: [ + { + id: "registering-observers", + title: "Registering Observers", + blocks: [ + { + type: "paragraph", + text: "If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:" + }, + { + type: "codeBlock", + lines: [ + "ChatSetAttr.registerObserver(event, observer);" + ] + }, + { + type: "paragraph", + text: "Where `event` is one of:" + }, + { + type: "unorderedList", + items: [ + "`\"add\"` - Called when attributes are created", + "`\"change\"` - Called when attributes are modified", + "`\"destroy\"` - Called when attributes are deleted" + ] + }, + { + type: "paragraph", + text: "And `observer` is an event handler function similar to Roll20's built-in event handlers." + }, + { + type: "paragraph", + text: "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." + } + ] + } + ] + } + ]; + var helpContent = { + $schema: $schema, + title: title, + introduction: introduction, + sections: sections + }; + + function loadHelpDocument() { + return helpContent; + } + + const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`)/g; + function renderInlineHtml(text) { + const parts = []; + let lastIndex = 0; + let match; + INLINE_PATTERN.lastIndex = 0; + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, match.index))); + } + const token = match[0]; + if (token.startsWith("**")) { + parts.push(`${escapeHtml(token.slice(2, -2))}`); + } + else { + parts.push(`${escapeHtml(token.slice(1, -1))}`); + } + lastIndex = match.index + token.length; } - 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 --hp_temp|8")), - h("p", null, - "This would set ", - h("code", null, "hp"), - " to 25, ", - h("code", null, "hp_max"), - " to 50, ", - h("code", null, "hp_temp"), - " to 8."), - 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_temp|-5 --hp|6")), - h("p", null, - "This subtracts 5 from ", - h("code", null, "hp_temp"), - " and adds 6 to ", - h("code", null, "hp"), - "."), - 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_temp|-5 --hp|25")), - h("p", null, - "This subtracts 5 from ", - h("code", null, "hp_temp"), - " but won't reduce it below 0 and increase ", - h("code", null, "hp"), - " 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")), - h("p", null, - "This resets ", - h("code", null, "hp"), - " to its maximum value."), - 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 --hp_temp")), - h("p", null, - "This removes the ", - h("code", null, "hp"), - " and ", - h("code", null, "hp_temp"), - " attributes."), - h("h2", { id: "beacon-computed-values" }, "Beacon Computed Values"), - h("p", null, "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try."), - h("p", null, "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message."), - h("p", null, - "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with ", - h("code", null, "user."), - " like ", - h("code", null, "user.spellpoints"), - ". They function like attributes and can be created, removed, set, reset, and modified as desired."), - h("p", null, - h("strong", null, "Example:")), - h("pre", null, - h("code", null, "!setattr --sel --spellpoints|18")), - h("p", null, - "This will create the ", - h("code", null, "user.spellpoints"), - " User Attribute, which can be referenced as either ", - h("code", null, "@{selected|user.spellpoints}"), - " or ", - h("code", null, "@{selected|spellpoints}"), - " and operates like an attribute."), - 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, "!resetattr --all --hp")), - 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 --reset --hp")), - 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 --mod --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 <ID1> <ID2> --hp|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 --hp_temp|8")), - 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 --hp|15")), - h("p", null, - "This will only update ", - h("code", null, "perception"), - " or ", - h("code", null, "hp"), - " 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 <NAME>"), - 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 <STRING>"), - 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 <STRING>"), - 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("p", null, - h("em", null, - h("strong", null, "Note:"), - " repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.")), - 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("p", null, - h("em", null)), - 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."))); + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + return new SafeHtml(parts.join("")); + } + function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function joinCodeLines(lines) { + return lines.join("\n"); + } + + function concatHtml(...parts) { + return new SafeHtml(parts.map(part => part.html).join("")); + } + function renderBlocks(blocks) { + if (!blocks) + return []; + const parts = []; + for (const block of blocks) { + switch (block.type) { + case "paragraph": + parts.push(h("p", {}, renderInlineHtml(block.text))); + break; + case "codeBlock": + parts.push(h("pre", {}, h("code", {}, joinCodeLines(block.lines)))); + break; + case "unorderedList": + parts.push(h("ul", {}, ...block.items.map(item => h("li", {}, renderInlineHtml(item))))); + break; + case "orderedList": + parts.push(h("ol", {}, ...block.items.map(item => { + const children = [renderInlineHtml(item.text)]; + if (item.codeBlock) { + children.push(h("pre", {}, h("code", {}, joinCodeLines(item.codeBlock.lines)))); + } + return h("li", {}, ...children); + }))); + break; + case "note": + parts.push(block.emphasis + ? h("p", {}, h("em", {}, h("strong", {}, "Note:"), " ", renderInlineHtml(block.text))) + : h("p", {}, renderInlineHtml(block.text))); + break; + } + } + return parts; + } + function renderSubsection(subsection) { + return concatHtml(h("h3", {}, subsection.title), ...renderBlocks(subsection.blocks)); + } + function renderSection(section) { + return concatHtml(h("h2", { id: section.id }, section.title), ...renderBlocks(section.blocks), ...(section.subsections?.map(renderSubsection) ?? [])); + } + function renderTableOfContents(doc, handoutID) { + return h("ol", {}, ...doc.sections.map(section => h("li", {}, h("a", { + href: `http://journal.roll20.net/handout/${handoutID}/#${section.title.replace(/\s+/g, "%20")}`, + }, section.title)))); + } + function renderHelpHtml(doc, handoutID) { + return concatHtml(h("h1", {}, doc.title), h("p", {}, doc.introduction), h("h2", {}, "Table of Contents"), renderTableOfContents(doc, handoutID), ...doc.sections.map(section => renderSection(section))).html; + } + + function createHelpHandout(handoutID) { + return renderHelpHtml(loadHelpDocument(), handoutID); } function checkHelpMessage(msg) { @@ -2733,7 +3497,7 @@ var ChatSetAttr = (function (exports) { "If you want to create a handout with the updated documentation, use the command ", h("code", null, "!setattrs-help"), " or click the button below"), - h("a", { href: "!setattrs-help" }, "Create Help Handout")))); + h("a", { href: "!setattrs-help" }, "Create Help Handout")))).html; } const v2_0 = { diff --git a/ChatSetAttr/README.md b/ChatSetAttr/README.md index bf57d3487e..851ea15a83 100644 --- a/ChatSetAttr/README.md +++ b/ChatSetAttr/README.md @@ -1,3 +1,5 @@ + + # 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. @@ -6,16 +8,17 @@ ChatSetAttr is a Roll20 API script that allows users to create, modify, or delet 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) +3. [Beacon Computed Values](#beacon-computed-values) +4. [Target Selection](#target-selection) +5. [Attribute Syntax](#attribute-syntax) +6. [Modifier Options](#modifier-options) +7. [Output Control Options](#output-control-options) +8. [Inline Roll Integration](#inline-roll-integration) +9. [Repeating Section Support](#repeating-section-support) +10. [Special Value Expressions](#special-value-expressions) +11. [Global Configuration](#global-configuration) +12. [Complete Examples](#complete-examples) +13. [For Developers](#for-developers) ## Basic Usage @@ -30,6 +33,7 @@ The script provides several command formats: Each command requires a target selection option and one or more attributes to modify. **Basic structure:** + ``` !setattr --[target selection] --attribute1|value1 --attribute2|value2|max2 ``` @@ -41,55 +45,76 @@ Each command requires a target selection option and one or more attributes to mo 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 +!setattr --sel --hp|25|50 --hp_temp|8 ``` -This would set `hp` to 25, `hp_max` to 50, `xp` to 0 and `xp_max` to 800. +This would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8. ### !modattr Adds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`. **Example:** + ``` -!modattr --sel --hp|-5 --xp|100 +!modattr --sel --hp_temp|-5 --hp|6 ``` -This subtracts 5 from `hp` and adds 100 to `xp`. +This subtracts 5 from `hp_temp` and adds 6 to `hp`. ### !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 +!modbattr --sel --hp_temp|-5 --hp|25 ``` -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`. +This subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` 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 +!resetattr --sel --hp ``` -This resets `hp`, and `xp` to their respective maximum values. +This resets `hp` to its maximum value. ### !delattr Deletes the specified attributes. **Example:** + +``` +!delattr --sel --hp --hp_temp +``` + +This removes the `hp` and `hp_temp` attributes. + +## Beacon Computed Values + +Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try. + +Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message. + +For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired. + +**Example:** + ``` -!delattr --sel --hp --xp +!setattr --sel --spellpoints|18 ``` -This removes the `hp` and `xp` attributes. +This will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute. ## Target Selection @@ -100,8 +125,9 @@ One of these options must be specified to determine which characters will be aff Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns. **Example:** + ``` -!setattr --all --hp|15 +!resetattr --all --hp ``` ### --allgm @@ -109,8 +135,9 @@ Affects all characters in the campaign. **GM only** and should be used with caut Affects all characters without player controllers (typically NPCs). **GM only**. **Example:** + ``` -!setattr --allgm --xp|150 +!setattr --allgm --reset --hp ``` ### --allplayers @@ -118,8 +145,9 @@ Affects all characters without player controllers (typically NPCs). **GM only**. Affects all characters with player controllers (typically PCs). **Example:** + ``` -!setattr --allplayers --hp|15 +!setattr --allplayers --mod --hp|-15 ``` ### --charid @@ -127,8 +155,9 @@ Affects all characters with player controllers (typically PCs). Affects characters with the specified character IDs. Non-GM players can only affect characters they control. **Example:** + ``` -!setattr --charid --xp|150 +!setattr --charid --hp|150 ``` ### --name @@ -136,6 +165,7 @@ Affects characters with the specified character IDs. Non-GM players can only aff 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" ``` @@ -145,8 +175,9 @@ Affects characters with the specified names. Non-GM players can only affect char Affects characters represented by currently selected tokens. **Example:** + ``` -!setattr --sel --hp|25 --xp|30 +!setattr --sel --hp|25 --hp_temp|8 ``` ### --sel-party @@ -154,6 +185,7 @@ Affects characters represented by currently selected tokens. Affects only party characters represented by currently selected tokens (characters with `inParty` set to true). **Example:** + ``` !setattr --sel-party --inspiration|1 ``` @@ -163,6 +195,7 @@ Affects only party characters represented by currently selected tokens (characte 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" ``` @@ -172,6 +205,7 @@ Affects only non-party characters represented by currently selected tokens (char 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 ``` @@ -179,40 +213,37 @@ Affects all characters marked as party members (characters with `inParty` set to ## 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) +- `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 - ``` - +``` +--strength|15 +``` 2. Set both current and maximum values: - ``` - --hp|27|35 - ``` - +``` +--hp|27|35 +``` 3. Set only the maximum value (leave current unchanged): - ``` - --hp||50 - ``` - +``` +--hp||50 +``` 4. Create empty attribute or set to empty: - ``` - --notes| - ``` - +``` +--notes| +``` 5. Use `#` instead of `|` (useful in roll queries): - ``` - --strength#15 - ``` +``` +--strength#15 +``` ## Modifier Options @@ -235,17 +266,19 @@ See `!resetattr` command. Prevents creation of new attributes, only updates existing ones. **Example:** + ``` -!setattr --sel --nocreate --perception|20 --xp|15 +!setattr --sel --nocreate --perception|20 --hp|15 ``` -This will only update `perception` or `xp` if it already exists. +This will only update `perception` or `hp` if it already exists. ### --evaluate Evaluates JavaScript expressions in attribute values. **GM only by default**. **Example:** + ``` !setattr --sel --evaluate --hp|2 * 3 ``` @@ -255,15 +288,17 @@ This will set the `hp` attribute to 6. ### --replace Replaces special characters to prevent Roll20 from evaluating them: + - < becomes [ - > 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" ``` @@ -279,6 +314,7 @@ These options control the feedback messages generated by the script: Suppresses normal output messages (error messages will still appear). **Example:** + ``` !setattr --sel --silent --stealth|20 ``` @@ -288,6 +324,7 @@ Suppresses normal output messages (error messages will still appear). Suppresses all output messages, including errors. **Example:** + ``` !setattr --sel --mute --nocreate --new_value|42 ``` @@ -297,33 +334,37 @@ Suppresses all output messages, including errors. 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 \ +### --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 \ +### --fb-header Customizes the header of the output message. **Example:** + ``` !setattr --sel --evaluate --fb-header "Combat Effects Applied" --status|"Poisoned" --hp|%hp%-5 ``` -### --fb-content \ +### --fb-content Customizes the content of the output message. **Example:** + ``` !setattr --sel --fb-content "Increasing Hitpoints" --hp|10 ``` @@ -332,19 +373,20 @@ Customizes the content of the output message. 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 +- `_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 +- `_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 ``` @@ -383,10 +425,10 @@ ChatSetAttr supports working with repeating sections: ### Creating New Repeating Items -Use `-CREATE` to create a new row in a repeating section: +Use `CREATE` to create a new row in a repeating section: ``` -!setattr --sel --repeating_inventory_-CREATE_itemname|"Magic Sword" --repeating_inventory_-CREATE_itemweight|2 +!setattr --sel --repeating_inventory_CREATE_itemname|"Magic Sword" --repeating_inventory_CREATE_itemweight|2 ``` ### Modifying Existing Repeating Items @@ -394,7 +436,7 @@ Use `-CREATE` to create a new row in a repeating section: Access by row ID: ``` -!setattr --sel --repeating_inventory_-ID_itemname|"Enchanted Magic Sword" +!setattr --sel --repeating_inventory_ID_itemname|"Enchanted Magic Sword" ``` Access by index (starts at 0): @@ -408,7 +450,7 @@ Access by index (starts at 0): Delete by row ID: ``` -!delattr --sel --repeating_inventory_-ID +!delattr --sel --repeating_inventory_ID ``` Delete by index: @@ -417,6 +459,8 @@ Delete by index: !delattr --sel --repeating_inventory_$0 ``` +> **Note:** repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them. + ## Special Value Expressions ### Attribute References @@ -531,15 +575,16 @@ Set hostile status for non-party characters among selected tokens: 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); ``` 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. \ 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. diff --git a/ChatSetAttr/docs/help/README.md b/ChatSetAttr/docs/help/README.md new file mode 100644 index 0000000000..47f5febcb9 --- /dev/null +++ b/ChatSetAttr/docs/help/README.md @@ -0,0 +1,75 @@ +# ChatSetAttr Help Content + +The file [`content.json`](content.json) is the single source of truth for ChatSetAttr user documentation. + +It is rendered to: + +- Roll20 help handout HTML (runtime, via `createHelpHandout`) +- [`README.md`](../../README.md) (GitHub, via `pnpm docs:generate`) +- [`script.json`](../../script.json) `description` (Roll20 One-Click page, via `pnpm docs:generate`) + +## Editing + +1. Edit `content.json` only — do not edit README or script description by hand. +2. Validate structure against [`content.schema.json`](content.schema.json) (VS Code will autocomplete if `$schema` is set). +3. Run `pnpm docs:generate` to regenerate README and script.json. +4. Run `pnpm docs:check` before committing to ensure generated files are in sync. + +## Document structure + +```json +{ + "title": "ChatSetAttr", + "introduction": "Opening paragraph...", + "sections": [ + { + "id": "basic-usage", + "title": "Basic Usage", + "blocks": [ ... ], + "subsections": [ + { "id": "setattr", "title": "!setattr", "blocks": [ ... ] } + ] + } + ] +} +``` + +- **sections** — top-level `h2` topics. Each needs a unique `id` (kebab-case slug used for anchors). +- **subsections** — optional `h3` topics under a section. +- **blocks** — content within a section or subsection. + +Table of Contents is generated automatically from section titles — do not add a TOC block. + +## Block types + +| Type | Purpose | +|------|---------| +| `paragraph` | Prose, labels like **Example:** | +| `codeBlock` | Command or macro examples (`lines` array) | +| `unorderedList` | Bullet list | +| `orderedList` | Numbered list; items may include nested `codeBlock` | +| `note` | Callout / warning (`emphasis: true` for emphasized notes) | + +## Inline markup + +Within `paragraph.text`, `unorderedList.items`, `orderedList.items[].text`, and `note.text`, use a small markdown subset: + +- `**bold text**` +- `` `inline code` `` + +Write special characters literally in JSON (`<`, `>`, `{`, `}`, `&`). Renderers escape them per output format. + +## Code blocks + +Use `lines` for readability: + +```json +{ + "type": "codeBlock", + "lines": [ + "!setattr --sel --hp|25|50" + ] +} +``` + +Multi-line commands use multiple array entries or embedded `\n` in a single line. diff --git a/ChatSetAttr/docs/help/content.json b/ChatSetAttr/docs/help/content.json new file mode 100644 index 0000000000..759a795072 --- /dev/null +++ b/ChatSetAttr/docs/help/content.json @@ -0,0 +1,1179 @@ +{ + "$schema": "./content.schema.json", + "title": "ChatSetAttr", + "introduction": "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.", + "sections": [ + { + "id": "basic-usage", + "title": "Basic Usage", + "blocks": [ + { + "type": "paragraph", + "text": "The script provides several command formats:" + }, + { + "type": "unorderedList", + "items": [ + "`!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" + ] + }, + { + "type": "paragraph", + "text": "Each command requires a target selection option and one or more attributes to modify." + }, + { + "type": "paragraph", + "text": "**Basic structure:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2" + ] + } + ] + }, + { + "id": "available-commands", + "title": "Available Commands", + "subsections": [ + { + "id": "setattr", + "title": "!setattr", + "blocks": [ + { + "type": "paragraph", + "text": "Creates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified)." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --hp|25|50 --hp_temp|8" + ] + }, + { + "type": "paragraph", + "text": "This would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8." + } + ] + }, + { + "id": "modattr", + "title": "!modattr", + "blocks": [ + { + "type": "paragraph", + "text": "Adds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!modattr --sel --hp_temp|-5 --hp|6" + ] + }, + { + "type": "paragraph", + "text": "This subtracts 5 from `hp_temp` and adds 6 to `hp`." + } + ] + }, + { + "id": "modbattr", + "title": "!modbattr", + "blocks": [ + { + "type": "paragraph", + "text": "Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!modbattr --sel --hp_temp|-5 --hp|25" + ] + }, + { + "type": "paragraph", + "text": "This subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`." + } + ] + }, + { + "id": "resetattr", + "title": "!resetattr", + "blocks": [ + { + "type": "paragraph", + "text": "Resets attributes to their maximum value. Shorthand for `!setattr --reset`." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!resetattr --sel --hp" + ] + }, + { + "type": "paragraph", + "text": "This resets `hp` to its maximum value." + } + ] + }, + { + "id": "delattr", + "title": "!delattr", + "blocks": [ + { + "type": "paragraph", + "text": "Deletes the specified attributes." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!delattr --sel --hp --hp_temp" + ] + }, + { + "type": "paragraph", + "text": "This removes the `hp` and `hp_temp` attributes." + } + ] + } + ] + }, + { + "id": "beacon-computed-values", + "title": "Beacon Computed Values", + "blocks": [ + { + "type": "paragraph", + "text": "Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try." + }, + { + "type": "paragraph", + "text": "Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message." + }, + { + "type": "paragraph", + "text": "For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --spellpoints|18" + ] + }, + { + "type": "paragraph", + "text": "This will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute." + } + ] + }, + { + "id": "target-selection", + "title": "Target Selection", + "blocks": [ + { + "type": "paragraph", + "text": "One of these options must be specified to determine which characters will be affected:" + } + ], + "subsections": [ + { + "id": "all", + "title": "--all", + "blocks": [ + { + "type": "paragraph", + "text": "Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!resetattr --all --hp" + ] + } + ] + }, + { + "id": "allgm", + "title": "--allgm", + "blocks": [ + { + "type": "paragraph", + "text": "Affects all characters without player controllers (typically NPCs). **GM only**." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --allgm --reset --hp" + ] + } + ] + }, + { + "id": "allplayers", + "title": "--allplayers", + "blocks": [ + { + "type": "paragraph", + "text": "Affects all characters with player controllers (typically PCs)." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --allplayers --mod --hp|-15" + ] + } + ] + }, + { + "id": "charid", + "title": "--charid", + "blocks": [ + { + "type": "paragraph", + "text": "Affects characters with the specified character IDs. Non-GM players can only affect characters they control." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --charid --hp|150" + ] + } + ] + }, + { + "id": "name", + "title": "--name", + "blocks": [ + { + "type": "paragraph", + "text": "Affects characters with the specified names. Non-GM players can only affect characters they control." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"" + ] + } + ] + }, + { + "id": "sel", + "title": "--sel", + "blocks": [ + { + "type": "paragraph", + "text": "Affects characters represented by currently selected tokens." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --hp|25 --hp_temp|8" + ] + } + ] + }, + { + "id": "sel-party", + "title": "--sel-party", + "blocks": [ + { + "type": "paragraph", + "text": "Affects only party characters represented by currently selected tokens (characters with `inParty` set to true)." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel-party --inspiration|1" + ] + } + ] + }, + { + "id": "sel-noparty", + "title": "--sel-noparty", + "blocks": [ + { + "type": "paragraph", + "text": "Affects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set)." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel-noparty --npc_status|\"Hostile\"" + ] + } + ] + }, + { + "id": "party", + "title": "--party", + "blocks": [ + { + "type": "paragraph", + "text": "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." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --party --rest_complete|1" + ] + } + ] + } + ] + }, + { + "id": "attribute-syntax", + "title": "Attribute Syntax", + "blocks": [ + { + "type": "paragraph", + "text": "The syntax for specifying attributes is:" + }, + { + "type": "codeBlock", + "lines": [ + "--attributeName|currentValue|maxValue" + ] + }, + { + "type": "unorderedList", + "items": [ + "`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)" + ] + } + ], + "subsections": [ + { + "id": "examples", + "title": "Examples:", + "blocks": [ + { + "type": "orderedList", + "items": [ + { + "text": "Set current value only:", + "codeBlock": { + "lines": [ + "--strength|15" + ] + } + }, + { + "text": "Set both current and maximum values:", + "codeBlock": { + "lines": [ + "--hp|27|35" + ] + } + }, + { + "text": "Set only the maximum value (leave current unchanged):", + "codeBlock": { + "lines": [ + "--hp||50" + ] + } + }, + { + "text": "Create empty attribute or set to empty:", + "codeBlock": { + "lines": [ + "--notes|" + ] + } + }, + { + "text": "Use `#` instead of `|` (useful in roll queries):", + "codeBlock": { + "lines": [ + "--strength#15" + ] + } + } + ] + } + ] + } + ] + }, + { + "id": "modifier-options", + "title": "Modifier Options", + "blocks": [ + { + "type": "paragraph", + "text": "These options change how attributes are processed:" + } + ], + "subsections": [ + { + "id": "mod", + "title": "--mod", + "blocks": [ + { + "type": "paragraph", + "text": "See `!modattr` command." + } + ] + }, + { + "id": "modb", + "title": "--modb", + "blocks": [ + { + "type": "paragraph", + "text": "See `!modbattr` command." + } + ] + }, + { + "id": "reset", + "title": "--reset", + "blocks": [ + { + "type": "paragraph", + "text": "See `!resetattr` command." + } + ] + }, + { + "id": "nocreate", + "title": "--nocreate", + "blocks": [ + { + "type": "paragraph", + "text": "Prevents creation of new attributes, only updates existing ones." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --nocreate --perception|20 --hp|15" + ] + }, + { + "type": "paragraph", + "text": "This will only update `perception` or `hp` if it already exists." + } + ] + }, + { + "id": "evaluate", + "title": "--evaluate", + "blocks": [ + { + "type": "paragraph", + "text": "Evaluates JavaScript expressions in attribute values. **GM only by default**." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --evaluate --hp|2 * 3" + ] + }, + { + "type": "paragraph", + "text": "This will set the `hp` attribute to 6." + } + ] + }, + { + "id": "replace", + "title": "--replace", + "blocks": [ + { + "type": "paragraph", + "text": "Replaces special characters to prevent Roll20 from evaluating them:" + }, + { + "type": "unorderedList", + "items": [ + "< becomes [", + "> becomes ]", + "~ becomes -", + "; becomes ?", + "` becomes @" + ] + }, + { + "type": "paragraph", + "text": "Also supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"" + ] + }, + { + "type": "paragraph", + "text": "This stores \"Roll [[1d6]] to succeed\" without evaluating the roll." + } + ] + } + ] + }, + { + "id": "output-control-options", + "title": "Output Control Options", + "blocks": [ + { + "type": "paragraph", + "text": "These options control the feedback messages generated by the script:" + } + ], + "subsections": [ + { + "id": "silent", + "title": "--silent", + "blocks": [ + { + "type": "paragraph", + "text": "Suppresses normal output messages (error messages will still appear)." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --silent --stealth|20" + ] + } + ] + }, + { + "id": "mute", + "title": "--mute", + "blocks": [ + { + "type": "paragraph", + "text": "Suppresses all output messages, including errors." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --mute --nocreate --new_value|42" + ] + } + ] + }, + { + "id": "fb-public", + "title": "--fb-public", + "blocks": [ + { + "type": "paragraph", + "text": "Sends output publicly to the chat instead of whispering to the command sender." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"" + ] + } + ] + }, + { + "id": "fb-from", + "title": "--fb-from ", + "blocks": [ + { + "type": "paragraph", + "text": "Changes the name of the sender for output messages (default is \"ChatSetAttr\")." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --fb-from \"Healing Potion\" --hp|25" + ] + } + ] + }, + { + "id": "fb-header", + "title": "--fb-header ", + "blocks": [ + { + "type": "paragraph", + "text": "Customizes the header of the output message." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5" + ] + } + ] + }, + { + "id": "fb-content", + "title": "--fb-content ", + "blocks": [ + { + "type": "paragraph", + "text": "Customizes the content of the output message." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10" + ] + } + ] + }, + { + "id": "special-placeholders", + "title": "Special Placeholders", + "blocks": [ + { + "type": "paragraph", + "text": "For use in `--fb-header` and `--fb-content`:" + }, + { + "type": "unorderedList", + "items": [ + "`_NAMEJ_` - Name of the Jth attribute being changed", + "`_TCURJ_` - Target current value of the Jth attribute", + "`_TMAXJ_` - Target maximum value of the Jth attribute" + ] + }, + { + "type": "paragraph", + "text": "For use in `--fb-content` only:" + }, + { + "type": "unorderedList", + "items": [ + "`_CHARNAME_` - Name of the character", + "`_CURJ_` - Final current value of the Jth attribute", + "`_MAXJ_` - Final maximum value of the Jth attribute" + ] + }, + { + "type": "paragraph", + "text": "**Important:** The Jth index starts with 0 at the first item." + }, + { + "type": "paragraph", + "text": "**Example:**" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10" + ] + } + ] + } + ] + }, + { + "id": "inline-roll-integration", + "title": "Inline Roll Integration", + "blocks": [ + { + "type": "paragraph", + "text": "ChatSetAttr can be used within roll templates or combined with inline rolls:" + } + ], + "subsections": [ + { + "id": "within-roll-templates", + "title": "Within Roll Templates", + "blocks": [ + { + "type": "paragraph", + "text": "Place the command between roll template properties and end it with `!!!`:" + }, + { + "type": "codeBlock", + "lines": [ + "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + ] + } + ] + }, + { + "id": "using-inline-rolls-in-values", + "title": "Using Inline Rolls in Values", + "blocks": [ + { + "type": "paragraph", + "text": "Inline rolls can be used for attribute values:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --hp|[[2d6+5]]" + ] + } + ] + }, + { + "id": "roll-queries", + "title": "Roll Queries", + "blocks": [ + { + "type": "paragraph", + "text": "Roll queries can determine attribute values:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --hp|?{Set strength to what value?|100}" + ] + } + ] + } + ] + }, + { + "id": "repeating-section-support", + "title": "Repeating Section Support", + "blocks": [ + { + "type": "paragraph", + "text": "ChatSetAttr supports working with repeating sections:" + } + ], + "subsections": [ + { + "id": "creating-new-repeating-items", + "title": "Creating New Repeating Items", + "blocks": [ + { + "type": "paragraph", + "text": "Use `CREATE` to create a new row in a repeating section:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2" + ] + } + ] + }, + { + "id": "modifying-existing-repeating-items", + "title": "Modifying Existing Repeating Items", + "blocks": [ + { + "type": "paragraph", + "text": "Access by row ID:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"" + ] + }, + { + "type": "paragraph", + "text": "Access by index (starts at 0):" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"" + ] + } + ] + }, + { + "id": "deleting-repeating-rows", + "title": "Deleting Repeating Rows", + "blocks": [ + { + "type": "paragraph", + "text": "Delete by row ID:" + }, + { + "type": "codeBlock", + "lines": [ + "!delattr --sel --repeating_inventory_ID" + ] + }, + { + "type": "paragraph", + "text": "Delete by index:" + }, + { + "type": "codeBlock", + "lines": [ + "!delattr --sel --repeating_inventory_$0" + ] + }, + { + "type": "note", + "text": "repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.", + "emphasis": true + } + ] + } + ] + }, + { + "id": "special-value-expressions", + "title": "Special Value Expressions", + "subsections": [ + { + "id": "attribute-references", + "title": "Attribute References", + "blocks": [ + { + "type": "paragraph", + "text": "Reference other attribute values using `%attribute_name%`:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --evaluate --temp_hp|%hp% / 2" + ] + } + ] + }, + { + "id": "resetting-to-maximum", + "title": "Resetting to Maximum", + "blocks": [ + { + "type": "paragraph", + "text": "Reset an attribute to its maximum value:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --hp|%hp_max%" + ] + } + ] + } + ] + }, + { + "id": "global-configuration", + "title": "Global Configuration", + "blocks": [ + { + "type": "paragraph", + "text": "The script has four global configuration options that can be toggled with `!setattr-config`:" + } + ], + "subsections": [ + { + "id": "players-can-modify", + "title": "--players-can-modify", + "blocks": [ + { + "type": "paragraph", + "text": "Allows players to modify attributes on characters they don't control." + }, + { + "type": "codeBlock", + "lines": [ + "!setattr-config --players-can-modify" + ] + } + ] + }, + { + "id": "players-can-evaluate", + "title": "--players-can-evaluate", + "blocks": [ + { + "type": "paragraph", + "text": "Allows players to use the `--evaluate` option." + }, + { + "type": "codeBlock", + "lines": [ + "!setattr-config --players-can-evaluate" + ] + } + ] + }, + { + "id": "players-can-target-party", + "title": "--players-can-target-party", + "blocks": [ + { + "type": "paragraph", + "text": "Allows players to use the `--party` target option. **GM only by default**." + }, + { + "type": "codeBlock", + "lines": [ + "!setattr-config --players-can-target-party" + ] + } + ] + }, + { + "id": "use-workers", + "title": "--use-workers", + "blocks": [ + { + "type": "paragraph", + "text": "Toggles whether the script triggers sheet workers when setting attributes." + }, + { + "type": "codeBlock", + "lines": [ + "!setattr-config --use-workers" + ] + } + ] + } + ] + }, + { + "id": "complete-examples", + "title": "Complete Examples", + "subsections": [ + { + "id": "basic-combat-example", + "title": "Basic Combat Example", + "blocks": [ + { + "type": "paragraph", + "text": "Reduce a character's HP and status after taking damage:" + }, + { + "type": "codeBlock", + "lines": [ + "!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"" + ] + } + ] + }, + { + "id": "leveling-up-a-character", + "title": "Leveling Up a Character", + "blocks": [ + { + "type": "paragraph", + "text": "Update multiple stats when a character gains a level:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public" + ] + } + ] + }, + { + "id": "create-new-item-in-inventory", + "title": "Create New Item in Inventory", + "blocks": [ + { + "type": "paragraph", + "text": "Add a new item to a character's inventory:" + }, + { + "type": "codeBlock", + "lines": [ + "!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\"" + ] + } + ] + }, + { + "id": "apply-status-effects-during-combat", + "title": "Apply Status Effects During Combat", + "blocks": [ + { + "type": "paragraph", + "text": "Apply a debuff to selected enemies in the middle of combat:" + }, + { + "type": "codeBlock", + "lines": [ + "&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}" + ] + } + ] + }, + { + "id": "party-management-examples", + "title": "Party Management Examples", + "blocks": [ + { + "type": "paragraph", + "text": "Give inspiration to all party members after a great roleplay moment:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"" + ] + }, + { + "type": "paragraph", + "text": "Apply a long rest to only party characters among selected tokens:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"" + ] + }, + { + "type": "paragraph", + "text": "Set hostile status for non-party characters among selected tokens:" + }, + { + "type": "codeBlock", + "lines": [ + "!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"" + ] + } + ] + } + ] + }, + { + "id": "for-developers", + "title": "For Developers", + "subsections": [ + { + "id": "registering-observers", + "title": "Registering Observers", + "blocks": [ + { + "type": "paragraph", + "text": "If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:" + }, + { + "type": "codeBlock", + "lines": [ + "ChatSetAttr.registerObserver(event, observer);" + ] + }, + { + "type": "paragraph", + "text": "Where `event` is one of:" + }, + { + "type": "unorderedList", + "items": [ + "`\"add\"` - Called when attributes are created", + "`\"change\"` - Called when attributes are modified", + "`\"destroy\"` - Called when attributes are deleted" + ] + }, + { + "type": "paragraph", + "text": "And `observer` is an event handler function similar to Roll20's built-in event handlers." + }, + { + "type": "paragraph", + "text": "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/docs/help/content.schema.json b/ChatSetAttr/docs/help/content.schema.json new file mode 100644 index 0000000000..ae58db9ec7 --- /dev/null +++ b/ChatSetAttr/docs/help/content.schema.json @@ -0,0 +1,132 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://roll20.net/chatsetattr/help/content.schema.json", + "title": "ChatSetAttr Help Document", + "type": "object", + "additionalProperties": false, + "required": ["title", "introduction", "sections"], + "properties": { + "$schema": { "type": "string" }, + "title": { "type": "string", "minLength": 1 }, + "introduction": { "type": "string", "minLength": 1 }, + "sections": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/section" } + } + }, + "$defs": { + "codeBlock": { + "type": "object", + "additionalProperties": false, + "required": ["lines"], + "properties": { + "lines": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } + }, + "block": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "text"], + "properties": { + "type": { "const": "paragraph" }, + "text": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "lines"], + "properties": { + "type": { "const": "codeBlock" }, + "lines": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "items"], + "properties": { + "type": { "const": "unorderedList" }, + "items": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "items"], + "properties": { + "type": { "const": "orderedList" }, + "items": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["text"], + "properties": { + "text": { "type": "string" }, + "codeBlock": { "$ref": "#/$defs/codeBlock" } + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "text"], + "properties": { + "type": { "const": "note" }, + "text": { "type": "string" }, + "emphasis": { "type": "boolean" } + } + } + ] + }, + "subsection": { + "type": "object", + "additionalProperties": false, + "required": ["title", "blocks"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "blocks": { + "type": "array", + "items": { "$ref": "#/$defs/block" } + } + } + }, + "section": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "blocks": { + "type": "array", + "items": { "$ref": "#/$defs/block" } + }, + "subsections": { + "type": "array", + "items": { "$ref": "#/$defs/subsection" } + } + } + } + } +} diff --git a/ChatSetAttr/package.json b/ChatSetAttr/package.json index b11d50dd77..6e90f33ed5 100644 --- a/ChatSetAttr/package.json +++ b/ChatSetAttr/package.json @@ -8,6 +8,8 @@ "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", + "docs:generate": "tsx scripts/generate-docs.ts", + "docs:check": "tsx scripts/generate-docs.ts --check", "test": "vitest", "test:run": "vitest run", "test:watch": "vitest --watch" @@ -33,6 +35,7 @@ "rollup-plugin-delete": "^3.0.1", "ts-node": "^10.9.2", "tslib": "^2.8.1", + "tsx": "^4.22.4", "typescript": "^5.9.3", "typescript-eslint": "^8.45.0", "underscore": "^1.13.7", diff --git a/ChatSetAttr/script.json b/ChatSetAttr/script.json index 60bee52410..3bf137957f 100644 --- a/ChatSetAttr/script.json +++ b/ChatSetAttr/script.json @@ -2,7 +2,7 @@ "name": "ChatSetAttr", "script": "ChatSetAttr.js", "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 four global configuration options, _playersCanModify_, _playersCanEvaluate_, _useWorkers_, and _playersCanTargetParty_, which can be toggled either on this page or by entering **!setattr-config** in chat. The first 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 _useWorkers_ option will determine if the script triggers sheet workers on use, and should normally be toggled on. The _playersCanTargetParty_ option allows players to use the **--party** target option.\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).", + "description": "# ChatSetAttr\n\nChatSetAttr 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.\n\n## Basic Usage\n\nThe script provides several command formats:\n\n- `!setattr [--options]` - Create or modify attributes\n- `!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values)\n- `!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds)\n- `!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values)\n- `!delattr [--options]` - Delete attributes\n\nEach command requires a target selection option and one or more attributes to modify.\n\n**Basic structure:**\n\n```\n!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2\n```\n\n## Available Commands\n\n### !setattr\n\nCreates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified).\n\n**Example:**\n\n```\n!setattr --sel --hp|25|50 --hp_temp|8\n```\n\nThis would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8.\n\n### !modattr\n\nAdds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`.\n\n**Example:**\n\n```\n!modattr --sel --hp_temp|-5 --hp|6\n```\n\nThis subtracts 5 from `hp_temp` and adds 6 to `hp`.\n\n### !modbattr\n\nAdds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`.\n\n**Example:**\n\n```\n!modbattr --sel --hp_temp|-5 --hp|25\n```\n\nThis subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`.\n\n### !resetattr\n\nResets attributes to their maximum value. Shorthand for `!setattr --reset`.\n\n**Example:**\n\n```\n!resetattr --sel --hp\n```\n\nThis resets `hp` to its maximum value.\n\n### !delattr\n\nDeletes the specified attributes.\n\n**Example:**\n\n```\n!delattr --sel --hp --hp_temp\n```\n\nThis removes the `hp` and `hp_temp` attributes.\n\n## Beacon Computed Values\n\nBeacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.\n\nSome Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.\n\nFor player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired.\n\n**Example:**\n\n```\n!setattr --sel --spellpoints|18\n```\n\nThis will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute.\n\n## Target Selection\n\nOne of these options must be specified to determine which characters will be affected:\n\n### --all\n\nAffects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns.\n\n**Example:**\n\n```\n!resetattr --all --hp\n```\n\n### --allgm\n\nAffects all characters without player controllers (typically NPCs). **GM only**.\n\n**Example:**\n\n```\n!setattr --allgm --reset --hp\n```\n\n### --allplayers\n\nAffects all characters with player controllers (typically PCs).\n\n**Example:**\n\n```\n!setattr --allplayers --mod --hp|-15\n```\n\n### --charid\n\nAffects characters with the specified character IDs. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --charid --hp|150\n```\n\n### --name\n\nAffects characters with the specified names. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"\n```\n\n### --sel\n\nAffects characters represented by currently selected tokens.\n\n**Example:**\n\n```\n!setattr --sel --hp|25 --hp_temp|8\n```\n\n### --sel-party\n\nAffects only party characters represented by currently selected tokens (characters with `inParty` set to true).\n\n**Example:**\n\n```\n!setattr --sel-party --inspiration|1\n```\n\n### --sel-noparty\n\nAffects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set).\n\n**Example:**\n\n```\n!setattr --sel-noparty --npc_status|\"Hostile\"\n```\n\n### --party\n\nAffects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration.\n\n**Example:**\n\n```\n!setattr --party --rest_complete|1\n```\n\n## Attribute Syntax\n\nThe syntax for specifying attributes is:\n\n```\n--attributeName|currentValue|maxValue\n```\n\n- `attributeName` is the name of the attribute to modify\n- `currentValue` is the value to set (optional for some commands)\n- `maxValue` is the maximum value to set (optional)\n\n### Examples:\n\n1. Set current value only:\n```\n--strength|15\n```\n2. Set both current and maximum values:\n```\n--hp|27|35\n```\n3. Set only the maximum value (leave current unchanged):\n```\n--hp||50\n```\n4. Create empty attribute or set to empty:\n```\n--notes|\n```\n5. Use `#` instead of `|` (useful in roll queries):\n```\n--strength#15\n```\n\n## Modifier Options\n\nThese options change how attributes are processed:\n\n### --mod\n\nSee `!modattr` command.\n\n### --modb\n\nSee `!modbattr` command.\n\n### --reset\n\nSee `!resetattr` command.\n\n### --nocreate\n\nPrevents creation of new attributes, only updates existing ones.\n\n**Example:**\n\n```\n!setattr --sel --nocreate --perception|20 --hp|15\n```\n\nThis will only update `perception` or `hp` if it already exists.\n\n### --evaluate\n\nEvaluates JavaScript expressions in attribute values. **GM only by default**.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --hp|2 * 3\n```\n\nThis will set the `hp` attribute to 6.\n\n### --replace\n\nReplaces special characters to prevent Roll20 from evaluating them:\n\n- < becomes [\n- > becomes ]\n- ~ becomes -\n- ; becomes ?\n- ` becomes @\n\nAlso supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?.\n\n**Example:**\n\n```\n!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"\n```\n\nThis stores \"Roll [[1d6]] to succeed\" without evaluating the roll.\n\n## Output Control Options\n\nThese options control the feedback messages generated by the script:\n\n### --silent\n\nSuppresses normal output messages (error messages will still appear).\n\n**Example:**\n\n```\n!setattr --sel --silent --stealth|20\n```\n\n### --mute\n\nSuppresses all output messages, including errors.\n\n**Example:**\n\n```\n!setattr --sel --mute --nocreate --new_value|42\n```\n\n### --fb-public\n\nSends output publicly to the chat instead of whispering to the command sender.\n\n**Example:**\n\n```\n!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"\n```\n\n### --fb-from \n\nChanges the name of the sender for output messages (default is \"ChatSetAttr\").\n\n**Example:**\n\n```\n!setattr --sel --fb-from \"Healing Potion\" --hp|25\n```\n\n### --fb-header \n\nCustomizes the header of the output message.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5\n```\n\n### --fb-content \n\nCustomizes the content of the output message.\n\n**Example:**\n\n```\n!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10\n```\n\n### Special Placeholders\n\nFor use in `--fb-header` and `--fb-content`:\n\n- `_NAMEJ_` - Name of the Jth attribute being changed\n- `_TCURJ_` - Target current value of the Jth attribute\n- `_TMAXJ_` - Target maximum value of the Jth attribute\n\nFor use in `--fb-content` only:\n\n- `_CHARNAME_` - Name of the character\n- `_CURJ_` - Final current value of the Jth attribute\n- `_MAXJ_` - Final maximum value of the Jth attribute\n\n**Important:** The Jth index starts with 0 at the first item.\n\n**Example:**\n\n```\n!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10\n```\n\n## Inline Roll Integration\n\nChatSetAttr can be used within roll templates or combined with inline rolls:\n\n### Within Roll Templates\n\nPlace the command between roll template properties and end it with `!!!`:\n\n```\n&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}\n```\n\n### Using Inline Rolls in Values\n\nInline rolls can be used for attribute values:\n\n```\n!setattr --sel --hp|[[2d6+5]]\n```\n\n### Roll Queries\n\nRoll queries can determine attribute values:\n\n```\n!setattr --sel --hp|?{Set strength to what value?|100}\n```\n\n## Repeating Section Support\n\nChatSetAttr supports working with repeating sections:\n\n### Creating New Repeating Items\n\nUse `CREATE` to create a new row in a repeating section:\n\n```\n!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2\n```\n\n### Modifying Existing Repeating Items\n\nAccess by row ID:\n\n```\n!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"\n```\n\nAccess by index (starts at 0):\n\n```\n!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"\n```\n\n### Deleting Repeating Rows\n\nDelete by row ID:\n\n```\n!delattr --sel --repeating_inventory_ID\n```\n\nDelete by index:\n\n```\n!delattr --sel --repeating_inventory_$0\n```\n\n> **Note:** repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.\n\n## Special Value Expressions\n\n### Attribute References\n\nReference other attribute values using `%attribute_name%`:\n\n```\n!setattr --sel --evaluate --temp_hp|%hp% / 2\n```\n\n### Resetting to Maximum\n\nReset an attribute to its maximum value:\n\n```\n!setattr --sel --hp|%hp_max%\n```\n\n## Global Configuration\n\nThe script has four global configuration options that can be toggled with `!setattr-config`:\n\n### --players-can-modify\n\nAllows players to modify attributes on characters they don't control.\n\n```\n!setattr-config --players-can-modify\n```\n\n### --players-can-evaluate\n\nAllows players to use the `--evaluate` option.\n\n```\n!setattr-config --players-can-evaluate\n```\n\n### --players-can-target-party\n\nAllows players to use the `--party` target option. **GM only by default**.\n\n```\n!setattr-config --players-can-target-party\n```\n\n### --use-workers\n\nToggles whether the script triggers sheet workers when setting attributes.\n\n```\n!setattr-config --use-workers\n```\n\n## Complete Examples\n\n### Basic Combat Example\n\nReduce a character's HP and status after taking damage:\n\n```\n!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"\n```\n\n### Leveling Up a Character\n\nUpdate multiple stats when a character gains a level:\n\n```\n!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public\n```\n\n### Create New Item in Inventory\n\nAdd a new item to a character's inventory:\n\n```\n!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\"\n```\n\n### Apply Status Effects During Combat\n\nApply a debuff to selected enemies in the middle of combat:\n\n```\n&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}\n```\n\n### Party Management Examples\n\nGive inspiration to all party members after a great roleplay moment:\n\n```\n!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"\n```\n\nApply a long rest to only party characters among selected tokens:\n\n```\n!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"\n```\n\nSet hostile status for non-party characters among selected tokens:\n\n```\n!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"\n```\n\n## For Developers\n\n### Registering Observers\n\nIf you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:\n\n```\nChatSetAttr.registerObserver(event, observer);\n```\n\nWhere `event` is one of:\n\n- `\"add\"` - Called when attributes are created\n- `\"change\"` - Called when attributes are modified\n- `\"destroy\"` - Called when attributes are deleted\n\nAnd `observer` is an event handler function similar to Roll20's built-in event handlers.\n\nThis allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface.", "authors": [ "Jakob", "GUD Team" @@ -76,4 +76,4 @@ "0.9.1", "0.9" ] -} \ No newline at end of file +} diff --git a/ChatSetAttr/scripts/generate-docs.ts b/ChatSetAttr/scripts/generate-docs.ts new file mode 100644 index 0000000000..307eb1ecd8 --- /dev/null +++ b/ChatSetAttr/scripts/generate-docs.ts @@ -0,0 +1,77 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import helpContent from "../docs/help/content.json" with { type: "json" }; +import { renderHelpMarkdown } from "../src/templates/help/renderMarkdown.ts"; +import type { HelpDocument } from "../src/templates/help/types.ts"; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const readmePath = join(rootDir, "README.md"); +const scriptJsonPath = join(rootDir, "script.json"); + +const README_HEADER = "\n\n"; + +function loadDocument(): HelpDocument { + return helpContent as HelpDocument; +} + +function buildReadme(doc: HelpDocument): string { + return README_HEADER + renderHelpMarkdown(doc, { includeToc: true }); +} + +function buildScriptDescription(doc: HelpDocument): string { + return renderHelpMarkdown(doc, { includeToc: false }).trim(); +} + +function readScriptJson(): Record { + return JSON.parse(readFileSync(scriptJsonPath, "utf8")) as Record; +} + +function writeScriptJson(scriptJson: Record, description: string): void { + scriptJson.description = description; + writeFileSync(scriptJsonPath, `${JSON.stringify(scriptJson, null, 4)}\n`, "utf8"); +} + +function generateDocs(): { readme: string; description: string } { + const doc = loadDocument(); + const readme = buildReadme(doc); + const description = buildScriptDescription(doc); + writeFileSync(readmePath, readme, "utf8"); + writeScriptJson(readScriptJson(), description); + return { readme, description }; +} + +function checkDocs(): void { + const existingReadme = readFileSync(readmePath, "utf8"); + const existingScript = readScriptJson(); + const existingDescription = String(existingScript.description ?? ""); + + const doc = loadDocument(); + const expectedReadme = buildReadme(doc); + const expectedDescription = buildScriptDescription(doc); + + const errors: string[] = []; + if (existingReadme !== expectedReadme) { + errors.push("README.md is out of date (run pnpm docs:generate)"); + } + if (existingDescription !== expectedDescription) { + errors.push("script.json description is out of date (run pnpm docs:generate)"); + } + + if (errors.length > 0) { + console.error(errors.join("\n")); + process.exit(1); + } + + console.log("Documentation is up to date."); +} + +const checkMode = process.argv.includes("--check"); + +if (checkMode) { + checkDocs(); +} else { + generateDocs(); + console.log("Generated README.md and script.json description."); +} diff --git a/ChatSetAttr/src/__tests__/templates/messages.test.ts b/ChatSetAttr/src/__tests__/templates/messages.test.ts index cf32fdb59f..52808f27c2 100644 --- a/ChatSetAttr/src/__tests__/templates/messages.test.ts +++ b/ChatSetAttr/src/__tests__/templates/messages.test.ts @@ -96,8 +96,8 @@ describe("messages", () => { const result = createChatMessage(header, messages); - expect(result).toContain("Special Characters: & < > \" '"); - expect(result).toContain("Message with & < > \" ' characters"); + expect(result).toContain("Special Characters: & < > " '"); + expect(result).toContain("Message with & < > " ' characters"); expect(result).toContain("Another message with åäö"); }); @@ -233,8 +233,8 @@ describe("messages", () => { const result = createErrorMessage(header, errors); - expect(result).toContain("Special Error Characters: & < > \" '"); - expect(result).toContain("Error with & < > \" ' characters"); + expect(result).toContain("Special Error Characters: & < > " '"); + expect(result).toContain("Error with & < > " ' characters"); expect(result).toContain("Another error with åäö"); }); diff --git a/ChatSetAttr/src/__tests__/unit/helpContent.test.ts b/ChatSetAttr/src/__tests__/unit/helpContent.test.ts new file mode 100644 index 0000000000..4200167c94 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/helpContent.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import helpContent from "../../../docs/help/content.json"; +import { loadHelpDocument } from "../../templates/help/loadContent"; +import { renderHelpHtml } from "../../templates/help/renderHtml"; +import { renderHelpMarkdown } from "../../templates/help/renderMarkdown"; +import type { HelpDocument } from "../../templates/help/types"; + +const SECTION_IDS = [ + "basic-usage", + "available-commands", + "beacon-computed-values", + "target-selection", + "attribute-syntax", + "modifier-options", + "output-control-options", + "inline-roll-integration", + "repeating-section-support", + "special-value-expressions", + "global-configuration", + "complete-examples", + "for-developers", +]; + +describe("help content", () => { + it("should load a document with all expected sections", () => { + const doc = loadHelpDocument(); + expect(doc.title).toBe("ChatSetAttr"); + expect(doc.sections).toHaveLength(SECTION_IDS.length); + expect(doc.sections.map(section => section.id)).toEqual(SECTION_IDS); + }); + + it("should match the on-disk content.json file", () => { + const doc = loadHelpDocument(); + expect(doc).toEqual(helpContent); + }); + + it("should render markdown with a table of contents when requested", () => { + const doc = loadHelpDocument(); + const markdown = renderHelpMarkdown(doc, { includeToc: true }); + expect(markdown).toContain("## Table of Contents"); + expect(markdown).toContain("1. [Basic Usage](#basic-usage)"); + expect(markdown).toContain("## Beacon Computed Values"); + }); + + it("should render markdown without a table of contents for script.json", () => { + const doc = loadHelpDocument(); + const markdown = renderHelpMarkdown(doc, { includeToc: false }); + expect(markdown).not.toContain("## Table of Contents"); + expect(markdown.startsWith("# ChatSetAttr")).toBe(true); + }); + + it("should escape literal angle brackets in handout HTML", () => { + const doc = loadHelpDocument(); + const html = renderHelpHtml(doc, "test-handout-id"); + expect(html).toContain("--fb-from <NAME>"); + expect(html).toContain("Roll <<1d6>> to succeed"); + expect(html).not.toContain("--fb-from "); + }); + + it("should preserve roll template syntax in handout HTML", () => { + const doc = loadHelpDocument(); + const html = renderHelpHtml(doc, "test-handout-id"); + expect(html).toContain("&{template:default}"); + expect(html).toContain("journal.roll20.net/handout/test-handout-id/#Basic%20Usage"); + }); + + it("should render script description matching markdown without TOC", () => { + const doc = loadHelpDocument(); + const scriptJson = JSON.parse( + readFileSync(join(process.cwd(), "script.json"), "utf8"), + ) as { description?: string }; + const expected = renderHelpMarkdown(doc, { includeToc: false }).trim(); + expect(scriptJson.description).toBe(expected); + }); + + it("should render README matching markdown with TOC header", () => { + const doc = loadHelpDocument(); + const readme = readFileSync(join(process.cwd(), "README.md"), "utf8"); + const expected = "\n\n" + + renderHelpMarkdown(doc, { includeToc: true }); + expect(readme).toBe(expected); + }); +}); + +describe("help content fixture", () => { + const fixture: HelpDocument = { + title: "Fixture", + introduction: "Intro with `code` and **bold**.", + sections: [ + { + id: "sample", + title: "Sample", + blocks: [ + { type: "paragraph", text: "Placeholder `--fb-from ` and `<<1d6>>`." }, + { type: "codeBlock", lines: ["&{template:default} {{name=Test}}"] }, + ], + }, + ], + }; + + it("should render fixture markdown predictably", () => { + const markdown = renderHelpMarkdown(fixture, { includeToc: false }); + expect(markdown).toContain("Placeholder `--fb-from ` and `<<1d6>>`."); + expect(markdown).toContain("&{template:default} {{name=Test}}"); + }); + + it("should render fixture HTML with escaped display characters", () => { + const html = renderHelpHtml(fixture, "fixture-handout"); + expect(html).toContain("<NAME>"); + expect(html).toContain("<<1d6>>"); + expect(html).toContain("&{template:default}"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/utils/chat.test.ts b/ChatSetAttr/src/__tests__/utils/chat.test.ts index a950393775..6de87ea5e5 100644 --- a/ChatSetAttr/src/__tests__/utils/chat.test.ts +++ b/ChatSetAttr/src/__tests__/utils/chat.test.ts @@ -6,73 +6,73 @@ 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("
    "); + expect(result.html).toBe("
    "); }); it("should create a tag with text content", () => { const result = h("p", {}, "Hello World"); - expect(result).toBe("

    Hello World

    "); + expect(result.html).toBe("

    Hello World

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

    Text with & < > characters

    "); + expect(result.html).toBe("

    Text with & < > characters

    "); }); it("should handle numeric string children", () => { const result = h("div", {}, "Count: ", "42"); - expect(result).toBe("
    Count: 42
    "); + expect(result.html).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('

    '); + expect(result.html).toBe('

    '); }); it("should handle complex nested attributes", () => { @@ -82,130 +82,130 @@ describe("chat utilities", () => { "data-toggle": "modal", "aria-label": "Main content" }, "Content"); - expect(result).toBe('
    Content
    '); + expect(result.html).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
    '); + expect(result.html).toBe('
    Styled text
    '); }); it("should preserve order of attributes", () => { const result = h("input", { z: "last", a: "first", m: "middle" }); - expect(result).toBe(''); + expect(result.html).toBe(''); }); it("should handle boolean-like attribute values", () => { const result = h("input", { disabled: "true", checked: "false" }); - expect(result).toBe(''); + expect(result.html).toBe(''); }); it("should handle whitespace in children", () => { const result = h("pre", {}, " Code with spaces "); - expect(result).toBe("
      Code with  spaces  
    "); + expect(result.html).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
    "); + expect(result.html).toBe("
    Content
    "); }); it("should handle undefined attribute values", () => { const result = h("div", { class: "test", id: undefined as unknown as string }); - expect(result).toBe('
    '); + expect(result.html).toBe('
    '); }); it("should handle null attribute values", () => { const result = h("div", { class: "test", id: null as unknown as string }); - expect(result).toBe('
    '); + expect(result.html).toBe('
    '); }); it("should handle empty string attribute values", () => { const result = h("input", { type: "text", value: "", placeholder: "" }); - expect(result).toBe(''); + expect(result.html).toBe(''); }); it("should handle zero as attribute value", () => { const result = h("div", { tabindex: "0", "data-count": "0" }); - expect(result).toBe('
    '); + expect(result.html).toBe('
    '); }); it("should handle attributes with special characters in keys", () => { const result = h("div", { "data-test-value": "test", "aria-label": "label" }); - expect(result).toBe('
    '); + expect(result.html).toBe('
    '); }); it("should handle empty tag name", () => { const result = h("", {}, "Content"); - expect(result).toBe("<>Content"); + expect(result.html).toBe("<>Content"); }); it("should handle tag name with numbers", () => { const result = h("h1", {}, "Heading"); - expect(result).toBe("

    Heading

    "); + expect(result.html).toBe("

    Heading

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

    Hello 世界 Café ☕

    "); + expect(result.html).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
    "); + expect(result.html).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
    "); + expect(result.html).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
    "); + expect(result.html).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
    "); + expect(result.html).toBe("
    FirstSecondThird
    "); }); it("should simulate JSX array behavior (like map)", () => { @@ -213,24 +213,24 @@ describe("chat utilities", () => { 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

    "); + expect(result.html).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
    "); + expect(result.html).toBe("
    ABCDE
    "); }); it("should handle empty arrays", () => { const result = h("div", {}, []); - expect(result).toBe("
    "); + expect(result.html).toBe("
    "); }); it("should handle arrays containing empty strings", () => { const children = ["Start", "", "End"]; const result = h("div", {}, children); - expect(result).toBe("
    StartEnd
    "); + expect(result.html).toBe("
    StartEnd
    "); }); }); }); @@ -487,7 +487,7 @@ describe("chat utilities", () => { 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
    '); + expect(result.html).toBe('
    Styled content
    '); }); it("should handle complex nested structures with styles", () => { @@ -498,7 +498,7 @@ describe("chat utilities", () => { const content = h("p", {}, "Some content"); const result = h("div", { style: containerStyle }, header, content); - expect(result).toBe('

    Title

    Some content

    '); + expect(result.html).toBe('

    Title

    Some content

    '); }); it("should handle multiple styled elements", () => { @@ -509,7 +509,7 @@ describe("chat utilities", () => { const link = h("a", { style: linkStyle, href: "#" }, "Link"); const result = h("div", {}, button, " ", link); - expect(result).toBe('
    Link
    '); + expect(result.html).toBe('
    Link
    '); }); }); }); \ No newline at end of file diff --git a/ChatSetAttr/src/env.d.ts b/ChatSetAttr/src/env.d.ts index 8e3a3172a4..5ead373dc6 100644 --- a/ChatSetAttr/src/env.d.ts +++ b/ChatSetAttr/src/env.d.ts @@ -22,12 +22,12 @@ declare global { function h( tagName: string, attributes: Record, - ...children: (string | null | undefined)[] - ): string; + ...children: (string | import("./utils/chat").SafeHtml | null | undefined)[] + ): import("./utils/chat").SafeHtml; var s: typeof import("./utils/chat").s; namespace JSX { - type Element = string; + type Element = import("./utils/chat").SafeHtml; interface IntrinsicElements { [elemName: string]: { [key: string]: string | undefined; diff --git a/ChatSetAttr/src/modules/help.ts b/ChatSetAttr/src/modules/help.ts index 0eb9e334c4..e5f6647044 100644 --- a/ChatSetAttr/src/modules/help.ts +++ b/ChatSetAttr/src/modules/help.ts @@ -1,4 +1,4 @@ -import { createHelpHandout } from "../templates/help"; +import { createHelpHandout } from "../templates/help/index"; export function checkHelpMessage(msg: string): boolean { return msg.trim().toLowerCase().startsWith("!setattrs-help"); diff --git a/ChatSetAttr/src/templates/config.tsx b/ChatSetAttr/src/templates/config.tsx index e63153cdd0..d6ca3bc4f5 100644 --- a/ChatSetAttr/src/templates/config.tsx +++ b/ChatSetAttr/src/templates/config.tsx @@ -68,5 +68,5 @@ export function createConfigMessage(): string {
- ); + ).html; }; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/delay.tsx b/ChatSetAttr/src/templates/delay.tsx index 7b1058a5ce..b9ca0a78de 100644 --- a/ChatSetAttr/src/templates/delay.tsx +++ b/ChatSetAttr/src/templates/delay.tsx @@ -13,5 +13,5 @@ export function createDelayMessage(): string { 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. - ); + ).html; }; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/help.tsx b/ChatSetAttr/src/templates/help.tsx deleted file mode 100644 index 67efac49a2..0000000000 --- a/ChatSetAttr/src/templates/help.tsx +++ /dev/null @@ -1,494 +0,0 @@ -export function createHelpHandout(handoutID: string): string { - - const contents = [ - "Basic Usage", - "Available Commands", - "Beacon Computed Values", - "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 --hp_temp|8
- -

This would set hp to 25, hp_max to 50, hp_temp to 8.

- -

!modattr

- -

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

- -

Example:

-
!modattr --sel --hp_temp|-5 --hp|6
- -

This subtracts 5 from hp_temp and adds 6 to hp.

- -

!modbattr

- -

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

- -

Example:

-
!modbattr --sel --hp_temp|-5 --hp|25
- -

This subtracts 5 from hp_temp but won't reduce it below 0 and increase hp 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
- -

This resets hp to its maximum value.

- -

!delattr

- -

Deletes the specified attributes.

- -

Example:

-
!delattr --sel --hp --hp_temp
- -

This removes the hp and hp_temp attributes.

- -

Beacon Computed Values

- -

Beacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.

- -

Some Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.

- -

For player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with user. like user.spellpoints. They function like attributes and can be created, removed, set, reset, and modified as desired.

- -

Example:

-
!setattr --sel --spellpoints|18
-

This will create the user.spellpoints User Attribute, which can be referenced as either @{selected|user.spellpoints} or @{selected|spellpoints} and operates like an attribute.

- -

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:

-
!resetattr --all --hp
- -

--allgm

- -

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

- -

Example:

-
!setattr --allgm --reset --hp
- -

--allplayers

- -

Affects all characters with player controllers (typically PCs).

- -

Example:

-
!setattr --allplayers --mod --hp|-15
- -

--charid

- -

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

- -

Example:

-
!setattr --charid &lt;ID1&gt; &lt;ID2&gt; --hp|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 --hp_temp|8
- -

--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 --hp|15
- -

This will only update perception or hp 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:

-
    -
  • &lt; becomes [
  • -
  • &gt; becomes ]
  • -
  • ~ becomes -
  • -
  • ; becomes ?
  • -
  • ` becomes @
  • -
- -

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

- -

Example:

-
!setattr --sel --replace --notes|"Roll &lt;&lt;1d6&gt;&gt; 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 &lt;NAME&gt;

- -

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

- -

Example:

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

--fb-header &lt;STRING&gt;

- -

Customizes the header of the output message.

- -

Example:

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

--fb-content &lt;STRING&gt;

- -

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 !!!:

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

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|?&lcub;Set strength to what value?|100&rcub;
- -

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
- -

Note: repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.

- -

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:

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

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/help/index.ts b/ChatSetAttr/src/templates/help/index.ts new file mode 100644 index 0000000000..2e06394bf5 --- /dev/null +++ b/ChatSetAttr/src/templates/help/index.ts @@ -0,0 +1,6 @@ +import { loadHelpDocument } from "./loadContent"; +import { renderHelpHtml } from "./renderHtml"; + +export function createHelpHandout(handoutID: string): string { + return renderHelpHtml(loadHelpDocument(), handoutID); +} diff --git a/ChatSetAttr/src/templates/help/inlineMarkdown.ts b/ChatSetAttr/src/templates/help/inlineMarkdown.ts new file mode 100644 index 0000000000..4dc67b1922 --- /dev/null +++ b/ChatSetAttr/src/templates/help/inlineMarkdown.ts @@ -0,0 +1,45 @@ +import { SafeHtml } from "../../utils/chat"; + +const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`)/g; + +export function renderInlineMarkdown(text: string): string { + return text; +} + +export function renderInlineHtml(text: string): SafeHtml { + const parts: string[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + INLINE_PATTERN.lastIndex = 0; + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, match.index))); + } + const token = match[0]; + if (token.startsWith("**")) { + parts.push(`${escapeHtml(token.slice(2, -2))}`); + } else { + parts.push(`${escapeHtml(token.slice(1, -1))}`); + } + lastIndex = match.index + token.length; + } + + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + + return new SafeHtml(parts.join("")); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function joinCodeLines(lines: string[]): string { + return lines.join("\n"); +} diff --git a/ChatSetAttr/src/templates/help/loadContent.ts b/ChatSetAttr/src/templates/help/loadContent.ts new file mode 100644 index 0000000000..4455354012 --- /dev/null +++ b/ChatSetAttr/src/templates/help/loadContent.ts @@ -0,0 +1,6 @@ +import type { HelpDocument } from "./types"; +import helpContent from "../../../docs/help/content.json"; + +export function loadHelpDocument(): HelpDocument { + return helpContent as HelpDocument; +} diff --git a/ChatSetAttr/src/templates/help/renderHtml.ts b/ChatSetAttr/src/templates/help/renderHtml.ts new file mode 100644 index 0000000000..3cde0d8c14 --- /dev/null +++ b/ChatSetAttr/src/templates/help/renderHtml.ts @@ -0,0 +1,99 @@ +import { h, SafeHtml } from "../../utils/chat"; +import type { + HelpBlock, + HelpDocument, + HelpSection, + HelpSubsection, +} from "./types"; +import { joinCodeLines, renderInlineHtml } from "./inlineMarkdown"; + +type Child = string | SafeHtml | null | undefined | Child[]; + +function concatHtml(...parts: SafeHtml[]): SafeHtml { + return new SafeHtml(parts.map(part => part.html).join("")); +} + +function renderBlocks(blocks: HelpBlock[] | undefined): SafeHtml[] { + if (!blocks) return []; + + const parts: SafeHtml[] = []; + for (const block of blocks) { + switch (block.type) { + case "paragraph": + parts.push(h("p", {}, renderInlineHtml(block.text))); + break; + case "codeBlock": + parts.push(h("pre", {}, h("code", {}, joinCodeLines(block.lines)))); + break; + case "unorderedList": + parts.push(h( + "ul", + {}, + ...block.items.map(item => h("li", {}, renderInlineHtml(item))), + )); + break; + case "orderedList": + parts.push(h( + "ol", + {}, + ...block.items.map(item => { + const children: Child[] = [renderInlineHtml(item.text)]; + if (item.codeBlock) { + children.push(h("pre", {}, h("code", {}, joinCodeLines(item.codeBlock.lines)))); + } + return h("li", {}, ...children); + }), + )); + break; + case "note": + parts.push(block.emphasis + ? h("p", {}, h("em", {}, h("strong", {}, "Note:"), " ", renderInlineHtml(block.text))) + : h("p", {}, renderInlineHtml(block.text))); + break; + } + } + return parts; +} + +function renderSubsection(subsection: HelpSubsection): SafeHtml { + return concatHtml( + h("h3", {}, subsection.title), + ...renderBlocks(subsection.blocks), + ); +} + +function renderSection(section: HelpSection): SafeHtml { + return concatHtml( + h("h2", { id: section.id }, section.title), + ...renderBlocks(section.blocks), + ...(section.subsections?.map(renderSubsection) ?? []), + ); +} + +function renderTableOfContents(doc: HelpDocument, handoutID: string): SafeHtml { + return h( + "ol", + {}, + ...doc.sections.map(section => h( + "li", + {}, + h( + "a", + { + href: `http://journal.roll20.net/handout/${handoutID}/#${section.title.replace(/\s+/g, "%20")}`, + }, + section.title, + ), + )), + ); +} + +export function renderHelpHtml(doc: HelpDocument, handoutID: string): string { + return concatHtml( + h("h1", {}, doc.title), + h("p", {}, doc.introduction), + h("h2", {}, "Table of Contents"), + renderTableOfContents(doc, handoutID), + ...doc.sections.map(section => renderSection(section)), + ).html; +} diff --git a/ChatSetAttr/src/templates/help/renderMarkdown.ts b/ChatSetAttr/src/templates/help/renderMarkdown.ts new file mode 100644 index 0000000000..b879f8af1f --- /dev/null +++ b/ChatSetAttr/src/templates/help/renderMarkdown.ts @@ -0,0 +1,91 @@ +import type { + HelpBlock, + HelpDocument, + HelpSection, + HelpSubsection, + RenderMarkdownOptions, +} from "./types"; +import { joinCodeLines, renderInlineMarkdown } from "./inlineMarkdown"; + +function slugify(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function renderCodeFence(lines: string[]): string { + return "```\n" + joinCodeLines(lines) + "\n```"; +} + +function renderBlocks(blocks: HelpBlock[] | undefined, lines: string[]): void { + if (!blocks) return; + + for (const block of blocks) { + switch (block.type) { + case "paragraph": + lines.push("", renderInlineMarkdown(block.text)); + break; + case "codeBlock": + lines.push("", renderCodeFence(block.lines)); + break; + case "unorderedList": + lines.push(""); + for (const item of block.items) { + lines.push(`- ${renderInlineMarkdown(item)}`); + } + break; + case "orderedList": + lines.push(""); + block.items.forEach((item, index) => { + lines.push(`${index + 1}. ${renderInlineMarkdown(item.text)}`); + if (item.codeBlock) { + lines.push(renderCodeFence(item.codeBlock.lines)); + } + }); + break; + case "note": + lines.push("", block.emphasis + ? `> **Note:** ${renderInlineMarkdown(block.text)}` + : `> ${renderInlineMarkdown(block.text)}`); + break; + } + } +} + +function renderSubsection(subsection: HelpSubsection, lines: string[]): void { + lines.push("", `### ${subsection.title}`); + renderBlocks(subsection.blocks, lines); +} + +function renderSection(section: HelpSection, lines: string[]): void { + lines.push("", `## ${section.title}`); + renderBlocks(section.blocks, lines); + section.subsections?.forEach(subsection => renderSubsection(subsection, lines)); +} + +export function renderHelpMarkdown( + doc: HelpDocument, + options: RenderMarkdownOptions = {}, +): string { + const lines: string[] = [ + `# ${doc.title}`, + "", + doc.introduction, + ]; + + if (options.includeToc) { + lines.push("", "## Table of Contents", ""); + doc.sections.forEach((section, index) => { + lines.push(`${index + 1}. [${section.title}](#${section.id})`); + }); + } + + doc.sections.forEach(section => renderSection(section, lines)); + + return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"; +} + +export function getSubsectionAnchor(subsection: HelpSubsection): string { + return subsection.id ?? slugify(subsection.title); +} diff --git a/ChatSetAttr/src/templates/help/types.ts b/ChatSetAttr/src/templates/help/types.ts new file mode 100644 index 0000000000..e904004206 --- /dev/null +++ b/ChatSetAttr/src/templates/help/types.ts @@ -0,0 +1,63 @@ +export type CodeBlock = { + lines: string[]; +}; + +export type ParagraphBlock = { + type: "paragraph"; + text: string; +}; + +export type CodeBlockBlock = { + type: "codeBlock"; +} & CodeBlock; + +export type UnorderedListBlock = { + type: "unorderedList"; + items: string[]; +}; + +export type OrderedListItem = { + text: string; + codeBlock?: CodeBlock; +}; + +export type OrderedListBlock = { + type: "orderedList"; + items: OrderedListItem[]; +}; + +export type NoteBlock = { + type: "note"; + text: string; + emphasis?: boolean; +}; + +export type HelpBlock = + | ParagraphBlock + | CodeBlockBlock + | UnorderedListBlock + | OrderedListBlock + | NoteBlock; + +export type HelpSubsection = { + id?: string; + title: string; + blocks: HelpBlock[]; +}; + +export type HelpSection = { + id: string; + title: string; + blocks?: HelpBlock[]; + subsections?: HelpSubsection[]; +}; + +export type HelpDocument = { + title: string; + introduction: string; + sections: HelpSection[]; +}; + +export type RenderMarkdownOptions = { + includeToc?: boolean; +}; diff --git a/ChatSetAttr/src/templates/messages.tsx b/ChatSetAttr/src/templates/messages.tsx index 48ed8484a3..7d172a06ef 100644 --- a/ChatSetAttr/src/templates/messages.tsx +++ b/ChatSetAttr/src/templates/messages.tsx @@ -42,7 +42,7 @@ function createMessage( {messages.map(message =>

{message}

)} - ); + ).html; } // #region Chat Message Function diff --git a/ChatSetAttr/src/templates/notification.tsx b/ChatSetAttr/src/templates/notification.tsx index 89e208ea43..e0d8150172 100644 --- a/ChatSetAttr/src/templates/notification.tsx +++ b/ChatSetAttr/src/templates/notification.tsx @@ -13,5 +13,5 @@ export function createNotifyMessage(title: string, content: string): string { {content} - ); + ).html; }; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/versions/2.0.0.tsx b/ChatSetAttr/src/templates/versions/2.0.0.tsx index 90eed76b9e..27ef8344c5 100644 --- a/ChatSetAttr/src/templates/versions/2.0.0.tsx +++ b/ChatSetAttr/src/templates/versions/2.0.0.tsx @@ -12,7 +12,7 @@ const PARAGRAPH_SPACING_STYLE = s({ marginBottom: "8px", }); -export function createVersionMessage() { +export function createVersionMessage(): string { return (

ChatSetAttr has been updated to version 2.0!

@@ -35,5 +35,5 @@ export function createVersionMessage() { Create Help Handout
- ); + ).html; } \ No newline at end of file diff --git a/ChatSetAttr/src/templates/welcome.tsx b/ChatSetAttr/src/templates/welcome.tsx index 1a040da682..6f5029bf9c 100644 --- a/ChatSetAttr/src/templates/welcome.tsx +++ b/ChatSetAttr/src/templates/welcome.tsx @@ -10,5 +10,5 @@ export function createWelcomeMessage(): string {

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

Create Journal Handout

- ); + ).html; } \ No newline at end of file diff --git a/ChatSetAttr/src/utils/chat.ts b/ChatSetAttr/src/utils/chat.ts index d713d13f60..364ddfc260 100644 --- a/ChatSetAttr/src/utils/chat.ts +++ b/ChatSetAttr/src/utils/chat.ts @@ -1,7 +1,7 @@ // #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 = ""; @@ -10,23 +10,44 @@ export function s(styleObject: Record = {}) { style += `${kebabKey}: ${value};`; } return style; -}; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export class SafeHtml { + constructor(public readonly html: string) {} +} + +function renderChild(child: Child): string { + if (child instanceof SafeHtml) { + return child.html; + } + if (typeof child === "string") { + return escapeHtml(child); + } + return ""; +} // #region JSX Helper -type Child = string | null | undefined | Child[]; +type Child = string | SafeHtml | null | undefined | Child[]; export function h( tagName: string, attributes: Record = {}, ...children: Child[] -): string { +): SafeHtml { const attrs = Object.entries(attributes ?? {}) - .map(([key, value]) => ` ${key}="${value}"`) + .map(([key, value]) => ` ${key}="${escapeHtml(String(value))}"`) .join(""); - // Deeply flatten arrays and filter out null/undefined values const flattenedChildren = children.flat(10).filter(child => child != null); - const childrenContent = flattenedChildren.join(""); + const childrenContent = flattenedChildren.map(renderChild).join(""); - return `<${tagName}${attrs}>${childrenContent}`; -}; \ No newline at end of file + return new SafeHtml(`<${tagName}${attrs}>${childrenContent}`); +} diff --git a/ChatSetAttr/tsconfig.json b/ChatSetAttr/tsconfig.json index c7fbe04532..42f0d3a30f 100644 --- a/ChatSetAttr/tsconfig.json +++ b/ChatSetAttr/tsconfig.json @@ -8,6 +8,7 @@ "esModuleInterop": true, "skipLibCheck": true, "sourceMap": false, + "resolveJsonModule": true, "jsx": "react", "jsxFactory": "h" }, diff --git a/ChatSetAttr/tsconfig.script.json b/ChatSetAttr/tsconfig.script.json index 810e7c1f9b..791aad4282 100644 --- a/ChatSetAttr/tsconfig.script.json +++ b/ChatSetAttr/tsconfig.script.json @@ -7,10 +7,11 @@ "esModuleInterop": true, "skipLibCheck": true, "sourceMap": false, + "resolveJsonModule": true, "jsx": "react", "jsxFactory": "h" }, - "include": ["src", "rollup.config.ts"], + "include": ["src", "docs/help/content.json", "rollup.config.ts"], "exclude": [ "node_modules", "src/__tests__/**", diff --git a/ChatSetAttr/tsconfig.vitest.json b/ChatSetAttr/tsconfig.vitest.json index cf58abcfc6..80918112c2 100644 --- a/ChatSetAttr/tsconfig.vitest.json +++ b/ChatSetAttr/tsconfig.vitest.json @@ -5,6 +5,7 @@ }, "include": [ "src/env.d.ts", + "docs/help/content.json", "**/*.test.ts", "**/*.spec.ts", "**/*.mock.ts", From 60891bf590d71cebe82d4584c36e1fa406f12f06 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 12 Jun 2026 12:12:19 -0500 Subject: [PATCH 25/38] Documentation auto-update, command name fixes, maintainer document. --- ChatSetAttr/2.0/ChatSetAttr.js | 60 +- ChatSetAttr/ChatSetAttr.js | 60 +- ChatSetAttr/MAINTAINER.md | 180 ++ ChatSetAttr/docs/help/content.revision.json | 4 + ChatSetAttr/package.json | 4 +- ChatSetAttr/scripts/generate-docs.ts | 49 +- .../integration/legacyAttributes.test.ts | 6 +- .../src/__tests__/legacy/ChatSetAttr.js | 1546 ++++++++--------- ChatSetAttr/src/__tests__/unit/config.test.ts | 6 + ChatSetAttr/src/__tests__/unit/help.test.ts | 118 ++ .../src/__tests__/unit/helpContent.test.ts | 16 + ChatSetAttr/src/index.ts | 2 + ChatSetAttr/src/modules/config.ts | 2 + ChatSetAttr/src/modules/help.ts | 54 +- ChatSetAttr/src/templates/help/contentHash.ts | 7 + .../src/templates/help/loadContentRevision.ts | 16 + ChatSetAttr/src/templates/versions/2.0.0.tsx | 4 +- ChatSetAttr/src/templates/welcome.tsx | 2 +- ChatSetAttr/tsconfig.script.json | 2 +- ChatSetAttr/tsconfig.vitest.json | 1 + ChatSetAttr/vitest.config.ts | 6 + 21 files changed, 1322 insertions(+), 823 deletions(-) create mode 100644 ChatSetAttr/MAINTAINER.md create mode 100644 ChatSetAttr/docs/help/content.revision.json create mode 100644 ChatSetAttr/src/__tests__/unit/help.test.ts create mode 100644 ChatSetAttr/src/templates/help/contentHash.ts create mode 100644 ChatSetAttr/src/templates/help/loadContentRevision.ts diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 849c20fcf4..c6be3ec09d 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -147,7 +147,7 @@ var ChatSetAttr = (function (exports) { h("code", null, "!setattr-help"), " command or click the button below:"), h("p", null, - h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))).html; + h("a", { href: "!setattr-help", style: buttonStyle }, "Create Journal Handout")))).html; } function getWhisperPrefix(playerID) { @@ -277,6 +277,7 @@ var ChatSetAttr = (function (exports) { playersCanModify: false, playersCanEvaluate: false, useWorkers: true, + helpContentUpdatedAt: 0, flags: [], }; function parseGlobalConfigCheckbox(g, label, valueField) { @@ -2434,24 +2435,56 @@ var ChatSetAttr = (function (exports) { return renderHelpHtml(loadHelpDocument(), handoutID); } + var updatedAt = 1781273463973; + var contentRevision = { + updatedAt: updatedAt + }; + + const revision = contentRevision; + function getBundledHelpContentUpdatedAt() { + return revision.updatedAt; + } + + const HELP_COMMAND = "!setattr-help"; + const HELP_HANDOUT_NAME = "ChatSetAttr Help"; function checkHelpMessage(msg) { - return msg.trim().toLowerCase().startsWith("!setattrs-help"); + return msg.trim().toLowerCase().startsWith(HELP_COMMAND); } - function handleHelpCommand() { - let handout = findObjs({ + function findHelpHandout() { + return findObjs({ _type: "handout", - name: "ChatSetAttr Help", + name: HELP_HANDOUT_NAME, })[0]; + } + function applyHelpContentToHandout(handout) { + const helpContent = createHelpHandout(handout.id); + const bundledAt = getBundledHelpContentUpdatedAt(); + handout.set({ + inplayerjournals: "all", + notes: helpContent, + }); + setConfig({ helpContentUpdatedAt: bundledAt }); + } + function handleHelpCommand() { + let handout = findHelpHandout(); if (!handout) { handout = createObj("handout", { - name: "ChatSetAttr Help", + name: HELP_HANDOUT_NAME, }); } - const helpContent = createHelpHandout(handout.id); - handout.set({ - "inplayerjournals": "all", - "notes": helpContent, - }); + applyHelpContentToHandout(handout); + } + function syncHelpHandoutOnStartup() { + const handout = findHelpHandout(); + if (!handout) { + return; + } + const bundledAt = getBundledHelpContentUpdatedAt(); + const stateAt = getConfig().helpContentUpdatedAt; + if (stateAt >= bundledAt) { + return; + } + applyHelpContentToHandout(handout); } function inlineRollValue(roll) { @@ -3495,9 +3528,9 @@ var ChatSetAttr = (function (exports) { h("div", { style: PARAGRAPH_SPACING_STYLE }, h("strong", null, "If you want to create a handout with the updated documentation, use the command ", - h("code", null, "!setattrs-help"), + h("code", null, "!setattr-help"), " or click the button below"), - h("a", { href: "!setattrs-help" }, "Create Help Handout")))).html; + h("a", { href: "!setattr-help" }, "Create Help Handout")))).html; } const v2_0 = { @@ -3590,6 +3623,7 @@ var ChatSetAttr = (function (exports) { on("ready", () => { checkGlobalConfig(); registerHandlers(); + syncHelpHandoutOnStartup(); update(); welcome(); }); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 849c20fcf4..c6be3ec09d 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -147,7 +147,7 @@ var ChatSetAttr = (function (exports) { h("code", null, "!setattr-help"), " command or click the button below:"), h("p", null, - h("a", { href: "!setattrs-help", style: buttonStyle }, "Create Journal Handout")))).html; + h("a", { href: "!setattr-help", style: buttonStyle }, "Create Journal Handout")))).html; } function getWhisperPrefix(playerID) { @@ -277,6 +277,7 @@ var ChatSetAttr = (function (exports) { playersCanModify: false, playersCanEvaluate: false, useWorkers: true, + helpContentUpdatedAt: 0, flags: [], }; function parseGlobalConfigCheckbox(g, label, valueField) { @@ -2434,24 +2435,56 @@ var ChatSetAttr = (function (exports) { return renderHelpHtml(loadHelpDocument(), handoutID); } + var updatedAt = 1781273463973; + var contentRevision = { + updatedAt: updatedAt + }; + + const revision = contentRevision; + function getBundledHelpContentUpdatedAt() { + return revision.updatedAt; + } + + const HELP_COMMAND = "!setattr-help"; + const HELP_HANDOUT_NAME = "ChatSetAttr Help"; function checkHelpMessage(msg) { - return msg.trim().toLowerCase().startsWith("!setattrs-help"); + return msg.trim().toLowerCase().startsWith(HELP_COMMAND); } - function handleHelpCommand() { - let handout = findObjs({ + function findHelpHandout() { + return findObjs({ _type: "handout", - name: "ChatSetAttr Help", + name: HELP_HANDOUT_NAME, })[0]; + } + function applyHelpContentToHandout(handout) { + const helpContent = createHelpHandout(handout.id); + const bundledAt = getBundledHelpContentUpdatedAt(); + handout.set({ + inplayerjournals: "all", + notes: helpContent, + }); + setConfig({ helpContentUpdatedAt: bundledAt }); + } + function handleHelpCommand() { + let handout = findHelpHandout(); if (!handout) { handout = createObj("handout", { - name: "ChatSetAttr Help", + name: HELP_HANDOUT_NAME, }); } - const helpContent = createHelpHandout(handout.id); - handout.set({ - "inplayerjournals": "all", - "notes": helpContent, - }); + applyHelpContentToHandout(handout); + } + function syncHelpHandoutOnStartup() { + const handout = findHelpHandout(); + if (!handout) { + return; + } + const bundledAt = getBundledHelpContentUpdatedAt(); + const stateAt = getConfig().helpContentUpdatedAt; + if (stateAt >= bundledAt) { + return; + } + applyHelpContentToHandout(handout); } function inlineRollValue(roll) { @@ -3495,9 +3528,9 @@ var ChatSetAttr = (function (exports) { h("div", { style: PARAGRAPH_SPACING_STYLE }, h("strong", null, "If you want to create a handout with the updated documentation, use the command ", - h("code", null, "!setattrs-help"), + h("code", null, "!setattr-help"), " or click the button below"), - h("a", { href: "!setattrs-help" }, "Create Help Handout")))).html; + h("a", { href: "!setattr-help" }, "Create Help Handout")))).html; } const v2_0 = { @@ -3590,6 +3623,7 @@ var ChatSetAttr = (function (exports) { on("ready", () => { checkGlobalConfig(); registerHandlers(); + syncHelpHandoutOnStartup(); update(); welcome(); }); diff --git a/ChatSetAttr/MAINTAINER.md b/ChatSetAttr/MAINTAINER.md new file mode 100644 index 0000000000..7451211b9c --- /dev/null +++ b/ChatSetAttr/MAINTAINER.md @@ -0,0 +1,180 @@ +# ChatSetAttr — Maintainer Guide + +This document is for developers working on the ChatSetAttr Roll20 API script. User-facing documentation lives in the generated [`README.md`](README.md) and in-game via `!setattr-help`. + +## Prerequisites + +- Node.js 18 or newer +- npm (used below; `pnpm` and `yarn` work equivalently if you prefer) + +Install dependencies from the project root: + +```bash +npm install +``` + +ChatSetAttr depends on two sibling packages that must be present in the Roll20 game at runtime: + +- **libSmartAttributes** +- **libUUID** + +These are linked as dev dependencies for local development and testing. + +## Project layout + +| Path | Purpose | +|------|---------| +| `src/` | TypeScript source (modules, templates, tests) | +| `docs/help/content.json` | Single source of truth for user documentation | +| `scripts/generate-docs.ts` | Regenerates README, script description, and content revision | +| `script.json` | Roll20 One-Click metadata (version, description, options) | +| `rollup.config.ts` | Bundles `src/index.ts` into deployable `.js` files | + +Source is written in TypeScript with JSX-style templates (`h()` helper) for HTML chat and handout output. Rollup produces an IIFE bundle named `ChatSetAttr.js`, plus a copy under `/ChatSetAttr.js` (for example `2.0/ChatSetAttr.js`). + +## Typical workflow + +A normal change goes through these phases in order. + +### 1. Edit source + +Implement changes under `src/`. Add or update unit tests under `src/__tests__/`. + +### 2. Lint + +```bash +npm run lint +``` + +Auto-fix where possible: + +```bash +npm run lint:fix +``` + +### 3. Test + +Run the full suite once (CI-style): + +```bash +npm run test:run +``` + +During development, use watch mode: + +```bash +npm test +# or +npm run test:watch +``` + +### 4. Coverage (optional but recommended before larger changes) + +```bash +npm run test:coverage +``` + +This prints a summary table and writes an HTML report to `coverage/index.html`. + +### 5. Documentation + +User docs are **not** edited in README or `script.json` directly. + +1. Edit [`docs/help/content.json`](docs/help/content.json) only. +2. See [`docs/help/README.md`](docs/help/README.md) for block types and inline markup rules. +3. Regenerate derived files: + + ```bash + npm run docs:generate + ``` + + This updates: + + - `README.md` + - `script.json` → `description` + - `docs/help/content.revision.json` (content hash + `updatedAt` timestamp; bumps only when `content.json` changes) + +4. Verify nothing is stale: + + ```bash + npm run docs:check + ``` + +At runtime, if a player has already created the help handout (`!setattr-help`), the script auto-refreshes it on API startup when the bundled revision is newer than `state.ChatSetAttr.helpContentUpdatedAt`. + +### 6. Build + +Produce the Roll20-ready bundle: + +```bash +npm run build +``` + +Output: + +- `ChatSetAttr.js` — latest build at repo root +- `/ChatSetAttr.js` — versioned copy (version comes from `script.json`) + +For continuous rebuilds while editing: + +```bash +npm start +``` + +### Pre-release checklist + +Before publishing a new build to Roll20: + +1. `npm run lint` +2. `npm run test:run` +3. `npm run docs:check` (run `npm run docs:generate` first if docs changed) +4. `npm run build` +5. Confirm `script.json` `version` matches the intended release +6. Upload the appropriate `ChatSetAttr.js` (or update the One-Click script source) + +## Adding a new script version + +Version migrations run automatically on API `ready` via [`src/modules/versioning.ts`](src/modules/versioning.ts). Each migration is a `VersionObject` with: + +- `appliesTo` — comparison against the stored version (e.g. `"<=1.10"`, `"<2.0"`) +- `version` — version string written to state after the migration runs +- `update` — function that performs one-time upgrade work (state changes, notifications, etc.) + +To add a new version (for example 2.1): + +1. **Bump `script.json`** — set `"version"` to the new release string. + +2. **Create a version module** — add `src/versions/2.1.0.ts` exporting a `VersionObject`. Follow the pattern in [`src/versions/2.0.0.ts`](src/versions/2.0.0.ts): migrate `state.ChatSetAttr` as needed and optionally notify GMs. + +3. **Create an update message template** (optional) — add `src/templates/versions/2.1.0.tsx` for the in-game changelog HTML, similar to [`src/templates/versions/2.0.0.tsx`](src/templates/versions/2.0.0.tsx). + +4. **Register the migration** — import the new object and append it to `VERSION_HISTORY` in `src/modules/versioning.ts`. Order matters: migrations are evaluated sequentially. + +5. **Update default config** if new options are added — extend `DEFAULT_CONFIG` and `GLOBAL_CONFIG_OPTIONS` in [`src/modules/config.ts`](src/modules/config.ts), and add matching `useroptions` entries in `script.json` when appropriate. + +6. **Add tests** — cover migration logic and any new config flags in `src/__tests__/unit/`. + +7. **Update user docs** — edit `docs/help/content.json`, then `npm run docs:generate`. + +8. **Build and verify** — `npm run test:run && npm run build`. + +On first load after upgrade, campaigns whose `state.ChatSetAttr.version` satisfies `appliesTo` will run the migration once and advance the stored version. + +## Configuration and state + +Runtime settings persist in `state.ChatSetAttr`. Global One-Click checkboxes sync through `checkGlobalConfig()` on startup. See `src/modules/config.ts` for the full schema. + +Flags such as `welcome` (first-run message) and `helpContentUpdatedAt` (last applied help revision) also live in state and should be considered when writing migrations or tests. + +## Package manager notes + +All scripts above use `npm run ', + "

Body

", + ); + + expect(message).toContain("<script>alert("x")</script>"); + expect(message).toContain("

Body

"); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/config.test.ts b/ChatSetAttr/src/__tests__/unit/config.test.ts index 453be06ce4..2267afcc2d 100644 --- a/ChatSetAttr/src/__tests__/unit/config.test.ts +++ b/ChatSetAttr/src/__tests__/unit/config.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { checkGlobalConfig, getConfig, setConfig } from "../../modules/config"; +import { + checkGlobalConfig, + getConfig, + getPersistedSchemaVersion, + getStateSchemaVersion, + persistStateVersionMetadata, + setConfig, + syncScriptVersion, +} from "../../modules/config"; const GLOBAL_CONFIG_LABELS = { playersCanModify: "Players can modify all characters", @@ -28,6 +36,98 @@ describe("config", () => { }; }); + describe("getStateSchemaVersion", () => { + it("should return 0 for missing values", () => { + expect(getStateSchemaVersion(undefined)).toBe(0); + expect(getStateSchemaVersion(null)).toBe(0); + }); + + it("should return numeric schema versions as-is", () => { + expect(getStateSchemaVersion(3)).toBe(3); + expect(getStateSchemaVersion(4)).toBe(4); + }); + + it("should return numeric strings as schema versions", () => { + expect(getStateSchemaVersion("3")).toBe(3); + expect(getStateSchemaVersion("4")).toBe(4); + }); + + it("should return 0 for non-numeric version strings", () => { + expect(getStateSchemaVersion("1.10")).toBe(0); + expect(getStateSchemaVersion("2.0")).toBe(0); + }); + }); + + describe("getPersistedSchemaVersion", () => { + it("should return 0 when version is missing", () => { + global.state.ChatSetAttr = { + flags: ["welcome"], + helpContentUpdatedAt: 123, + }; + expect(getPersistedSchemaVersion()).toBe(0); + }); + + it("should return persisted schema version when present", () => { + global.state.ChatSetAttr = { version: 3 }; + expect(getPersistedSchemaVersion()).toBe(3); + + global.state.ChatSetAttr = { version: 4 }; + expect(getPersistedSchemaVersion()).toBe(4); + }); + }); + + describe("persistStateVersionMetadata", () => { + it("should persist only scriptVersion when schema version is missing", () => { + global.state.ChatSetAttr = { + flags: ["welcome"], + helpContentUpdatedAt: 1781273463973, + useWorkers: false, + }; + + persistStateVersionMetadata(); + + expect(Object.hasOwn(global.state.ChatSetAttr, "version")).toBe(false); + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + + it("should persist normalized schema version and scriptVersion when version is stored as a string", () => { + global.state.ChatSetAttr = { version: "3" }; + + persistStateVersionMetadata(); + + expect(global.state.ChatSetAttr.version).toBe(3); + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + + it("should persist scriptVersion when schema version is already stored", () => { + global.state.ChatSetAttr = { version: 4 }; + + persistStateVersionMetadata(); + + expect(global.state.ChatSetAttr.version).toBe(4); + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + }); + + describe("syncScriptVersion", () => { + it("should persist script.json version in state", () => { + global.state.ChatSetAttr = { scriptVersion: "1.10" }; + + syncScriptVersion(); + + expect(global.state.ChatSetAttr.scriptVersion).toBe("2.0"); + }); + + it("should not write state when script version and schema version are already current", () => { + global.state.ChatSetAttr = { version: 4, scriptVersion: "2.0" }; + const before = { ...global.state.ChatSetAttr }; + + syncScriptVersion(); + + expect(global.state.ChatSetAttr).toEqual(before); + }); + }); + describe("getConfig", () => { it("should return default config when no state config exists", () => { // Clear the state entirely @@ -36,7 +136,8 @@ describe("config", () => { const config = getConfig(); expect(config).toEqual({ - version: "2.0", + version: 4, + scriptVersion: "2.0", globalconfigCache: { lastsaved: 0 }, @@ -56,7 +157,8 @@ describe("config", () => { const config = getConfig(); expect(config).toEqual({ - version: "2.0", + version: 4, + scriptVersion: "2.0", globalconfigCache: { lastsaved: 0 }, @@ -75,7 +177,8 @@ describe("config", () => { const config = getConfig(); expect(config).toEqual({ - version: "2.0", + version: 4, + scriptVersion: "2.0", globalconfigCache: { lastsaved: 0 }, @@ -97,7 +200,8 @@ describe("config", () => { const config = getConfig(); expect(config).toEqual({ - version: "2.0", + version: 4, + scriptVersion: "2.0", globalconfigCache: { lastsaved: 0 }, @@ -125,6 +229,7 @@ describe("config", () => { expect(config).toEqual({ version: 4, + scriptVersion: "2.0", globalconfigCache: { lastsaved: 1234567890 }, @@ -149,7 +254,7 @@ describe("config", () => { expect(config.globalconfigCache).toEqual({ lastsaved: 9876543210 }); - expect(config.version).toBe("2.0"); + expect(config.version).toBe(4); expect(config.playersCanModify).toBe(false); }); @@ -163,7 +268,8 @@ describe("config", () => { const config = getConfig(); expect(config).toEqual({ - version: "2.0", + version: 4, + scriptVersion: "2.0", globalconfigCache: { lastsaved: 0 }, @@ -487,7 +593,7 @@ describe("config", () => { expect(config.playersCanModify).toBe(true); expect(config.playersCanEvaluate).toBe(true); expect(config.useWorkers).toBe(false); - expect(config.version).toBe("2.0"); // Default value + expect(config.version).toBe(4); // Default value }); it("should handle overriding previously set values", () => { diff --git a/ChatSetAttr/src/__tests__/unit/helpContent.test.ts b/ChatSetAttr/src/__tests__/unit/helpContent.test.ts index 9dc9bfd006..8c6a44b8e4 100644 --- a/ChatSetAttr/src/__tests__/unit/helpContent.test.ts +++ b/ChatSetAttr/src/__tests__/unit/helpContent.test.ts @@ -1,132 +1,118 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import helpContent from "../../../docs/help/content.json"; -import { hashHelpContentFile } from "../../templates/help/contentHash"; import { loadHelpDocument } from "../../templates/help/loadContent"; -import { getBundledHelpContentHash, getBundledHelpContentUpdatedAt } from "../../templates/help/loadContentRevision"; import { renderHelpHtml } from "../../templates/help/renderHtml"; import { renderHelpMarkdown } from "../../templates/help/renderMarkdown"; import type { HelpDocument } from "../../templates/help/types"; -const contentPath = join(process.cwd(), "docs/help/content.json"); -const revisionPath = join(process.cwd(), "docs/help/content.revision.json"); - -const SECTION_IDS = [ - "basic-usage", - "available-commands", - "beacon-computed-values", - "target-selection", - "attribute-syntax", - "modifier-options", - "output-control-options", - "inline-roll-integration", - "repeating-section-support", - "special-value-expressions", - "global-configuration", - "complete-examples", - "for-developers", -]; - -describe("help content", () => { - it("should load a document with all expected sections", () => { - const doc = loadHelpDocument(); - expect(doc.title).toBe("ChatSetAttr"); - expect(doc.sections).toHaveLength(SECTION_IDS.length); - expect(doc.sections.map(section => section.id)).toEqual(SECTION_IDS); - }); +const fixture: HelpDocument = { + title: "Fixture Help", + introduction: "Intro with `code` and **bold**.", + sections: [ + { + id: "sample-section", + title: "Sample Section", + blocks: [ + { type: "paragraph", text: "Placeholder `--fb-from ` and `<<1d6>>`." }, + { type: "codeBlock", lines: ["&{template:default} {{name=Test}}"] }, + { type: "unorderedList", items: ["First item", "Second with `code`"] }, + { + type: "orderedList", + items: [ + { text: "Step one" }, + { + text: "Step two with example", + codeBlock: { lines: ["!setattr --sel --hp|25"] }, + }, + ], + }, + { type: "note", text: "A regular note.", emphasis: false }, + { type: "note", text: "An emphasized note.", emphasis: true }, + ], + subsections: [ + { + id: "nested", + title: "Nested Subsection", + blocks: [{ type: "paragraph", text: "Subsection body." }], + }, + ], + }, + { + id: "second-section", + title: "Second Section", + blocks: [{ type: "paragraph", text: "More content." }], + }, + ], +}; - it("should match the on-disk content.json file", () => { +describe("help content loaders", () => { + it("should load a document with required fields", () => { const doc = loadHelpDocument(); - expect(doc).toEqual(helpContent); + + expect(typeof doc.title).toBe("string"); + expect(doc.title.length).toBeGreaterThan(0); + expect(typeof doc.introduction).toBe("string"); + expect(Array.isArray(doc.sections)).toBe(true); + expect(doc.sections.length).toBeGreaterThan(0); + + for (const section of doc.sections) { + expect(typeof section.id).toBe("string"); + expect(section.id.length).toBeGreaterThan(0); + expect(typeof section.title).toBe("string"); + expect(section.title.length).toBeGreaterThan(0); + } }); +}); +describe("help content rendering", () => { it("should render markdown with a table of contents when requested", () => { - const doc = loadHelpDocument(); - const markdown = renderHelpMarkdown(doc, { includeToc: true }); + const markdown = renderHelpMarkdown(fixture, { includeToc: true }); + expect(markdown).toContain("## Table of Contents"); - expect(markdown).toContain("1. [Basic Usage](#basic-usage)"); - expect(markdown).toContain("## Beacon Computed Values"); + expect(markdown).toContain("1. [Sample Section](#sample-section)"); + expect(markdown).toContain("2. [Second Section](#second-section)"); + expect(markdown).toContain("## Sample Section"); + expect(markdown).toContain("### Nested Subsection"); }); - it("should render markdown without a table of contents for script.json", () => { - const doc = loadHelpDocument(); - const markdown = renderHelpMarkdown(doc, { includeToc: false }); + it("should render markdown without a table of contents", () => { + const markdown = renderHelpMarkdown(fixture, { includeToc: false }); + expect(markdown).not.toContain("## Table of Contents"); - expect(markdown.startsWith("# ChatSetAttr")).toBe(true); + expect(markdown.startsWith("# Fixture Help")).toBe(true); + }); + + it("should render markdown block types from fixture content", () => { + const markdown = renderHelpMarkdown(fixture, { includeToc: false }); + + expect(markdown).toContain("Placeholder `--fb-from ` and `<<1d6>>`."); + expect(markdown).toContain("&{template:default} {{name=Test}}"); + expect(markdown).toContain("- First item"); + expect(markdown).toContain("1. Step one"); + expect(markdown).toContain("> **Note:** An emphasized note."); + expect(markdown).toContain("> A regular note."); }); it("should escape literal angle brackets in handout HTML", () => { - const doc = loadHelpDocument(); - const html = renderHelpHtml(doc, "test-handout-id"); + const html = renderHelpHtml(fixture, "test-handout-id"); + expect(html).toContain("--fb-from <NAME>"); - expect(html).toContain("Roll <<1d6>> to succeed"); + expect(html).toContain("<<1d6>>"); expect(html).not.toContain("--fb-from "); }); it("should preserve roll template syntax in handout HTML", () => { - const doc = loadHelpDocument(); - const html = renderHelpHtml(doc, "test-handout-id"); - expect(html).toContain("&{template:default}"); - expect(html).toContain("journal.roll20.net/handout/test-handout-id/#Basic%20Usage"); - }); - - it("should render script description matching markdown without TOC", () => { - const doc = loadHelpDocument(); - const scriptJson = JSON.parse( - readFileSync(join(process.cwd(), "script.json"), "utf8"), - ) as { description?: string }; - const expected = renderHelpMarkdown(doc, { includeToc: false }).trim(); - expect(scriptJson.description).toBe(expected); - }); - - it("should render README matching markdown with TOC header", () => { - const doc = loadHelpDocument(); - const readme = readFileSync(join(process.cwd(), "README.md"), "utf8"); - const expected = "\n\n" - + renderHelpMarkdown(doc, { includeToc: true }); - expect(readme).toBe(expected); - }); + const html = renderHelpHtml(fixture, "test-handout-id"); - it("should have a revision file matching content.json", () => { - const revision = JSON.parse(readFileSync(revisionPath, "utf8")) as { - contentHash: string; - updatedAt: number; - }; - expect(revision.contentHash).toBe(hashHelpContentFile(contentPath)); - expect(revision.updatedAt).toBe(getBundledHelpContentUpdatedAt()); - expect(revision.contentHash).toBe(getBundledHelpContentHash()); - expect(revision.updatedAt).toBeGreaterThan(0); + expect(html).toContain("&{template:default}"); + expect(html).toContain("journal.roll20.net/handout/test-handout-id/#Sample%20Section"); }); -}); -describe("help content fixture", () => { - const fixture: HelpDocument = { - title: "Fixture", - introduction: "Intro with `code` and **bold**.", - sections: [ - { - id: "sample", - title: "Sample", - blocks: [ - { type: "paragraph", text: "Placeholder `--fb-from ` and `<<1d6>>`." }, - { type: "codeBlock", lines: ["&{template:default} {{name=Test}}"] }, - ], - }, - ], - }; - - it("should render fixture markdown predictably", () => { - const markdown = renderHelpMarkdown(fixture, { includeToc: false }); - expect(markdown).toContain("Placeholder `--fb-from ` and `<<1d6>>`."); - expect(markdown).toContain("&{template:default} {{name=Test}}"); - }); + it("should render ordered list code blocks and notes in handout HTML", () => { + const html = renderHelpHtml(fixture, "test-handout-id"); - it("should render fixture HTML with escaped display characters", () => { - const html = renderHelpHtml(fixture, "fixture-handout"); - expect(html).toContain("<NAME>"); - expect(html).toContain("<<1d6>>"); - expect(html).toContain("&{template:default}"); + expect(html).toContain("!setattr --sel --hp|25"); + expect(html).toContain("Note:"); + expect(html).toContain("Nested Subsection"); }); }); diff --git a/ChatSetAttr/src/__tests__/unit/versioning.test.ts b/ChatSetAttr/src/__tests__/unit/versioning.test.ts index fb6c1097f6..c33718642d 100644 --- a/ChatSetAttr/src/__tests__/unit/versioning.test.ts +++ b/ChatSetAttr/src/__tests__/unit/versioning.test.ts @@ -1,23 +1,24 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { checkForUpdates } from "../../modules/versioning"; import { v2_0 } from "../../versions/2.0.0"; -import { getConfig, setConfig } from "../../modules/config"; +import { setConfig } from "../../modules/config"; vi.mock("../../versions/2.0.0", () => { return { v2_0: { - appliesTo: "<=1.10", - version: "2.0", + appliesTo: "<=3", + version: 4, update: vi.fn(), }, }; }); -const version2 = vi.mocked(v2_0); +const migration2 = vi.mocked(v2_0); -vi.mock("../../modules/config", () => { +vi.mock("../../modules/config", async (importOriginal) => { + const actual = await importOriginal(); return { - getConfig: vi.fn(() => ({ version: "1.10" })), + ...actual, setConfig: vi.fn(), }; }); @@ -25,311 +26,87 @@ vi.mock("../../modules/config", () => { describe("versioning", () => { beforeEach(() => { vi.clearAllMocks(); - // Reset v2_0.appliesTo to its default value - vi.mocked(v2_0).appliesTo = "<=1.10"; + vi.mocked(v2_0).appliesTo = "<=3"; }); describe("checkForUpdates", () => { - it("should update version when current version is less than or equal to target", () => { - // arrange + it("should run migration when state schema is 3", () => { + checkForUpdates(3); - // 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(); + expect(migration2.update).toHaveBeenCalled(); + expect(setConfig).toHaveBeenCalledWith({ version: 4 }); }); - it("should call setConfig with updated version after update", () => { - // arrange - const mockConfig = { version: "1.10" }; - vi.mocked(getConfig).mockReturnValue(mockConfig); - - // act - checkForUpdates("1.9"); + it("should run migration when state schema is 0", () => { + checkForUpdates(0); - // assert - expect(version2.update).toHaveBeenCalled(); - expect(setConfig).toHaveBeenCalledWith(expect.objectContaining({ - version: "2.0" - })); + expect(migration2.update).toHaveBeenCalled(); + expect(setConfig).toHaveBeenCalledWith({ version: 4 }); }); - it("should handle empty version strings gracefully", () => { - // arrange + it("should not run migration when state schema is already 4", () => { + checkForUpdates(4); - // act & assert - expect(() => checkForUpdates("")).not.toThrow(); - // Empty string gets parsed as version "0.0.0", which is <= "1.10" - expect(version2.update).toHaveBeenCalled(); + expect(migration2.update).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); }); - it("should handle malformed version strings gracefully", () => { - // arrange + it("should not run migration when state schema is greater than 4", () => { + checkForUpdates(5); - // act & assert - expect(() => checkForUpdates("invalid.version")).not.toThrow(); - expect(version2.update).not.toHaveBeenCalled(); + expect(migration2.update).not.toHaveBeenCalled(); }); - it("should handle version with only major number", () => { - // arrange - - // act - checkForUpdates("1"); + it("should call setConfig with schema version 4 after update", () => { + checkForUpdates(3); - // assert - expect(version2.update).toHaveBeenCalled(); + expect(setConfig).toHaveBeenCalledWith({ version: 4 }); }); }); - describe("different comparison operators", () => { - beforeEach(() => { - // Reset the mock to use different appliesTo values for each test - vi.clearAllMocks(); - }); - + describe("comparison operators", () => { it("should handle < operator correctly", () => { - // arrange - vi.mocked(v2_0).appliesTo = "<1.10"; - - // act - checkForUpdates("1.9"); + vi.mocked(v2_0).appliesTo = "<4"; - // assert - expect(version2.update).toHaveBeenCalled(); + checkForUpdates(3); + expect(migration2.update).toHaveBeenCalled(); vi.clearAllMocks(); - checkForUpdates("1.10"); - expect(version2.update).not.toHaveBeenCalled(); + checkForUpdates(4); + expect(migration2.update).not.toHaveBeenCalled(); }); it("should handle >= operator correctly", () => { - // arrange - vi.mocked(v2_0).appliesTo = ">=1.10"; - - // act - checkForUpdates("1.10"); + vi.mocked(v2_0).appliesTo = ">=3"; - // assert - expect(version2.update).toHaveBeenCalled(); - - vi.clearAllMocks(); - checkForUpdates("1.11"); - expect(version2.update).toHaveBeenCalled(); + checkForUpdates(3); + expect(migration2.update).toHaveBeenCalled(); vi.clearAllMocks(); - checkForUpdates("1.9"); - expect(version2.update).not.toHaveBeenCalled(); + checkForUpdates(2); + expect(migration2.update).not.toHaveBeenCalled(); }); it("should handle > operator correctly", () => { - // arrange - vi.mocked(v2_0).appliesTo = ">1.10"; + vi.mocked(v2_0).appliesTo = ">2"; - // act - checkForUpdates("1.11"); - - // assert - expect(version2.update).toHaveBeenCalled(); + checkForUpdates(3); + expect(migration2.update).toHaveBeenCalled(); vi.clearAllMocks(); - checkForUpdates("1.10"); - expect(version2.update).not.toHaveBeenCalled(); + checkForUpdates(2); + expect(migration2.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 - }); + vi.mocked(v2_0).appliesTo = "=3"; - // 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(); + checkForUpdates(3); + expect(migration2.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); + checkForUpdates(4); + expect(migration2.update).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/ChatSetAttr/src/__tests__/utils/chat.test.ts b/ChatSetAttr/src/__tests__/utils/chat.test.ts index 6de87ea5e5..3e12ff5deb 100644 --- a/ChatSetAttr/src/__tests__/utils/chat.test.ts +++ b/ChatSetAttr/src/__tests__/utils/chat.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @stylistic/quotes */ import { describe, it, expect } from "vitest"; -import { h, s } from "../../utils/chat"; +import { h, rawHtml, s } from "../../utils/chat"; describe("chat utilities", () => { describe("h function (JSX helper)", () => { @@ -235,6 +235,19 @@ describe("chat utilities", () => { }); }); + describe("rawHtml", () => { + it("should render SafeHtml children without escaping", () => { + const result = h("div", {}, rawHtml("

Hello

")); + expect(result.html).toBe("

Hello

"); + }); + + it("should preserve multiple HTML elements when embedded in JSX", () => { + const fragment = rawHtml("

Line 1

Line 2

"); + const result = h("div", {}, fragment); + expect(result.html).toBe("

Line 1

Line 2

"); + }); + }); + describe("s function (style helper)", () => { it("should convert a simple style object to CSS string", () => { const result = s({ color: "red" }); diff --git a/ChatSetAttr/src/env.d.ts b/ChatSetAttr/src/env.d.ts index 5ead373dc6..6472a9acbc 100644 --- a/ChatSetAttr/src/env.d.ts +++ b/ChatSetAttr/src/env.d.ts @@ -10,7 +10,7 @@ declare global { var libUUID: { generateRowID(): string; generateUUID(): string }; /** Roll20 Mod API persistent script state. */ var state: { - ChatSetAttr?: Record & { version?: string }; + ChatSetAttr?: Record & { version?: number | string; scriptVersion?: string }; [key: string]: unknown; }; /** Roll20 One-Click script page configuration. */ diff --git a/ChatSetAttr/src/index.ts b/ChatSetAttr/src/index.ts index 78d2d8c6bd..3684519ae6 100644 --- a/ChatSetAttr/src/index.ts +++ b/ChatSetAttr/src/index.ts @@ -1,5 +1,5 @@ import { registerHandlers } from "./modules/main"; -import { checkGlobalConfig } from "./modules/config"; +import { checkGlobalConfig, persistStateVersionMetadata, syncScriptVersion } from "./modules/config"; import { syncHelpHandoutOnStartup } from "./modules/help"; import { update, welcome } from "./modules/versioning"; import "./utils/chat"; @@ -8,8 +8,10 @@ on("ready", () => { checkGlobalConfig(); registerHandlers(); syncHelpHandoutOnStartup(); + syncScriptVersion(); update(); welcome(); + persistStateVersionMetadata(); }); export { registerObserver } from "./modules/observer"; \ No newline at end of file diff --git a/ChatSetAttr/src/modules/config.ts b/ChatSetAttr/src/modules/config.ts index 70ed38c022..7dadfe931a 100644 --- a/ChatSetAttr/src/modules/config.ts +++ b/ChatSetAttr/src/modules/config.ts @@ -1,8 +1,12 @@ +import scriptJson from "../../script.json" assert { type: "json" }; import { getWhisperPrefix, sendNotification } from "./chat"; import { createConfigMessage } from "../templates/config"; +export const STATE_SCHEMA_VERSION = 4; + type ScriptConfig = { - version: number | string; + version: number; + scriptVersion: string; globalconfigCache: GlobalConfigCache; playersCanTargetParty: boolean; playersCanModify: boolean; @@ -43,10 +47,9 @@ export const GLOBAL_CONFIG_OPTIONS = [ }, ] as const; -const SCHEMA_VERSION = "2.0"; - const DEFAULT_CONFIG: ScriptConfig = { - version: SCHEMA_VERSION, + version: STATE_SCHEMA_VERSION, + scriptVersion: scriptJson.version, globalconfigCache: { lastsaved: 0, }, @@ -58,6 +61,51 @@ const DEFAULT_CONFIG: ScriptConfig = { flags: [], }; +export function getStateSchemaVersion(raw: unknown): number { + if (raw === undefined || raw === null) { + return 0; + } + if (typeof raw === "number" && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === "string") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && /^\d+$/.test(raw.trim())) { + return parsed; + } + return 0; + } + return 0; +} + +function ensureChatSetAttrState(): Record { + if (!state.ChatSetAttr) { + state.ChatSetAttr = {}; + } + return state.ChatSetAttr; +} + +export function getPersistedSchemaVersion(): number { + return getStateSchemaVersion(state.ChatSetAttr?.version); +} + +export function persistStateVersionMetadata(): void { + const raw = ensureChatSetAttrState(); + const schemaVersion = getStateSchemaVersion(raw.version); + + if (schemaVersion > 0 && raw.version !== schemaVersion) { + raw.version = schemaVersion; + } + + if (!Object.hasOwn(raw, "scriptVersion") || raw.scriptVersion !== scriptJson.version) { + raw.scriptVersion = scriptJson.version; + } +} + +export function syncScriptVersion(): void { + persistStateVersionMetadata(); +} + export function parseGlobalConfigCheckbox( g: GlobalConfigRecord, label: string, @@ -127,11 +175,7 @@ export function getConfig() { }; export function setConfig(newConfig: Record) { - const stateConfig = state.ChatSetAttr || {}; - state.ChatSetAttr = { - ...stateConfig, - ...newConfig, - }; + Object.assign(ensureChatSetAttrState(), newConfig); }; export function hasFlag(flag: string) { diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 768e65875a..d1b554344f 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -195,9 +195,9 @@ export function registerHandlers() { } const debugVersion = msg.content.startsWith("!setattrs-debugversion"); if (debugVersion) { - log("ChatSetAttr: Debug - setting version to 1.10."); + log("ChatSetAttr: Debug - setting state schema version to 3."); if (!state.ChatSetAttr) state.ChatSetAttr = {}; - state.ChatSetAttr.version = "1.10"; + state.ChatSetAttr.version = 3; return; } const isHelpMessage = checkHelpMessage(msg.content); diff --git a/ChatSetAttr/src/modules/versioning.ts b/ChatSetAttr/src/modules/versioning.ts index c46086cf4d..f9f94713ea 100644 --- a/ChatSetAttr/src/modules/versioning.ts +++ b/ChatSetAttr/src/modules/versioning.ts @@ -1,7 +1,13 @@ import type { VersionObject } from "../types"; import { v2_0 } from "../versions/2.0.0"; import { sendWelcomeMessage } from "./chat"; -import { getConfig, hasFlag, setConfig, setFlag } from "./config"; +import { + getPersistedSchemaVersion, + hasFlag, + persistStateVersionMetadata, + setConfig, + setFlag, +} from "./config"; const VERSION_HISTORY: VersionObject[] = [ v2_0, @@ -16,26 +22,21 @@ export function welcome() { }; export function update() { - log("ChatSetAttr: Checking for updates..."); - const config = getConfig(); - let currentVersion = config.version || "1.10"; + log("ChatSetAttr: Checking for state schema updates..."); + const currentSchemaVersion = getPersistedSchemaVersion(); - log(`ChatSetAttr: Current version: ${currentVersion}`); - if (currentVersion === 3) { - currentVersion = "1.10"; - } - - log(`ChatSetAttr: Normalized current version: ${currentVersion}`); - checkForUpdates(String(currentVersion)); + log(`ChatSetAttr: Current state schema version: ${currentSchemaVersion}`); + checkForUpdates(currentSchemaVersion); + persistStateVersionMetadata(); }; -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); +export function checkForUpdates(currentSchemaVersion: number): void { + for (const migration of VERSION_HISTORY) { + log(`ChatSetAttr: Evaluating schema migration to ${migration.version} (appliesTo: ${migration.appliesTo})`); + const applies = migration.appliesTo; + const threshold = Number(applies.replace(/(<=|<|>=|>|=)/, "").trim()); + const comparison = applies.replace(String(threshold), "").trim(); + const compared = compareSchemaVersions(currentSchemaVersion, threshold); let shouldApply = false; switch (comparison) { @@ -57,28 +58,17 @@ export function checkForUpdates(currentVersion: string): void { } if (shouldApply) { - version.update(); - currentVersion = version.version; - updateVersionInState(currentVersion); + migration.update(); + currentSchemaVersion = migration.version; + updateVersionInState(currentSchemaVersion); } } } -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 compareSchemaVersions(current: number, threshold: number): number { + return current - threshold; +} -function updateVersionInState(newVersion: string): void { - const config = getConfig(); - config.version = newVersion; - setConfig(config); -}; \ No newline at end of file +function updateVersionInState(newSchemaVersion: number): void { + setConfig({ version: newSchemaVersion }); +} diff --git a/ChatSetAttr/src/templates/config.tsx b/ChatSetAttr/src/templates/config.tsx index d6ca3bc4f5..7284902b0f 100644 --- a/ChatSetAttr/src/templates/config.tsx +++ b/ChatSetAttr/src/templates/config.tsx @@ -43,7 +43,11 @@ export function createConfigMessage(): string { const config = getConfig(); const configEntries = Object.entries(config); const relevantEntries = configEntries.filter(([key]) => - key !== "version" && key !== "globalconfigCache" && key !== "flags" + key !== "version" + && key !== "scriptVersion" + && key !== "globalconfigCache" + && key !== "flags" + && key !== "helpContentUpdatedAt" ); return (
diff --git a/ChatSetAttr/src/templates/notification.tsx b/ChatSetAttr/src/templates/notification.tsx index e0d8150172..963add71dc 100644 --- a/ChatSetAttr/src/templates/notification.tsx +++ b/ChatSetAttr/src/templates/notification.tsx @@ -1,4 +1,4 @@ -import { s } from "../utils/chat"; +import { rawHtml, s } from "../utils/chat"; import { frameStyleBase, headerStyleBase } from "./styles"; const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); @@ -10,7 +10,7 @@ export function createNotifyMessage(title: string, content: string): string {
{title}
- {content} + {rawHtml(content)}
).html; diff --git a/ChatSetAttr/src/types.ts b/ChatSetAttr/src/types.ts index 3409d50a41..031dbf0b6b 100644 --- a/ChatSetAttr/src/types.ts +++ b/ChatSetAttr/src/types.ts @@ -153,24 +153,18 @@ export const ALIAS_CHARACTERS: Record = { // #region Versioning -export type VersionString = - `${number}.${number}` | - `${number}.${number}.${number}` | - `${number}.${number}${string | ""}` | - `${number}.${number}.${number}${string | ""}`; - -export type VersionComparison = +export type SchemaVersionComparison = "<=" | "<" | ">=" | ">" | "=" ; -export type VersionAppliesTo = `${VersionComparison}${VersionString}`; +export type SchemaVersionAppliesTo = `${SchemaVersionComparison}${number}`; export type VersionObject = { - appliesTo: VersionAppliesTo; - version: VersionString; + appliesTo: SchemaVersionAppliesTo; + version: number; update: () => void; }; diff --git a/ChatSetAttr/src/utils/chat.ts b/ChatSetAttr/src/utils/chat.ts index 364ddfc260..8c818217dc 100644 --- a/ChatSetAttr/src/utils/chat.ts +++ b/ChatSetAttr/src/utils/chat.ts @@ -24,6 +24,10 @@ export class SafeHtml { constructor(public readonly html: string) {} } +export function rawHtml(html: string): SafeHtml { + return new SafeHtml(html); +} + function renderChild(child: Child): string { if (child instanceof SafeHtml) { return child.html; diff --git a/ChatSetAttr/src/versions/2.0.0.ts b/ChatSetAttr/src/versions/2.0.0.ts index 2247c3f47d..7aeb341500 100644 --- a/ChatSetAttr/src/versions/2.0.0.ts +++ b/ChatSetAttr/src/versions/2.0.0.ts @@ -1,19 +1,19 @@ +import scriptJson from "../../script.json" assert { type: "json" }; import { sendNotification } from "../modules/chat"; -import { getConfig, setConfig } from "../modules/config"; +import { setConfig } from "../modules/config"; import { createVersionMessage } from "../templates/versions/2.0.0"; import type { VersionObject } from "../types"; export const v2_0: VersionObject = { - appliesTo: "<=1.10", - version: "2.0", + appliesTo: "<=3", + version: 4, update: () => { - // Update state data - const config = getConfig(); - config.version = "2.0"; - config.playersCanTargetParty = true; - setConfig(config); + setConfig({ + version: 4, + playersCanTargetParty: true, + scriptVersion: scriptJson.version, + }); - // Send message explaining update const title = "ChatSetAttr Updated to Version 2.0"; const content = createVersionMessage(); diff --git a/ChatSetAttr/vitest.setup.ts b/ChatSetAttr/vitest.setup.ts index de7c83a0fe..f75a71f7eb 100644 --- a/ChatSetAttr/vitest.setup.ts +++ b/ChatSetAttr/vitest.setup.ts @@ -30,7 +30,8 @@ global.executeCommand = simulateChatMessage; // region State global.state = { ChatSetAttr: { - version: "1.10", + version: 4, + scriptVersion: "2.0", playersCanModify: true, playersCanEvaluate: true, useWorkers: true From f30078919201bb1ac6a1830df07f68314dc1ae94 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 10:58:21 -0500 Subject: [PATCH 27/38] fix for isLegacySheet() --- ChatSetAttr/src/modules/observerPayload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatSetAttr/src/modules/observerPayload.ts b/ChatSetAttr/src/modules/observerPayload.ts index d48186d243..40b4ef3ef9 100644 --- a/ChatSetAttr/src/modules/observerPayload.ts +++ b/ChatSetAttr/src/modules/observerPayload.ts @@ -105,7 +105,7 @@ export function tryFindLegacyAttribute( export function isLegacySheet(targetId: string): boolean { const character = getObj("character", targetId); - return character?.sheetEnvironment === "legacy"; + return (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined); }; function legacyAttributeForSheet( From 298a95038754ec93e03016e51bc9ba29d04a1042 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 10:59:39 -0500 Subject: [PATCH 28/38] Fixed legacy detection in default --- libSmartAttributes/0.0.4/libSmartAttributes.js | 2 +- libSmartAttributes/src/.index.ts.swp | Bin 12288 -> 0 bytes libSmartAttributes/src/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 libSmartAttributes/src/.index.ts.swp diff --git a/libSmartAttributes/0.0.4/libSmartAttributes.js b/libSmartAttributes/0.0.4/libSmartAttributes.js index ac1d3d8233..a05379fb7c 100644 --- a/libSmartAttributes/0.0.4/libSmartAttributes.js +++ b/libSmartAttributes/0.0.4/libSmartAttributes.js @@ -51,7 +51,7 @@ var libSmartAttributes = (function () { if (!character) { return false; } - if (character?.sheetEnvironment === "legacy") { + if (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined) { const legacyAttr = findObjs({ _type: "attribute", _characterid: characterId, diff --git a/libSmartAttributes/src/.index.ts.swp b/libSmartAttributes/src/.index.ts.swp deleted file mode 100644 index e7009e29c239c94caa13c82150bf47623f2f483d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI2&u<$=6vrq0E>I{xB@V!gNsA*HJA_jmOjNZTOsGL`Pci|i32-W*s$BnE2?AbkL$b<+yYA#fY2Tr0=|~`4!9!7Uo!VTJ2Xdd3fm4g52uopLl^o9&t3?@ZZGh=D3K#{3 zP+%u}YVyfA)gQ}^(tY>7IK(?I9d0!9I&fKk9GU=%P47zK<1MggOMQNSp09V)@u}{Ej;1GBo%z(e4@n7H~_ye2==fKzC3!wJkQ(!(u0i%FXAXcC=#fWU&l9EE- zjVhoi6y3X0>UpW}S**7FK;peRk6cOXjK%W=LPmk7W_10f{Gy(a#@6LpOLWNdCDr}N zv&r|!YVd$tQUvJMKUeKeK@Dp--V#YaHEnTxiec6cAnL9Xn>p7ILRO_{=6fb7&|(yd zpg6Yf@uui376^-qy5kAEK*|#0|WXII2vR{Zi-wkpGjoI2&zyUJl2 ztyIX1T$lFji6_*J+JbQiMr8S3D7zyn#M|7FO6Vay(r%PWG~W;&$zToY1|%C|z?CZ% zPx-y2Wc#_N_A&_^stFEn;8C-Mt9AC3Nm!&gA!GTm-C84ZppM{nSKwq3t!5_iK(n5p zSLB{C+smBzzNNSW{$5^PS1fUBP1#!vMAJVi@;X+!UCK(tLTCKdiG?I0)$(PEjX(#{ z5gpZzUGC;g)mB-FZe?hhay*OLoN7;EtGf(U2(fR^)|SJ(iaixEX|N=NdbX_Y1fgU+ zT=OidN1i1eZ0$gW=uNdUg}77cEg@}V)3c|yLx$FAv`9|Qk%T(uXC{#1-8!~Z^ayLSHDS--cDaaBytEWf=zG((yuvAM^ z+d+|O@Ky_{k1a}rGF0U-R3%B30iBYzFGBBeNn+JO)j^SPLKISNKjC}s8m#Y-#iiAV z&FL?(*`^Q{y!P92Z8wIfcWYEkq#G8O*3EY)$@vZYq6#YVGbtrmAeL3VnoXh-!fg}g zYW;ZLGQxGm^V^B|&6>m08l$I}EFOhH($lPRAbxLN#8m#OCaxau4qz8Klfl-@37 zYmfCv$NrPMn^CG3Z8*b~)kB`)mpQQYQK#mjqttdXJyD_o@z_ tv_^BmPzI4kR6o^8=-w-lSZUujXiq{RhdE(=h-5 diff --git a/libSmartAttributes/src/index.ts b/libSmartAttributes/src/index.ts index 93a1a72466..f178375765 100644 --- a/libSmartAttributes/src/index.ts +++ b/libSmartAttributes/src/index.ts @@ -75,7 +75,7 @@ async function deleteAttribute(characterId: string, name: string, type: Attribut return false; } - if (character?.sheetEnvironment === "legacy") { + if (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined) { const legacyAttr = findObjs({ _type: "attribute", _characterid: characterId, From 191010230f3312c0c60eea74015d636aa24a65c4 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 11:14:52 -0500 Subject: [PATCH 29/38] Fixing default delete events --- ChatSetAttr/2.0/ChatSetAttr.js | 22 ++++-- ChatSetAttr/ChatSetAttr.js | 22 ++++-- .../integration/legacyAttributes.test.ts | 20 ++++-- .../__tests__/unit/observerPayload.test.ts | 69 +++++++++++++++---- ChatSetAttr/src/__tests__/unit/update.test.ts | 10 ++- ChatSetAttr/src/modules/observerPayload.ts | 16 ++++- ChatSetAttr/src/modules/updates.ts | 18 +++-- 7 files changed, 140 insertions(+), 37 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 58b934c925..3f99ec4f69 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -485,7 +485,10 @@ var ChatSetAttr = (function (exports) { } function isLegacySheet(targetId) { const character = getObj("character", targetId); - return character?.sheetEnvironment === "legacy"; + if (!character) { + return false; + } + return character.sheetEnvironment === "legacy" || character.sheetEnvironment === undefined; } function legacyAttributeForSheet(targetId, actualName) { if (!isLegacySheet(targetId)) { @@ -589,6 +592,12 @@ var ChatSetAttr = (function (exports) { }; return obj; } + function resolveObserverDestroyObj(targetId, actualName, kind) { + if (kind !== "attribute" || !isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } function resolveObserverObj(targetId, actualName, kind, state) { if (kind === "attribute") { const legacyAttr = legacyAttributeForSheet(targetId, actualName); @@ -700,8 +709,7 @@ var ChatSetAttr = (function (exports) { } const maxKey = `${actualName}_max`; const hasCompanionCurrent = Object.hasOwn(results[target], actualName); - const character = getObj("character", target); - if (character?.sheetEnvironment === "legacy") { + if (isLegacySheet(target)) { return hasCompanionCurrent; } // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. @@ -726,6 +734,7 @@ var ChatSetAttr = (function (exports) { const setOptions = buildSetAttributeOptions({ noCreate }); const deleteKinds = new Map(); const deleteStates = new Map(); + const deleteObserverTargets = new Map(); if (!isSetting) { for (const target in results) { for (const name in results[target]) { @@ -738,6 +747,10 @@ var ChatSetAttr = (function (exports) { const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); deleteStates.set(groupKey, await captureDeletePriorState(target, actualName, kind, priorValues)); } + if (!deleteObserverTargets.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteObserverTargets.set(groupKey, resolveObserverDestroyObj(target, actualName, kind)); + } } } } @@ -807,7 +820,8 @@ var ChatSetAttr = (function (exports) { notifyObservers("change", obj, prev); } else { - const obj = resolveObserverObj(group.target, group.actualName, kind, state); + const obj = deleteObserverTargets.get(groupKey) + ?? resolveObserverObj(group.target, group.actualName, kind, state); notifyObservers("destroy", obj); } } diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 58b934c925..3f99ec4f69 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -485,7 +485,10 @@ var ChatSetAttr = (function (exports) { } function isLegacySheet(targetId) { const character = getObj("character", targetId); - return character?.sheetEnvironment === "legacy"; + if (!character) { + return false; + } + return character.sheetEnvironment === "legacy" || character.sheetEnvironment === undefined; } function legacyAttributeForSheet(targetId, actualName) { if (!isLegacySheet(targetId)) { @@ -589,6 +592,12 @@ var ChatSetAttr = (function (exports) { }; return obj; } + function resolveObserverDestroyObj(targetId, actualName, kind) { + if (kind !== "attribute" || !isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); + } function resolveObserverObj(targetId, actualName, kind, state) { if (kind === "attribute") { const legacyAttr = legacyAttributeForSheet(targetId, actualName); @@ -700,8 +709,7 @@ var ChatSetAttr = (function (exports) { } const maxKey = `${actualName}_max`; const hasCompanionCurrent = Object.hasOwn(results[target], actualName); - const character = getObj("character", target); - if (character?.sheetEnvironment === "legacy") { + if (isLegacySheet(target)) { return hasCompanionCurrent; } // Beacon userAttributes are removed when current is cleared; a follow-up max delete fails. @@ -726,6 +734,7 @@ var ChatSetAttr = (function (exports) { const setOptions = buildSetAttributeOptions({ noCreate }); const deleteKinds = new Map(); const deleteStates = new Map(); + const deleteObserverTargets = new Map(); if (!isSetting) { for (const target in results) { for (const name in results[target]) { @@ -738,6 +747,10 @@ var ChatSetAttr = (function (exports) { const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); deleteStates.set(groupKey, await captureDeletePriorState(target, actualName, kind, priorValues)); } + if (!deleteObserverTargets.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteObserverTargets.set(groupKey, resolveObserverDestroyObj(target, actualName, kind)); + } } } } @@ -807,7 +820,8 @@ var ChatSetAttr = (function (exports) { notifyObservers("change", obj, prev); } else { - const obj = resolveObserverObj(group.target, group.actualName, kind, state); + const obj = deleteObserverTargets.get(groupKey) + ?? resolveObserverObj(group.target, group.actualName, kind, state); notifyObservers("destroy", obj); } } diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index 8416c23b36..de271c0013 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -36,10 +36,14 @@ describe("ChatSetAttr Integration Tests", () => { resetAllCallbacks(); }); - /** libSmartAttributes.deleteAttribute uses legacy removal only when sheetEnvironment is legacy */ + /** Default-sandbox legacy characters omit sheetEnvironment (treated as legacy). */ function createLegacyCharacter(properties: Record) { + return createObj("character", properties); + } + + function createBeaconCharacter(properties: Record) { const character = createObj("character", properties); - Object.assign(character, { sheetEnvironment: "legacy" }); + Object.assign(character, { sheetEnvironment: "beacon" }); return character; } @@ -583,7 +587,7 @@ describe("ChatSetAttr Integration Tests", () => { it("should not delete attributes exposed as beacon computeds", async () => { const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); - createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createBeaconCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); createObj("attribute", { _characterid: "char1", name: "ComputedLike", current: "10" }); executeCommand("!delattr --charid char1 --ComputedLike"); @@ -1091,7 +1095,7 @@ describe("ChatSetAttr Integration Tests", () => { it("should observe attribute deletions with registered observers", async () => { const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); - createObj("attribute", { _id: "attr1", _characterid: "char1", name: "DeleteMe", current: "10", max: "20" }); + const attribute = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "DeleteMe", current: "10", max: "20" }); const mockObserver = vi.fn(); ChatSetAttr.registerObserver("destroy", mockObserver); @@ -1103,6 +1107,8 @@ describe("ChatSetAttr Integration Tests", () => { }); const firstCall = mockObserver.mock.calls[0]; + expect(firstCall[0]).toBe(attribute); + expect(firstCall[0].get("type")).toBe("attribute"); expect(firstCall[0].get("name")).toBe("DeleteMe"); expect(firstCall[0].get("current")).toBe("10"); expect(firstCall[0].get("max")).toBe("20"); @@ -1111,7 +1117,7 @@ describe("ChatSetAttr Integration Tests", () => { it("should observe userAttribute 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 }); + createBeaconCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); const mockObserver = vi.fn(); ChatSetAttr.registerObserver("destroy", mockObserver); @@ -1136,7 +1142,7 @@ describe("ChatSetAttr Integration Tests", () => { it("should observe userAttribute deletions with max on registered observers", async () => { const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); - createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createBeaconCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); const mockObserver = vi.fn(); ChatSetAttr.registerObserver("destroy", mockObserver); @@ -1186,7 +1192,7 @@ describe("ChatSetAttr Integration Tests", () => { it("should not notify observers when deleteAttribute fails on a computed", async () => { const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); - createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); + createBeaconCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ComputedLike", current: "10" }); const mockObserver = vi.fn(); diff --git a/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts b/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts index 31e7c4f4c0..c89eb85c60 100644 --- a/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts +++ b/ChatSetAttr/src/__tests__/unit/observerPayload.test.ts @@ -4,15 +4,23 @@ import { captureDeletePriorState, createObserverAttributeObject, emptySnapshot, + isLegacySheet, isNewAttributeOrUser, mergeAttributeState, resolveObserverAddObj, + resolveObserverDestroyObj, resolveObserverKind, resolveObserverObj, toSnapshot, tryFindLegacyAttribute, } from "../../modules/observerPayload"; +function createBeaconCharacter(properties: Record) { + const character = createObj("character", properties); + Object.assign(character, { sheetEnvironment: "beacon" }); + return character; +} + describe("observerPayload", () => { beforeEach(() => { vi.clearAllMocks(); @@ -23,6 +31,28 @@ describe("observerPayload", () => { resetAllObjects(); }); + describe("isLegacySheet", () => { + it("should treat missing sheetEnvironment as legacy", () => { + createObj("character", { _id: "char1", name: "Hero" }); + expect(isLegacySheet("char1")).toBe(true); + }); + + it("should treat explicit legacy sheetEnvironment as legacy", () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "legacy" }); + expect(isLegacySheet("char1")).toBe(true); + }); + + it("should not treat beacon sheetEnvironment as legacy", () => { + createBeaconCharacter({ _id: "char1", name: "Hero" }); + expect(isLegacySheet("char1")).toBe(false); + }); + + it("should not treat missing characters as legacy", () => { + expect(isLegacySheet("missing-char")).toBe(false); + }); + }); + describe("createObserverAttributeObject", () => { it("should expose get, set, and toJSON", () => { const obj = createObserverAttributeObject("char1", "hp", "computed", { current: "10", max: "20" }); @@ -117,23 +147,22 @@ describe("observerPayload", () => { }); describe("resolveObserverKind", () => { - it("should return attribute when legacy object exists", async () => { - const character = createObj("character", { _id: "char1", name: "Hero" }); - Object.assign(character, { sheetEnvironment: "legacy" }); + it("should return attribute for default-sandbox legacy characters", async () => { + createObj("character", { _id: "char1", name: "Hero" }); createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10" }); await expect(resolveObserverKind("char1", "hp")).resolves.toBe("attribute"); }); it("should return computed when beacon value exists", async () => { - createObj("character", { _id: "char1", name: "Hero" }); + createBeaconCharacter({ _id: "char1", name: "Hero" }); await setSheetItem("char1", "beacon_hp", "10", "current"); await expect(resolveObserverKind("char1", "beacon_hp")).resolves.toBe("computed"); }); it("should return computed on beacon sheets even when a legacy attribute object exists", async () => { - createObj("character", { _id: "char1", name: "Hero" }); + createBeaconCharacter({ _id: "char1", name: "Hero" }); createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ComputedLike", current: "10" }); await setSheetItem("char1", "ComputedLike", "10", "current"); @@ -142,9 +171,8 @@ describe("observerPayload", () => { }); describe("resolveObserverObj", () => { - it("should prefer live legacy attribute objects", () => { - const character = createObj("character", { _id: "char1", name: "Hero" }); - Object.assign(character, { sheetEnvironment: "legacy" }); + it("should prefer live legacy attribute objects on default-sandbox characters", () => { + createObj("character", { _id: "char1", name: "Hero" }); const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); const obj = resolveObserverObj("char1", "hp", "attribute", { @@ -158,7 +186,7 @@ describe("observerPayload", () => { }); it("should build synthetic computed object on beacon sheets even when legacy attribute exists", () => { - createObj("character", { _id: "char1", name: "Hero" }); + createBeaconCharacter({ _id: "char1", name: "Hero" }); createObj("attribute", { _id: "attr1", _characterid: "char1", name: "ComputedLike", current: "10", max: "20" }); const obj = resolveObserverObj("char1", "ComputedLike", "computed", { @@ -186,6 +214,21 @@ describe("observerPayload", () => { }); }); + describe("resolveObserverDestroyObj", () => { + it("should return the live legacy attribute before deletion on default-sandbox characters", () => { + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + + expect(resolveObserverDestroyObj("char1", "hp", "attribute")).toBe(legacy); + }); + + it("should return undefined for beacon sheets", () => { + createBeaconCharacter({ _id: "char1", name: "Hero" }); + + expect(resolveObserverDestroyObj("char1", "hp", "attribute")).toBeUndefined(); + }); + }); + describe("resolveObserverAddObj", () => { it("should return synthetic object with added values", () => { const obj = resolveObserverAddObj("char1", "NewAttr", "userAttribute", { current: "42", max: "100" }); @@ -195,9 +238,8 @@ describe("observerPayload", () => { expect(obj.toJSON()._type).toBe("userAttribute"); }); - it("should return live legacy object when available", () => { - const character = createObj("character", { _id: "char1", name: "Hero" }); - Object.assign(character, { sheetEnvironment: "legacy" }); + it("should return live legacy object when available on default-sandbox characters", () => { + createObj("character", { _id: "char1", name: "Hero" }); const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "NewAttr", current: "42", max: "100" }); const obj = resolveObserverAddObj("char1", "NewAttr", "attribute", { current: "42", max: "100" }); @@ -208,8 +250,7 @@ describe("observerPayload", () => { describe("captureDeletePriorState", () => { it("should read max from legacy attribute when priorValues omit hp_max", async () => { - const character = createObj("character", { _id: "char1", name: "Hero" }); - Object.assign(character, { sheetEnvironment: "legacy" }); + createObj("character", { _id: "char1", name: "Hero" }); createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); const state = await captureDeletePriorState("char1", "hp", "attribute", { char1: { hp: 10 } }); diff --git a/ChatSetAttr/src/__tests__/unit/update.test.ts b/ChatSetAttr/src/__tests__/unit/update.test.ts index 3d7e7941bb..1e30fc51f4 100644 --- a/ChatSetAttr/src/__tests__/unit/update.test.ts +++ b/ChatSetAttr/src/__tests__/unit/update.test.ts @@ -518,20 +518,22 @@ describe("updates", () => { }); it("should include max on destroy when legacy attribute had max", async () => { - const character = createObj("character", { _id: "char1", name: "Hero" }); - Object.assign(character, { sheetEnvironment: "legacy" }); - createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); + createObj("character", { _id: "char1", name: "Hero" }); + const legacy = createObj("attribute", { _id: "attr1", _characterid: "char1", name: "hp", current: "10", max: "20" }); mocklibSmartAttributes.deleteAttribute.mockResolvedValue(true); const priorValues = { char1: { hp: 10 } }; await makeUpdate("delattr", { char1: { hp: undefined } }, { priorValues }); expect(mockNotifyObservers).toHaveBeenCalledTimes(1); + expect(mockNotifyObservers.mock.calls[0][1]).toBe(legacy); expect(mockNotifyObservers.mock.calls[0][1].get("current")).toBe("10"); expect(mockNotifyObservers.mock.calls[0][1].get("max")).toBe("20"); }); it("should notify destroy for userAttribute delete without max", async () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "beacon" }); vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name, type) => { if (name === "user.UserOnlyAttr" && type === "current") { return "42"; @@ -554,6 +556,8 @@ describe("updates", () => { }); it("should notify destroy for userAttribute delete with max", async () => { + const character = createObj("character", { _id: "char1", name: "Hero" }); + Object.assign(character, { sheetEnvironment: "beacon" }); vi.spyOn(global, "getSheetItem").mockImplementation(async (_charId, name, type) => { if (name === "user.UserAttrWithMax") { return type === "current" ? "42" : "100"; diff --git a/ChatSetAttr/src/modules/observerPayload.ts b/ChatSetAttr/src/modules/observerPayload.ts index 40b4ef3ef9..83b1d06f94 100644 --- a/ChatSetAttr/src/modules/observerPayload.ts +++ b/ChatSetAttr/src/modules/observerPayload.ts @@ -105,7 +105,10 @@ export function tryFindLegacyAttribute( export function isLegacySheet(targetId: string): boolean { const character = getObj("character", targetId); - return (character?.sheetEnvironment === "legacy" || character?.sheetEnvironment === undefined); + if (!character) { + return false; + } + return character.sheetEnvironment === "legacy" || character.sheetEnvironment === undefined; }; function legacyAttributeForSheet( @@ -247,6 +250,17 @@ export function createObserverAttributeObject( return obj; }; +export function resolveObserverDestroyObj( + targetId: string, + actualName: string, + kind: ObserverAttributeKind, +): ObserverCallbackTarget | undefined { + if (kind !== "attribute" || !isLegacySheet(targetId)) { + return undefined; + } + return tryFindLegacyAttribute(targetId, actualName); +}; + export function resolveObserverObj( targetId: string, actualName: string, diff --git a/ChatSetAttr/src/modules/updates.ts b/ChatSetAttr/src/modules/updates.ts index 140650cd52..fad16a3385 100644 --- a/ChatSetAttr/src/modules/updates.ts +++ b/ChatSetAttr/src/modules/updates.ts @@ -1,4 +1,4 @@ -import { type AttributeRecord, type AttributeValue, type Command } from "../types"; +import { type AttributeRecord, type AttributeValue, type Command, type ObserverCallbackTarget } from "../types"; import { getConfig } from "./config"; import { notifyObservers } from "./observer"; import { @@ -8,10 +8,12 @@ import { logicalAttributeKey, mergeAttributeState, resolveObserverAddObj, + resolveObserverDestroyObj, resolveObserverKind, resolveObserverObj, toActualName, toSnapshot, + tryFindLegacyAttribute, } from "./observerPayload"; type UpdateOptions = { @@ -81,8 +83,7 @@ function shouldSkipPairedMaxDelete( const maxKey = `${actualName}_max`; const hasCompanionCurrent = Object.hasOwn(results[target], actualName); - const character = getObj("character", target); - if (character?.sheetEnvironment === "legacy") { + if (isLegacySheet(target)) { return hasCompanionCurrent; } @@ -117,6 +118,7 @@ export async function makeUpdate( const setOptions = buildSetAttributeOptions({ noCreate }); const deleteKinds = new Map>>(); const deleteStates = new Map>>(); + const deleteObserverTargets = new Map(); if (!isSetting) { for (const target in results) { @@ -133,6 +135,13 @@ export async function makeUpdate( await captureDeletePriorState(target, actualName, kind, priorValues), ); } + if (!deleteObserverTargets.has(groupKey)) { + const kind = deleteKinds.get(groupKey) ?? await resolveObserverKind(target, actualName); + deleteObserverTargets.set( + groupKey, + resolveObserverDestroyObj(target, actualName, kind), + ); + } } } } @@ -215,7 +224,8 @@ export async function makeUpdate( } notifyObservers("change", obj, prev); } else { - const obj = resolveObserverObj(group.target, group.actualName, kind, state); + const obj = deleteObserverTargets.get(groupKey) + ?? resolveObserverObj(group.target, group.actualName, kind, state); notifyObservers("destroy", obj); } } From 0f40ee99749b35aa08ff0e8528eddf17fb4d061b Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 11:16:11 -0500 Subject: [PATCH 30/38] tests for legacy detection on default. --- libSmartAttributes/tests/index.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/libSmartAttributes/tests/index.test.ts b/libSmartAttributes/tests/index.test.ts index 95f7de0b65..1d94b2c2c7 100644 --- a/libSmartAttributes/tests/index.test.ts +++ b/libSmartAttributes/tests/index.test.ts @@ -280,6 +280,18 @@ describe("SmartAttributes", () => { expect(result).toBe(true); }); + it("should return true when removing a legacy attribute on default-sandbox characters", async () => { + const mockRemove = vi.fn(); + mockGetObj.mockReturnValue({}); + mockFindObjs.mockReturnValue([{ remove: mockRemove }]); + + const result = await SmartAttributes.deleteAttribute(characterId, attributeName); + + expect(mockRemove).toHaveBeenCalled(); + expect(mockGetSheetItem).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + it("should return false when legacy character has no matching attribute", async () => { mockGetObj.mockReturnValue({ sheetEnvironment: "legacy" }); mockFindObjs.mockReturnValue([]); From 2deb10a5f03707fd6c9128c7ace13e5399239493 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 16:48:41 -0500 Subject: [PATCH 31/38] Correct || issue for no change. --- ChatSetAttr/2.0/ChatSetAttr.js | 2 +- ChatSetAttr/ChatSetAttr.js | 2 +- .../integration/legacyAttributes.test.ts | 54 +++++++++++++++++++ .../src/__tests__/unit/message.test.ts | 10 ++++ .../src/__tests__/unit/modifications.test.ts | 26 +++++++++ ChatSetAttr/src/modules/modifications.ts | 2 +- 6 files changed, 93 insertions(+), 3 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 3f99ec4f69..0c1e1df5e5 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -3075,7 +3075,7 @@ var ChatSetAttr = (function (exports) { } } let processedCurrent = undefined; - if (mod.current !== "undefined") { + if (mod.current !== undefined && mod.current !== "undefined") { processedCurrent = String(mod.current); processedCurrent = processModifierValue(processedCurrent, resolved, { shouldEvaluate: options.evaluate, diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 3f99ec4f69..0c1e1df5e5 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -3075,7 +3075,7 @@ var ChatSetAttr = (function (exports) { } } let processedCurrent = undefined; - if (mod.current !== "undefined") { + if (mod.current !== undefined && mod.current !== "undefined") { processedCurrent = String(mod.current); processedCurrent = processModifierValue(processedCurrent, resolved, { shouldEvaluate: options.evaluate, diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index de271c0013..5c47096fe3 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -120,6 +120,60 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should set only max for legacy attributes using || syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "hp1", _characterid: "char1", name: "HP", current: "10", max: "20" }); + + executeCommand("!setattr --charid char1 --HP||23"); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "HP", "current"); + const hpMax = await libSmartAttributes.getAttribute("char1", "HP", "max"); + expect(hp).toBe("10"); + expect(hpMax).toBe("23"); + }); + }); + + it("should set only max for beacon computeds using || syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createBeaconCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _characterid: "char1", name: "ComputedLike", current: "10", max: "20" }); + + executeCommand("!setattr --charid char1 --ComputedLike||23"); + + await vi.waitFor(async () => { + const current = await libSmartAttributes.getAttribute("char1", "ComputedLike", "current"); + const max = await libSmartAttributes.getAttribute("char1", "ComputedLike", "max"); + expect(current).toBe("10"); + expect(max).toBe("23"); + }); + }); + + it("should set only max for userAttributes using || syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createBeaconCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + + executeCommand("!setattr --charid char1 --UserAttrWithMax|42"); + executeCommand("!setattr --charid char1 --UserAttrWithMax_max|100"); + + await vi.waitFor(async () => { + const current = await libSmartAttributes.getAttribute("char1", "UserAttrWithMax", "current"); + const max = await libSmartAttributes.getAttribute("char1", "UserAttrWithMax", "max"); + expect(current).toBe("42"); + expect(max).toBe("100"); + }); + + executeCommand("!setattr --charid char1 --UserAttrWithMax||75"); + + await vi.waitFor(async () => { + const current = await libSmartAttributes.getAttribute("char1", "UserAttrWithMax", "current"); + const max = await libSmartAttributes.getAttribute("char1", "UserAttrWithMax", "max"); + expect(current).toBe("42"); + expect(max).toBe("75"); + }); + }); + 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); diff --git a/ChatSetAttr/src/__tests__/unit/message.test.ts b/ChatSetAttr/src/__tests__/unit/message.test.ts index b6129e7794..6751437ff9 100644 --- a/ChatSetAttr/src/__tests__/unit/message.test.ts +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -305,6 +305,16 @@ describe("message", () => { }); }); + it("should parse max-only syntax with trailing pipe segments", () => { + const result = parse("!setattr --sel --hp||23|43"); + expect(result.changes).toHaveLength(1); + expect(result.changes[0]).toEqual({ + name: "hp", + max: "23", + }); + expect(result.changes[0]).not.toHaveProperty("current"); + }); + it("should parse multiple attributes", () => { const result = parse("!setattr --sel --strength|18 --dexterity|14|16 --constitution"); expect(result.changes).toHaveLength(3); diff --git a/ChatSetAttr/src/__tests__/unit/modifications.test.ts b/ChatSetAttr/src/__tests__/unit/modifications.test.ts index 8e8f03e98b..c8dfd6345d 100644 --- a/ChatSetAttr/src/__tests__/unit/modifications.test.ts +++ b/ChatSetAttr/src/__tests__/unit/modifications.test.ts @@ -413,5 +413,31 @@ describe("modifications", () => { ]); expect(errors).toEqual([]); }); + + it("should omit current when only max is provided (|| max-only syntax)", () => { + const result = processModifications( + [{ name: "hp", max: "23" }], + { hp: "10", hp_max: "20" }, + options, + {}, + [], + "Test Character", + ); + + expect(result).toEqual([{ name: "hp", max: "23" }]); + }); + + it("should skip the literal string undefined as current", () => { + const result = processModifications( + [{ name: "hp", current: "undefined", max: "23" }], + {}, + options, + {}, + [], + "Test Character", + ); + + expect(result).toEqual([{ name: "hp", max: "23" }]); + }); }); }); \ No newline at end of file diff --git a/ChatSetAttr/src/modules/modifications.ts b/ChatSetAttr/src/modules/modifications.ts index ba50d7012a..e9a73b4963 100644 --- a/ChatSetAttr/src/modules/modifications.ts +++ b/ChatSetAttr/src/modules/modifications.ts @@ -127,7 +127,7 @@ export function processModifications( } let processedCurrent = undefined; - if (mod.current !== "undefined") { + if (mod.current !== undefined && mod.current !== "undefined") { processedCurrent = String(mod.current); processedCurrent = processModifierValue(processedCurrent, resolved, { shouldEvaluate: options.evaluate, From af8c739ae1d9c7a9ade30231311ede2c6b5f681f Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 19:22:38 -0500 Subject: [PATCH 32/38] Updated formatting to be more concise and informative, matching 1.10 --- ChatSetAttr/2.0/ChatSetAttr.js | 278 ++++++++++-------- ChatSetAttr/ChatSetAttr.js | 278 ++++++++++-------- .../integration/legacyAttributes.test.ts | 82 +++++- .../src/__tests__/unit/commands.test.ts | 33 --- .../src/__tests__/unit/feedback.test.ts | 89 +++++- ChatSetAttr/src/modules/commands.ts | 134 ++------- ChatSetAttr/src/modules/feedback.ts | 73 ++++- ChatSetAttr/src/modules/main.ts | 67 ++++- 8 files changed, 606 insertions(+), 428 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 0c1e1df5e5..792b6a52b7 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -860,59 +860,6 @@ var ChatSetAttr = (function (exports) { return attributes; } - 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 = getChangedAttributeNames(targetValues); - 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 sheetCurrent = startingValues[attributeName]; - const sheetMax = startingValues[`${attributeName}_max`]; - const resultCurrent = targetValues[attributeName]; - const resultMax = targetValues[`${attributeName}_max`]; - switch (key) { - case "NAME": - return attributeName; - case "TCUR": - return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; - case "TMAX": - return sheetMax !== undefined ? `${sheetMax}` : ""; - case "CUR": { - const value = resultCurrent ?? sheetCurrent; - return value !== undefined ? `${value}` : ""; - } - case "MAX": { - const value = resultMax ?? sheetMax; - return value !== undefined ? `${value}` : ""; - } - default: - return ""; - } - }); - return message; - } - function getChangedAttributeNames(targetValues) { - const seen = new Set(); - const names = []; - for (const key of Object.keys(targetValues)) { - const name = key.endsWith("_max") ? key.slice(0, -4) : key; - if (!seen.has(name)) { - seen.add(name); - names.push(name); - } - } - return names; - } - function cleanValue(value) { return value.trim().replace(/^['"](.*)['"]$/g, "$1"); } @@ -925,9 +872,8 @@ var ChatSetAttr = (function (exports) { } // region Command Handlers - async function setattr(changes, target, referenced = [], noCreate = false, feedback) { + async function setattr(changes, target, referenced = [], noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const request = createRequestList(referenced, changes, false); const currentValues = await getCurrentValues(target, request, changes); @@ -936,41 +882,29 @@ var ChatSetAttr = (function (exports) { for (const change of changes) { const { name, current, max } = change; if (!name) - continue; // skip if no name provided + continue; if (undefinedAttributes.includes(name) && noCreate) { errors.push(`Missing attribute ${name} not created for ${characterName}.`); continue; } if (current !== undefined) { result[name] = current; - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; } if (max !== undefined) { result[`${name}_max`] = max; - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[`${name}_max`] = newMessage; } } return { result, - messagesByKey, errors, }; } - async function modattr(changes, target, referenced, noCreate = false, feedback) { + async function modattr(changes, target, referenced, noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const currentValues = await getCurrentValues(target, referenced, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); - const characterName = getCharName(target); + getCharName(target); for (const change of changes) { const { name, current, max } = change; if (!name) @@ -986,35 +920,23 @@ var ChatSetAttr = (function (exports) { } if (current !== undefined) { result[name] = calculateModifiedValue(asNumber, current); - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; } if (max !== undefined) { result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[`${name}_max`] = newMessage; } } return { result, - messagesByKey, errors, }; } - async function modbattr(changes, target, referenced, noCreate = false, feedback) { + async function modbattr(changes, target, referenced, noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); - const characterName = getCharName(target); + getCharName(target); for (const change of changes) { const { name, current, max } = change; if (!name) @@ -1039,31 +961,19 @@ var ChatSetAttr = (function (exports) { 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); - } - if (current !== undefined) { - messagesByKey[name] = newMessage; - } - if (max !== undefined) { - messagesByKey[`${name}_max`] = newMessage; - } } return { result, - messagesByKey, errors, }; } - async function resetattr(changes, target, referenced, noCreate = false, feedback) { + async function resetattr(changes, target, referenced, noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); - const characterName = getCharName(target); + getCharName(target); for (const change of changes) { const { name } = change; if (!name) @@ -1084,41 +994,23 @@ var ChatSetAttr = (function (exports) { else { result[name] = 0; } - let newMessage = `Reset attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; } return { result, - messagesByKey, errors, }; } - async function delattr(changes, target, referenced, _, feedback) { + async function delattr(changes, target, referenced, _, _feedback) { const result = {}; - const messagesByKey = {}; - 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}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; - if (currentValues[`${name}_max`] !== undefined) { - messagesByKey[`${name}_max`] = newMessage; - } } return { result, - messagesByKey, errors: [], }; } @@ -1213,6 +1105,109 @@ var ChatSetAttr = (function (exports) { return Math.max(Math.min(currentValue, maxValue), 0); } + function formatFeedbackValue(value) { + if (value === undefined || value === null || value === "") { + return "(empty)"; + } + return String(value); + } + function formatAttributePart(name, result) { + const hasCurrent = Object.hasOwn(result, name); + const maxKey = `${name}_max`; + const hasMax = Object.hasOwn(result, maxKey); + if (!hasCurrent && !hasMax) { + return null; + } + if (hasCurrent && hasMax) { + return `${name} to ${formatFeedbackValue(result[name])} / ${formatFeedbackValue(result[maxKey])}`; + } + if (hasCurrent) { + return `${name} to ${formatFeedbackValue(result[name])}`; + } + return `${name} to ${formatFeedbackValue(result[maxKey])} (max)`; + } + function formatSettingFeedback(characterName, changes, result) { + const parts = []; + for (const change of changes) { + if (!change.name) + continue; + const part = formatAttributePart(change.name, result); + if (part) { + parts.push(part); + } + } + if (parts.length === 0) { + return null; + } + return `Setting ${parts.join(", ")} for character ${characterName}.`; + } + function formatDeleteFeedback(characterName, changes, result) { + const names = []; + for (const change of changes) { + if (!change.name) + continue; + if (Object.hasOwn(result, change.name)) { + names.push(change.name); + } + } + if (names.length === 0) { + return null; + } + return `Deleting attribute(s) ${names.join(", ")} for character ${characterName}.`; + } + 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 = getChangedAttributeNames(targetValues); + 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 sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; + case "TMAX": + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } + default: + return ""; + } + }); + return message; + } + function getChangedAttributeNames(targetValues) { + const seen = new Set(); + const names = []; + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; + } + var $schema = "./content.schema.json"; var title = "ChatSetAttr"; var introduction = "ChatSetAttr is a Roll20 Mod 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."; @@ -3439,7 +3434,7 @@ var ChatSetAttr = (function (exports) { } // Execute const priorValues = {}; - const pendingMessages = {}; + const pendingChanges = {}; for (const target of targets) { const attrs = await getAttributes(target, request); priorValues[target] = attrs; @@ -3455,7 +3450,7 @@ var ChatSetAttr = (function (exports) { errors.push(...response.errors); continue; } - pendingMessages[target] = { ...pendingMessages[target], ...response.messagesByKey }; + pendingChanges[target] = modifications; result[target] = response.result; } const updateResult = await makeUpdate(operation, result, { @@ -3463,26 +3458,51 @@ var ChatSetAttr = (function (exports) { priorValues}); clearTimer("chatsetattr"); errors.push(...updateResult.errors); - for (const target in pendingMessages) { - for (const key in pendingMessages[target]) { - if (!updateResult.failed.includes(`${target}:${key}`)) { - messages.push(pendingMessages[target][key]); - } + for (const target in result) { + const filteredResult = filterSuccessfulResult(target, result[target], updateResult.failed); + if (Object.keys(filteredResult).length === 0) { + continue; + } + const characterName = getCharName(target); + const targetChanges = pendingChanges[target] ?? []; + let message; + if (feedback?.content) { + message = createFeedbackMessage(characterName, feedback, priorValues[target] ?? {}, filteredResult); + } + else if (operation === "delattr") { + message = formatDeleteFeedback(characterName, targetChanges, filteredResult); + } + else { + message = formatSettingFeedback(characterName, targetChanges, filteredResult); + } + if (message) { + messages.push(message); } } sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); - const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; + const delSetTitle = operation === "delattr" ? "Deleting attributes" : "Setting attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, { - from: feedback?.from, - public: feedback?.public, - }, output); + if (messages.length > 0) { + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); + } } function errorOut(errorText, playerid, errors, output) { errors.push(errorText); sendErrors(playerid, "Errors", errors, undefined, output); clearTimer("chatsetattr"); } + function filterSuccessfulResult(target, targetResult, failed) { + const filtered = {}; + for (const key in targetResult) { + if (!failed.includes(`${target}:${key}`)) { + filtered[key] = targetResult[key]; + } + } + return filtered; + } function generateRequest(references, changes) { const referenceSet = new Set(references); for (const change of changes) { diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 0c1e1df5e5..792b6a52b7 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -860,59 +860,6 @@ var ChatSetAttr = (function (exports) { return attributes; } - 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 = getChangedAttributeNames(targetValues); - 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 sheetCurrent = startingValues[attributeName]; - const sheetMax = startingValues[`${attributeName}_max`]; - const resultCurrent = targetValues[attributeName]; - const resultMax = targetValues[`${attributeName}_max`]; - switch (key) { - case "NAME": - return attributeName; - case "TCUR": - return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; - case "TMAX": - return sheetMax !== undefined ? `${sheetMax}` : ""; - case "CUR": { - const value = resultCurrent ?? sheetCurrent; - return value !== undefined ? `${value}` : ""; - } - case "MAX": { - const value = resultMax ?? sheetMax; - return value !== undefined ? `${value}` : ""; - } - default: - return ""; - } - }); - return message; - } - function getChangedAttributeNames(targetValues) { - const seen = new Set(); - const names = []; - for (const key of Object.keys(targetValues)) { - const name = key.endsWith("_max") ? key.slice(0, -4) : key; - if (!seen.has(name)) { - seen.add(name); - names.push(name); - } - } - return names; - } - function cleanValue(value) { return value.trim().replace(/^['"](.*)['"]$/g, "$1"); } @@ -925,9 +872,8 @@ var ChatSetAttr = (function (exports) { } // region Command Handlers - async function setattr(changes, target, referenced = [], noCreate = false, feedback) { + async function setattr(changes, target, referenced = [], noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const request = createRequestList(referenced, changes, false); const currentValues = await getCurrentValues(target, request, changes); @@ -936,41 +882,29 @@ var ChatSetAttr = (function (exports) { for (const change of changes) { const { name, current, max } = change; if (!name) - continue; // skip if no name provided + continue; if (undefinedAttributes.includes(name) && noCreate) { errors.push(`Missing attribute ${name} not created for ${characterName}.`); continue; } if (current !== undefined) { result[name] = current; - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; } if (max !== undefined) { result[`${name}_max`] = max; - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[`${name}_max`] = newMessage; } } return { result, - messagesByKey, errors, }; } - async function modattr(changes, target, referenced, noCreate = false, feedback) { + async function modattr(changes, target, referenced, noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const currentValues = await getCurrentValues(target, referenced, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); - const characterName = getCharName(target); + getCharName(target); for (const change of changes) { const { name, current, max } = change; if (!name) @@ -986,35 +920,23 @@ var ChatSetAttr = (function (exports) { } if (current !== undefined) { result[name] = calculateModifiedValue(asNumber, current); - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; } if (max !== undefined) { result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[`${name}_max`] = newMessage; } } return { result, - messagesByKey, errors, }; } - async function modbattr(changes, target, referenced, noCreate = false, feedback) { + async function modbattr(changes, target, referenced, noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); - const characterName = getCharName(target); + getCharName(target); for (const change of changes) { const { name, current, max } = change; if (!name) @@ -1039,31 +961,19 @@ var ChatSetAttr = (function (exports) { 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); - } - if (current !== undefined) { - messagesByKey[name] = newMessage; - } - if (max !== undefined) { - messagesByKey[`${name}_max`] = newMessage; - } } return { result, - messagesByKey, errors, }; } - async function resetattr(changes, target, referenced, noCreate = false, feedback) { + async function resetattr(changes, target, referenced, noCreate = false, _feedback) { const result = {}; - const messagesByKey = {}; const errors = []; const request = createRequestList(referenced, changes, true); const currentValues = await getCurrentValues(target, request, changes); const undefinedAttributes = extractUndefinedAttributes(currentValues); - const characterName = getCharName(target); + getCharName(target); for (const change of changes) { const { name } = change; if (!name) @@ -1084,41 +994,23 @@ var ChatSetAttr = (function (exports) { else { result[name] = 0; } - let newMessage = `Reset attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; } return { result, - messagesByKey, errors, }; } - async function delattr(changes, target, referenced, _, feedback) { + async function delattr(changes, target, referenced, _, _feedback) { const result = {}; - const messagesByKey = {}; - 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}.`; - if (feedback.content) { - newMessage = createFeedbackMessage(characterName, feedback, currentValues, result); - } - messagesByKey[name] = newMessage; - if (currentValues[`${name}_max`] !== undefined) { - messagesByKey[`${name}_max`] = newMessage; - } } return { result, - messagesByKey, errors: [], }; } @@ -1213,6 +1105,109 @@ var ChatSetAttr = (function (exports) { return Math.max(Math.min(currentValue, maxValue), 0); } + function formatFeedbackValue(value) { + if (value === undefined || value === null || value === "") { + return "(empty)"; + } + return String(value); + } + function formatAttributePart(name, result) { + const hasCurrent = Object.hasOwn(result, name); + const maxKey = `${name}_max`; + const hasMax = Object.hasOwn(result, maxKey); + if (!hasCurrent && !hasMax) { + return null; + } + if (hasCurrent && hasMax) { + return `${name} to ${formatFeedbackValue(result[name])} / ${formatFeedbackValue(result[maxKey])}`; + } + if (hasCurrent) { + return `${name} to ${formatFeedbackValue(result[name])}`; + } + return `${name} to ${formatFeedbackValue(result[maxKey])} (max)`; + } + function formatSettingFeedback(characterName, changes, result) { + const parts = []; + for (const change of changes) { + if (!change.name) + continue; + const part = formatAttributePart(change.name, result); + if (part) { + parts.push(part); + } + } + if (parts.length === 0) { + return null; + } + return `Setting ${parts.join(", ")} for character ${characterName}.`; + } + function formatDeleteFeedback(characterName, changes, result) { + const names = []; + for (const change of changes) { + if (!change.name) + continue; + if (Object.hasOwn(result, change.name)) { + names.push(change.name); + } + } + if (names.length === 0) { + return null; + } + return `Deleting attribute(s) ${names.join(", ")} for character ${characterName}.`; + } + 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 = getChangedAttributeNames(targetValues); + 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 sheetCurrent = startingValues[attributeName]; + const sheetMax = startingValues[`${attributeName}_max`]; + const resultCurrent = targetValues[attributeName]; + const resultMax = targetValues[`${attributeName}_max`]; + switch (key) { + case "NAME": + return attributeName; + case "TCUR": + return sheetCurrent !== undefined ? `${sheetCurrent}` : ""; + case "TMAX": + return sheetMax !== undefined ? `${sheetMax}` : ""; + case "CUR": { + const value = resultCurrent ?? sheetCurrent; + return value !== undefined ? `${value}` : ""; + } + case "MAX": { + const value = resultMax ?? sheetMax; + return value !== undefined ? `${value}` : ""; + } + default: + return ""; + } + }); + return message; + } + function getChangedAttributeNames(targetValues) { + const seen = new Set(); + const names = []; + for (const key of Object.keys(targetValues)) { + const name = key.endsWith("_max") ? key.slice(0, -4) : key; + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; + } + var $schema = "./content.schema.json"; var title = "ChatSetAttr"; var introduction = "ChatSetAttr is a Roll20 Mod 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."; @@ -3439,7 +3434,7 @@ var ChatSetAttr = (function (exports) { } // Execute const priorValues = {}; - const pendingMessages = {}; + const pendingChanges = {}; for (const target of targets) { const attrs = await getAttributes(target, request); priorValues[target] = attrs; @@ -3455,7 +3450,7 @@ var ChatSetAttr = (function (exports) { errors.push(...response.errors); continue; } - pendingMessages[target] = { ...pendingMessages[target], ...response.messagesByKey }; + pendingChanges[target] = modifications; result[target] = response.result; } const updateResult = await makeUpdate(operation, result, { @@ -3463,26 +3458,51 @@ var ChatSetAttr = (function (exports) { priorValues}); clearTimer("chatsetattr"); errors.push(...updateResult.errors); - for (const target in pendingMessages) { - for (const key in pendingMessages[target]) { - if (!updateResult.failed.includes(`${target}:${key}`)) { - messages.push(pendingMessages[target][key]); - } + for (const target in result) { + const filteredResult = filterSuccessfulResult(target, result[target], updateResult.failed); + if (Object.keys(filteredResult).length === 0) { + continue; + } + const characterName = getCharName(target); + const targetChanges = pendingChanges[target] ?? []; + let message; + if (feedback?.content) { + message = createFeedbackMessage(characterName, feedback, priorValues[target] ?? {}, filteredResult); + } + else if (operation === "delattr") { + message = formatDeleteFeedback(characterName, targetChanges, filteredResult); + } + else { + message = formatSettingFeedback(characterName, targetChanges, filteredResult); + } + if (message) { + messages.push(message); } } sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); - const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; + const delSetTitle = operation === "delattr" ? "Deleting attributes" : "Setting attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, { - from: feedback?.from, - public: feedback?.public, - }, output); + if (messages.length > 0) { + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); + } } function errorOut(errorText, playerid, errors, output) { errors.push(errorText); sendErrors(playerid, "Errors", errors, undefined, output); clearTimer("chatsetattr"); } + function filterSuccessfulResult(target, targetResult, failed) { + const filtered = {}; + for (const key in targetResult) { + if (!failed.includes(`${target}:${key}`)) { + filtered[key] = targetResult[key]; + } + } + return filtered; + } function generateRequest(references, changes) { const referenceSet = new Set(references); for (const change of changes) { diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index 5c47096fe3..69e2b8191d 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -174,6 +174,78 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should emit one consolidated 1.10-style message for max-only syntax", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createLegacyCharacter({ _id: "char1", name: "The Aaron 2014", controlledby: player.id }); + createObj("attribute", { _id: "hp1", _characterid: character.id, name: "hp", current: "10", max: "20" }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --sel --hp||48", { selected: [token.properties] }); + + await vi.waitFor(async () => { + const hpMax = await libSmartAttributes.getAttribute("char1", "hp", "max"); + expect(hpMax).toBe("48"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Setting attributes") && + call[1].includes("Setting hp to 48 (max) for character The Aaron 2014."), + ); + expect(feedbackCall).toBeDefined(); + + const setAttributeCount = (feedbackCall![1] as string).match(/Set attribute '/g)?.length ?? 0; + expect(setAttributeCount).toBe(0); + }); + }); + + it("should emit one consolidated 1.10-style message for current and max", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createLegacyCharacter({ _id: "char1", name: "The Aaron 2014", controlledby: player.id }); + createObj("attribute", { _id: "hp1", _characterid: character.id, name: "hp", current: "10", max: "20" }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --sel --hp|13|63", { selected: [token.properties] }); + + await vi.waitFor(async () => { + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Setting hp to 13 / 63 for character The Aaron 2014."), + ); + expect(feedbackCall).toBeDefined(); + + const body = feedbackCall![1] as string; + const settingLines = (body.match(/Setting hp to/g) ?? []).length; + expect(settingLines).toBe(1); + }); + }); + + it("should emit one consolidated 1.10-style message for multiple attributes", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + const character = createLegacyCharacter({ _id: "char1", name: "The Aaron 2014", controlledby: player.id }); + const token = createObj("graphic", { _id: "token1", represents: character.id, _subtype: "token" }); + vi.mocked(sendChat).mockClear(); + + executeCommand( + "!setattr --sel --hp|13|63 --ac|18 --speed|50", + { selected: [token.properties] }, + ); + + await vi.waitFor(async () => { + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes( + "Setting hp to 13 / 63, ac to 18, speed to 50 for character The Aaron 2014.", + ), + ); + expect(feedbackCall).toBeDefined(); + + const body = feedbackCall![1] as string; + expect((body.match(/

/g) ?? []).length).toBe(1); + }); + }); + 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); @@ -856,7 +928,7 @@ describe("ChatSetAttr Integration Tests", () => { return call[0] === "ChatSetAttr" && typeof message === "string" && !message.startsWith("/w ") && - message.includes("Setting Attribute"); + message.includes("Setting attributes"); }); expect(feedbackCall).toBeDefined(); @@ -877,7 +949,7 @@ describe("ChatSetAttr Integration Tests", () => { const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => call[0] === "ChatSetAttr" && call[1] && typeof call[1] === "string" && - call[1].includes("Set attribute 'Attribute'") + call[1].includes("Setting Attribute to 42 for character Character 1.") ); expect(feedbackCall).toBeDefined(); @@ -899,7 +971,7 @@ describe("ChatSetAttr Integration Tests", () => { const senderIsWizard = call[0] === "Wizard"; const message = call[1]; const messageIsString = typeof message === "string"; - const messageIncludesFeedback = message.includes("Set attribute 'Spell'"); + const messageIncludesFeedback = message.includes("Setting Spell to Fireball for character Character 1."); return senderIsWizard && messageIsString && messageIncludesFeedback; }); @@ -921,7 +993,7 @@ describe("ChatSetAttr Integration Tests", () => { 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") + !call[1].includes("Setting attributes") ); expect(feedbackCall).toBeDefined(); @@ -1003,7 +1075,7 @@ describe("ChatSetAttr Integration Tests", () => { const publicSuccessCall = vi.mocked(sendChat).mock.calls.find(call => call[1] && typeof call[1] === "string" && !call[1].startsWith("/w ") && - call[1].includes("Set attribute") + /Setting .+ for character/.test(call[1]), ); expect(publicSuccessCall).toBeUndefined(); }); diff --git a/ChatSetAttr/src/__tests__/unit/commands.test.ts b/ChatSetAttr/src/__tests__/unit/commands.test.ts index 5a2133d57e..2d717249b8 100644 --- a/ChatSetAttr/src/__tests__/unit/commands.test.ts +++ b/ChatSetAttr/src/__tests__/unit/commands.test.ts @@ -37,10 +37,6 @@ describe("commands", () => { strength: 15, dexterity: 12, }); - expect(result.messagesByKey).toEqual({ - strength: "Set attribute 'strength' on ID: char1.", - dexterity: "Set attribute 'dexterity' on ID: char1.", - }); expect(result.errors).toEqual([]); }); @@ -56,10 +52,6 @@ describe("commands", () => { hp_max: 25, mp_max: 15, }); - expect(result.messagesByKey).toEqual({ - hp_max: "Set attribute 'hp' on ID: char1.", - mp_max: "Set attribute 'mp' on ID: char1.", - }); expect(result.errors).toEqual([]); }); @@ -125,26 +117,6 @@ describe("commands", () => { }); }); - it("should substitute _MAX0_ from sheet when only current is modified", async () => { - mockGetAttributes.mockResolvedValue({ - hp: 7, - hp_max: 119, - }); - - const changes: Attribute[] = [ - { name: "hp", current: "+3" }, - ]; - const feedback = { - public: false, - content: "_NAME0_ was _TCUR0_/_TMAX0_ now _CUR0_/_MAX0_ for _CHARNAME_", - }; - - const result = await modattr(changes, "char1", [], false, feedback); - - expect(result.messagesByKey.hp).toBe("hp was 7/119 now 10/119 for ID: char1"); - expect(result.messagesByKey.hp).not.toContain("undefined"); - }); - it("should modify current values with subtraction", async () => { const changes: Attribute[] = [ { name: "hp", current: "-3" }, @@ -416,10 +388,6 @@ describe("commands", () => { tempattr: undefined, tempattr_max: undefined, }); - expect(result.messagesByKey).toEqual({ - oldattr: "Deleted attribute 'oldattr' on ID: char1.", - tempattr: "Deleted attribute 'tempattr' on ID: char1.", - }); expect(result.errors).toEqual([]); }); @@ -443,7 +411,6 @@ describe("commands", () => { const result = await delattr(changes, "char1", [], false, feedbackMock); expect(result.result).toEqual({}); - expect(result.messagesByKey).toEqual({}); expect(result.errors).toEqual([]); }); }); diff --git a/ChatSetAttr/src/__tests__/unit/feedback.test.ts b/ChatSetAttr/src/__tests__/unit/feedback.test.ts index 27b2278df3..aac9adc8d7 100644 --- a/ChatSetAttr/src/__tests__/unit/feedback.test.ts +++ b/ChatSetAttr/src/__tests__/unit/feedback.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; -import { createFeedbackMessage } from "../../modules/feedback"; -import type { AttributeRecord, FeedbackObject } from "../../types"; +import { + createFeedbackMessage, + formatDeleteFeedback, + formatSettingFeedback, +} from "../../modules/feedback"; +import type { Attribute, AttributeRecord, FeedbackObject } from "../../types"; + +const characterName = "The Aaron 2014"; describe("createFeedbackMessage", () => { const mockStartingValues: AttributeRecord = { @@ -127,3 +133,82 @@ describe("createFeedbackMessage", () => { expect(result).toBe("Alice updated: hp: 10→25, strength: 15→16"); }); }); + +describe("formatSettingFeedback", () => { + it("should format max-only syntax", () => { + const changes: Attribute[] = [{ name: "hp", max: "48" }]; + const result: AttributeRecord = { hp_max: "48" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 48 (max) for character The Aaron 2014.", + ); + }); + + it("should format current and max", () => { + const changes: Attribute[] = [{ name: "hp", current: "13", max: "63" }]; + const result: AttributeRecord = { hp: "13", hp_max: "63" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 13 / 63 for character The Aaron 2014.", + ); + }); + + it("should format multiple attributes in command order", () => { + const changes: Attribute[] = [ + { name: "hp", current: "13", max: "63" }, + { name: "ac", current: "18" }, + { name: "speed", current: "50" }, + ]; + const result: AttributeRecord = { + hp: "13", + hp_max: "63", + ac: "18", + speed: "50", + }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 13 / 63, ac to 18, speed to 50 for character The Aaron 2014.", + ); + }); + + it("should format current-only changes", () => { + const changes: Attribute[] = [{ name: "ac", current: "18" }]; + const result: AttributeRecord = { ac: "18" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting ac to 18 for character The Aaron 2014.", + ); + }); + + it("should show (empty) for empty string values", () => { + const changes: Attribute[] = [{ name: "notes", current: "" }]; + const result: AttributeRecord = { notes: "" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting notes to (empty) for character The Aaron 2014.", + ); + }); + + it("should omit attributes not present in the filtered result", () => { + const changes: Attribute[] = [{ name: "hp", current: "13", max: "63" }]; + const result: AttributeRecord = { hp: "13" }; + expect(formatSettingFeedback(characterName, changes, result)).toBe( + "Setting hp to 13 for character The Aaron 2014.", + ); + }); + + it("should return null when nothing was set", () => { + const changes: Attribute[] = [{ name: "hp", current: "13" }]; + expect(formatSettingFeedback(characterName, changes, {})).toBeNull(); + }); +}); + +describe("formatDeleteFeedback", () => { + it("should format deleted attribute names", () => { + const changes: Attribute[] = [{ name: "hp" }, { name: "ac" }]; + const result: AttributeRecord = { hp: undefined, hp_max: undefined, ac: undefined }; + expect(formatDeleteFeedback(characterName, changes, result)).toBe( + "Deleting attribute(s) hp, ac for character The Aaron 2014.", + ); + }); + + it("should return null when no attributes were deleted", () => { + const changes: Attribute[] = [{ name: "hp" }]; + expect(formatDeleteFeedback(characterName, changes, {})).toBeNull(); + }); +}); diff --git a/ChatSetAttr/src/modules/commands.ts b/ChatSetAttr/src/modules/commands.ts index f406838679..d2217fbb60 100644 --- a/ChatSetAttr/src/modules/commands.ts +++ b/ChatSetAttr/src/modules/commands.ts @@ -1,11 +1,9 @@ import type { Command, Attribute, AttributeRecord, AttributeValue, FeedbackObject } from "../types"; import { getAttributes } from "./attributes"; -import { createFeedbackMessage } from "./feedback"; import { getCharName } from "./helpers"; export type HandlerResponse = { result: AttributeRecord; - messagesByKey: Record; errors: string[]; }; @@ -23,10 +21,9 @@ export async function setattr( target: string, referenced: string[] = [], noCreate = false, - feedback: FeedbackObject, + _feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; - const messagesByKey: Record = {}; const errors: string[] = []; const request = createRequestList(referenced, changes, false); @@ -36,56 +33,33 @@ export async function setattr( for (const change of changes) { const { name, current, max } = change; - if (!name) continue; // skip if no name provided + if (!name) continue; if (undefinedAttributes.includes(name) && noCreate) { errors.push(`Missing attribute ${name} not created for ${characterName}.`); continue; } if (current !== undefined) { result[name] = current; - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage( - characterName, - feedback, - currentValues, - result, - ); - } - messagesByKey[name] = newMessage; } if (max !== undefined) { result[`${name}_max`] = max; - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage( - characterName, - feedback, - currentValues, - result, - ); - } - messagesByKey[`${name}_max`] = newMessage; } } return { result, - messagesByKey, errors, }; - -}; +} export async function modattr( changes: Attribute[], target: string, referenced: string[], noCreate = false, - feedback: FeedbackObject, + _feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; - const messagesByKey: Record = {}; const errors: string[] = []; const currentValues = await getCurrentValues(target, referenced, changes); @@ -106,48 +80,26 @@ export async function modattr( } if (current !== undefined) { result[name] = calculateModifiedValue(asNumber, current); - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage( - characterName, - feedback, - currentValues, - result, - ); - } - messagesByKey[name] = newMessage; } if (max !== undefined) { result[`${name}_max`] = calculateModifiedValue(currentValues[`${name}_max`], max); - let newMessage = `Set attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage( - characterName, - feedback, - currentValues, - result, - ); - } - messagesByKey[`${name}_max`] = newMessage; } } return { result, - messagesByKey, errors, }; -}; +} export async function modbattr( changes: Attribute[], target: string, referenced: string[], noCreate = false, - feedback: FeedbackObject, + _feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; - const messagesByKey: Record = {}; const errors: string[] = []; const request = createRequestList(referenced, changes, true); @@ -181,28 +133,10 @@ export async function modbattr( newMax, ); } - - let newMessage = `Modified attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage( - characterName, - feedback, - currentValues, - result, - ); - } - - if (current !== undefined) { - messagesByKey[name] = newMessage; - } - if (max !== undefined) { - messagesByKey[`${name}_max`] = newMessage; - } } return { result, - messagesByKey, errors, }; } @@ -212,10 +146,9 @@ export async function resetattr( target: string, referenced: string[], noCreate = false, - feedback: FeedbackObject, + _feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; - const messagesByKey: Record = {}; const errors: string[] = []; const request = createRequestList(referenced, changes, true); @@ -241,23 +174,10 @@ export async function resetattr( } else { result[name] = 0; } - - let newMessage = `Reset attribute '${name}' on ${characterName}.`; - if (feedback.content) { - newMessage = createFeedbackMessage( - characterName, - feedback, - currentValues, - result, - ); - } - - messagesByKey[name] = newMessage; } return { result, - messagesByKey, errors, }; } @@ -267,42 +187,22 @@ export async function delattr( target: string, referenced: string[], _: boolean, - feedback: FeedbackObject, + _feedback: FeedbackObject, ): Promise { const result: AttributeRecord = {}; - const messagesByKey: Record = {}; - 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}.`; - - if (feedback.content) { - newMessage = createFeedbackMessage( - characterName, - feedback, - currentValues, - result, - ); - } - - messagesByKey[name] = newMessage; - - if (currentValues[`${name}_max`] !== undefined) { - messagesByKey[`${name}_max`] = newMessage; - } } + return { result, - messagesByKey, errors: [], }; -}; +} export type HandlerDictionary = { [key in Command]?: HandlerFunction; @@ -333,7 +233,7 @@ function createRequestList( } } return Array.from(requestSet); -}; +} function extractUndefinedAttributes( attributes: AttributeRecord @@ -346,7 +246,7 @@ function extractUndefinedAttributes( } } return names; -}; +} async function getCurrentValues( target: string, @@ -362,7 +262,7 @@ async function getCurrentValues( } const attributes = await getAttributes(target, Array.from(queriedAttributes)); return attributes; -}; +} function calculateModifiedValue( baseValue: string | number | boolean | undefined, @@ -378,7 +278,7 @@ function calculateModifiedValue( 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") { @@ -388,7 +288,7 @@ function getOperator(value: string | number | boolean) { } } return; -}; +} function applyCalculation( baseValue: number, @@ -408,7 +308,7 @@ function applyCalculation( default: return baseValue + modification; } -}; +} function calculateBoundValue( currentValue: AttributeValue, @@ -419,4 +319,4 @@ function calculateBoundValue( if (isNaN(currentValue)) currentValue = 0; if (isNaN(maxValue)) return currentValue; return Math.max(Math.min(currentValue, maxValue), 0); -}; +} diff --git a/ChatSetAttr/src/modules/feedback.ts b/ChatSetAttr/src/modules/feedback.ts index d17deb1dfb..d99f6b5b55 100644 --- a/ChatSetAttr/src/modules/feedback.ts +++ b/ChatSetAttr/src/modules/feedback.ts @@ -1,4 +1,75 @@ -import type { AttributeRecord, FeedbackObject } from "../types"; +import type { Attribute, AttributeRecord, AttributeValue, FeedbackObject } from "../types"; + +function formatFeedbackValue(value: AttributeValue): string { + if (value === undefined || value === null || value === "") { + return "(empty)"; + } + return String(value); +} + +function formatAttributePart( + name: string, + result: AttributeRecord, +): string | null { + const hasCurrent = Object.hasOwn(result, name); + const maxKey = `${name}_max`; + const hasMax = Object.hasOwn(result, maxKey); + + if (!hasCurrent && !hasMax) { + return null; + } + + if (hasCurrent && hasMax) { + return `${name} to ${formatFeedbackValue(result[name])} / ${formatFeedbackValue(result[maxKey])}`; + } + if (hasCurrent) { + return `${name} to ${formatFeedbackValue(result[name])}`; + } + return `${name} to ${formatFeedbackValue(result[maxKey])} (max)`; +} + +export function formatSettingFeedback( + characterName: string, + changes: Attribute[], + result: AttributeRecord, +): string | null { + const parts: string[] = []; + + for (const change of changes) { + if (!change.name) continue; + const part = formatAttributePart(change.name, result); + if (part) { + parts.push(part); + } + } + + if (parts.length === 0) { + return null; + } + + return `Setting ${parts.join(", ")} for character ${characterName}.`; +} + +export function formatDeleteFeedback( + characterName: string, + changes: Attribute[], + result: AttributeRecord, +): string | null { + const names: string[] = []; + + for (const change of changes) { + if (!change.name) continue; + if (Object.hasOwn(result, change.name)) { + names.push(change.name); + } + } + + if (names.length === 0) { + return null; + } + + return `Deleting attribute(s) ${names.join(", ")} for character ${characterName}.`; +} export function createFeedbackMessage( characterName: string, diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index d1b554344f..84a70806f6 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -4,6 +4,11 @@ import { getAttributes } from "./attributes"; import { sendDelayMessage, sendErrors, sendMessages, normalizeCommandOutputOptions } from "./chat"; import { handlers } from "./commands"; import { checkConfigMessage, getConfig, handleConfigCommand, hasFlag } from "./config"; +import { + createFeedbackMessage, + formatDeleteFeedback, + formatSettingFeedback, +} from "./feedback"; import { checkHelpMessage, handleHelpCommand } from "./help"; import { getCharName } from "./helpers"; import { processInlinerolls } from "./inlinerolls"; @@ -87,7 +92,7 @@ async function acceptMessage(msg: Roll20ChatMessage) { // Execute const priorValues: Record = {}; - const pendingMessages: Record> = {}; + const pendingChanges: Record = {}; for (const target of targets) { const attrs = await getAttributes(target, request); @@ -111,7 +116,7 @@ async function acceptMessage(msg: Roll20ChatMessage) { continue; } - pendingMessages[target] = { ...pendingMessages[target], ...response.messagesByKey }; + pendingChanges[target] = modifications; result[target] = response.result; } @@ -125,21 +130,43 @@ async function acceptMessage(msg: Roll20ChatMessage) { errors.push(...updateResult.errors); - for (const target in pendingMessages) { - for (const key in pendingMessages[target]) { - if (!updateResult.failed.includes(`${target}:${key}`)) { - messages.push(pendingMessages[target][key]); - } + for (const target in result) { + const filteredResult = filterSuccessfulResult(target, result[target], updateResult.failed); + if (Object.keys(filteredResult).length === 0) { + continue; + } + + const characterName = getCharName(target); + const targetChanges = pendingChanges[target] ?? []; + let message: string | null; + + if (feedback?.content) { + message = createFeedbackMessage( + characterName, + feedback, + priorValues[target] ?? {}, + filteredResult, + ); + } else if (operation === "delattr") { + message = formatDeleteFeedback(characterName, targetChanges, filteredResult); + } else { + message = formatSettingFeedback(characterName, targetChanges, filteredResult); + } + + if (message) { + messages.push(message); } } sendErrors(msg.playerid, "Errors", errors, feedback?.from, output); - const delSetTitle = operation === "delattr" ? "Deleting Attributes" : "Setting Attributes"; + const delSetTitle = operation === "delattr" ? "Deleting attributes" : "Setting attributes"; const feedbackTitle = feedback?.header ?? delSetTitle; - sendMessages(msg.playerid, feedbackTitle, messages, { - from: feedback?.from, - public: feedback?.public, - }, output); + if (messages.length > 0) { + sendMessages(msg.playerid, feedbackTitle, messages, { + from: feedback?.from, + public: feedback?.public, + }, output); + } }; function errorOut( @@ -153,6 +180,22 @@ function errorOut( clearTimer("chatsetattr"); } +function filterSuccessfulResult( + target: string, + targetResult: AttributeRecord, + failed: string[], +): AttributeRecord { + const filtered: AttributeRecord = {}; + + for (const key in targetResult) { + if (!failed.includes(`${target}:${key}`)) { + filtered[key] = targetResult[key]; + } + } + + return filtered; +} + export function generateRequest( references: string[], From 99cced2017cd4f9a37b0de5d2ba87e5688c64bd1 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 19:54:24 -0500 Subject: [PATCH 33/38] Fixing embedded syntax for roll templates. --- ChatSetAttr/2.0/ChatSetAttr.js | 10 ++- ChatSetAttr/ChatSetAttr.js | 10 ++- ChatSetAttr/README.md | 2 +- ChatSetAttr/docs/help/content.json | 2 +- ChatSetAttr/docs/help/content.revision.json | 4 +- ChatSetAttr/script.json | 2 +- .../integration/legacyAttributes.test.ts | 70 +++++++++++++++++++ .../src/__tests__/unit/inlinerolls.test.ts | 57 ++++++++++++++- ChatSetAttr/src/modules/inlinerolls.ts | 12 ++++ ChatSetAttr/src/modules/main.ts | 3 +- 10 files changed, 161 insertions(+), 11 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 792b6a52b7..bb18eac382 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -1998,7 +1998,7 @@ var ChatSetAttr = (function (exports) { { type: "codeBlock", lines: [ - "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + "&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" ] } ] @@ -2486,7 +2486,7 @@ var ChatSetAttr = (function (exports) { return renderHelpHtml(loadHelpDocument(), handoutID); } - var updatedAt = 1781300523076; + var updatedAt = 1781656733311; var contentRevision = { updatedAt: updatedAt }; @@ -2555,6 +2555,11 @@ var ChatSetAttr = (function (exports) { const tableText = tableItems.filter(Boolean).join(", "); return (tableText.length && tableText) || roll.results.total || 0; } + function normalizeTemplateRollProperties(content) { + return content + .replace(/\{\{[^}[\]]+=\$?\[\[(\d+)\]\].*?\}\}/g, (_, index) => `$[[${index}]]`) + .replace(/\{\{[^}=]+=([^}]+)\}\}/g, (_, value) => value.trim()); + } function processInlinerolls(msg) { if (!msg.inlinerolls?.length) { return msg.content; @@ -3531,6 +3536,7 @@ var ChatSetAttr = (function (exports) { return; msg.content = inlineMessage; } + msg.content = normalizeTemplateRollProperties(msg.content); msg.content = processInlinerolls(msg); const debugReset = msg.content.startsWith("!setattrs-debugreset"); if (debugReset) { diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 792b6a52b7..bb18eac382 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1998,7 +1998,7 @@ var ChatSetAttr = (function (exports) { { type: "codeBlock", lines: [ - "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + "&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" ] } ] @@ -2486,7 +2486,7 @@ var ChatSetAttr = (function (exports) { return renderHelpHtml(loadHelpDocument(), handoutID); } - var updatedAt = 1781300523076; + var updatedAt = 1781656733311; var contentRevision = { updatedAt: updatedAt }; @@ -2555,6 +2555,11 @@ var ChatSetAttr = (function (exports) { const tableText = tableItems.filter(Boolean).join(", "); return (tableText.length && tableText) || roll.results.total || 0; } + function normalizeTemplateRollProperties(content) { + return content + .replace(/\{\{[^}[\]]+=\$?\[\[(\d+)\]\].*?\}\}/g, (_, index) => `$[[${index}]]`) + .replace(/\{\{[^}=]+=([^}]+)\}\}/g, (_, value) => value.trim()); + } function processInlinerolls(msg) { if (!msg.inlinerolls?.length) { return msg.content; @@ -3531,6 +3536,7 @@ var ChatSetAttr = (function (exports) { return; msg.content = inlineMessage; } + msg.content = normalizeTemplateRollProperties(msg.content); msg.content = processInlinerolls(msg); const debugReset = msg.content.startsWith("!setattrs-debugreset"); if (debugReset) { diff --git a/ChatSetAttr/README.md b/ChatSetAttr/README.md index 36369458a9..d9f7ec3cd0 100644 --- a/ChatSetAttr/README.md +++ b/ChatSetAttr/README.md @@ -400,7 +400,7 @@ ChatSetAttr can be used within roll templates or combined with inline rolls: 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}} +&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}} ``` ### Using Inline Rolls in Values diff --git a/ChatSetAttr/docs/help/content.json b/ChatSetAttr/docs/help/content.json index e39f5db79a..09be30f8c5 100644 --- a/ChatSetAttr/docs/help/content.json +++ b/ChatSetAttr/docs/help/content.json @@ -789,7 +789,7 @@ { "type": "codeBlock", "lines": [ - "&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" + "&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}" ] } ] diff --git a/ChatSetAttr/docs/help/content.revision.json b/ChatSetAttr/docs/help/content.revision.json index fb84de1d70..f18d6010af 100644 --- a/ChatSetAttr/docs/help/content.revision.json +++ b/ChatSetAttr/docs/help/content.revision.json @@ -1,4 +1,4 @@ { - "contentHash": "4707ec7d53697d7f38ee83ad512e84a0db54e177816d6504e99978e0bc446261", - "updatedAt": 1781300523076 + "contentHash": "4399ea631338695ed2401276e0254f96157de7ac70993e8140ee89b773fd1d60", + "updatedAt": 1781656733311 } diff --git a/ChatSetAttr/script.json b/ChatSetAttr/script.json index 17cb58ac17..77bd1ddcee 100644 --- a/ChatSetAttr/script.json +++ b/ChatSetAttr/script.json @@ -2,7 +2,7 @@ "name": "ChatSetAttr", "script": "ChatSetAttr.js", "version": "2.0", - "description": "# ChatSetAttr\n\nChatSetAttr is a Roll20 Mod 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.\n\n## Basic Usage\n\nThe script provides several command formats:\n\n- `!setattr [--options]` - Create or modify attributes\n- `!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values)\n- `!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds)\n- `!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values)\n- `!delattr [--options]` - Delete attributes\n\nEach command requires a target selection option and one or more attributes to modify.\n\n**Basic structure:**\n\n```\n!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2\n```\n\n## Available Commands\n\n### !setattr\n\nCreates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified).\n\n**Example:**\n\n```\n!setattr --sel --hp|25|50 --hp_temp|8\n```\n\nThis would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8.\n\n### !modattr\n\nAdds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`.\n\n**Example:**\n\n```\n!modattr --sel --hp_temp|-5 --hp|6\n```\n\nThis subtracts 5 from `hp_temp` and adds 6 to `hp`.\n\n### !modbattr\n\nAdds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`.\n\n**Example:**\n\n```\n!modbattr --sel --hp_temp|-5 --hp|25\n```\n\nThis subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`.\n\n### !resetattr\n\nResets attributes to their maximum value. Shorthand for `!setattr --reset`.\n\n**Example:**\n\n```\n!resetattr --sel --hp\n```\n\nThis resets `hp` to its maximum value.\n\n### !delattr\n\nDeletes the specified attributes.\n\n**Example:**\n\n```\n!delattr --sel --hp --hp_temp\n```\n\nThis removes the `hp` and `hp_temp` attributes.\n\n## Beacon Computed Values\n\nBeacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.\n\nSome Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.\n\nFor player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired.\n\n**Example:**\n\n```\n!setattr --sel --spellpoints|18\n```\n\nThis will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute.\n\n## Target Selection\n\nOne of these options must be specified to determine which characters will be affected:\n\n### --all\n\nAffects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns.\n\n**Example:**\n\n```\n!resetattr --all --hp\n```\n\n### --allgm\n\nAffects all characters without player controllers (typically NPCs). **GM only**.\n\n**Example:**\n\n```\n!setattr --allgm --reset --hp\n```\n\n### --allplayers\n\nAffects all characters with player controllers (typically PCs).\n\n**Example:**\n\n```\n!setattr --allplayers --mod --hp|-15\n```\n\n### --charid\n\nAffects characters with the specified character IDs. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --charid --hp|150\n```\n\n### --name\n\nAffects characters with the specified names. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"\n```\n\n### --sel\n\nAffects characters represented by currently selected tokens.\n\n**Example:**\n\n```\n!setattr --sel --hp|25 --hp_temp|8\n```\n\n### --sel-party\n\nAffects only party characters represented by currently selected tokens (characters with `inParty` set to true).\n\n**Example:**\n\n```\n!setattr --sel-party --inspiration|1\n```\n\n### --sel-noparty\n\nAffects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set).\n\n**Example:**\n\n```\n!setattr --sel-noparty --npc_status|\"Hostile\"\n```\n\n### --party\n\nAffects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration.\n\n**Example:**\n\n```\n!setattr --party --rest_complete|1\n```\n\n## Attribute Syntax\n\nThe syntax for specifying attributes is:\n\n```\n--attributeName|currentValue|maxValue\n```\n\n- `attributeName` is the name of the attribute to modify\n- `currentValue` is the value to set (optional for some commands)\n- `maxValue` is the maximum value to set (optional)\n\n### Examples:\n\n1. Set current value only:\n```\n--strength|15\n```\n2. Set both current and maximum values:\n```\n--hp|27|35\n```\n3. Set only the maximum value (leave current unchanged):\n```\n--hp||50\n```\n4. Create empty attribute or set to empty:\n```\n--notes|\n```\n5. Use `#` instead of `|` (useful in roll queries):\n```\n--strength#15\n```\n\n## Modifier Options\n\nThese options change how attributes are processed:\n\n### --mod\n\nSee `!modattr` command.\n\n### --modb\n\nSee `!modbattr` command.\n\n### --reset\n\nSee `!resetattr` command.\n\n### --nocreate\n\nPrevents creation of new attributes, only updates existing ones.\n\n**Example:**\n\n```\n!setattr --sel --nocreate --perception|20 --hp|15\n```\n\nThis will only update `perception` or `hp` if it already exists.\n\n### --evaluate\n\nEvaluates JavaScript expressions in attribute values. **GM only by default**.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --hp|2 * 3\n```\n\nThis will set the `hp` attribute to 6.\n\n### --replace\n\nReplaces special characters to prevent Roll20 from evaluating them:\n\n- < becomes [\n- > becomes ]\n- ~ becomes -\n- ; becomes ?\n- ` becomes @\n\nAlso supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?.\n\n**Example:**\n\n```\n!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"\n```\n\nThis stores \"Roll [[1d6]] to succeed\" without evaluating the roll.\n\n## Output Control Options\n\nThese options control the feedback messages generated by the script:\n\n### --silent\n\nSuppresses normal output messages (error messages will still appear).\n\n**Example:**\n\n```\n!setattr --sel --silent --stealth|20\n```\n\n### --mute\n\nSuppresses all output messages, including errors.\n\n**Example:**\n\n```\n!setattr --sel --mute --nocreate --new_value|42\n```\n\n### --fb-public\n\nSends output publicly to the chat instead of whispering to the command sender.\n\n**Example:**\n\n```\n!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"\n```\n\n### --fb-from \n\nChanges the name of the sender for output messages (default is \"ChatSetAttr\").\n\n**Example:**\n\n```\n!setattr --sel --fb-from \"Healing Potion\" --hp|25\n```\n\n### --fb-header \n\nCustomizes the header of the output message.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5\n```\n\n### --fb-content \n\nCustomizes the content of the output message.\n\n**Example:**\n\n```\n!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10\n```\n\n### Special Placeholders\n\nFor use in `--fb-header` and `--fb-content`:\n\n- `_NAMEJ_` - Name of the Jth attribute being changed\n- `_TCURJ_` - Target current value of the Jth attribute\n- `_TMAXJ_` - Target maximum value of the Jth attribute\n\nFor use in `--fb-content` only:\n\n- `_CHARNAME_` - Name of the character\n- `_CURJ_` - Final current value of the Jth attribute\n- `_MAXJ_` - Final maximum value of the Jth attribute\n\n**Important:** The Jth index starts with 0 at the first item.\n\n**Example:**\n\n```\n!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10\n```\n\n## Inline Roll Integration\n\nChatSetAttr can be used within roll templates or combined with inline rolls:\n\n### Within Roll Templates\n\nPlace the command between roll template properties and end it with `!!!`:\n\n```\n&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}\n```\n\n### Using Inline Rolls in Values\n\nInline rolls can be used for attribute values:\n\n```\n!setattr --sel --hp|[[2d6+5]]\n```\n\n### Roll Queries\n\nRoll queries can determine attribute values:\n\n```\n!setattr --sel --hp|?{Set strength to what value?|100}\n```\n\n## Repeating Section Support\n\nChatSetAttr supports working with repeating sections:\n\n### Creating New Repeating Items\n\nUse `CREATE` to create a new row in a repeating section:\n\n```\n!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2\n```\n\n### Modifying Existing Repeating Items\n\nAccess by row ID:\n\n```\n!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"\n```\n\nAccess by index (starts at 0):\n\n```\n!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"\n```\n\n### Deleting Repeating Rows\n\nDelete by row ID:\n\n```\n!delattr --sel --repeating_inventory_ID\n```\n\nDelete by index:\n\n```\n!delattr --sel --repeating_inventory_$0\n```\n\n> **Note:** repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.\n\n## Special Value Expressions\n\n### Attribute References\n\nReference other attribute values using `%attribute_name%`:\n\n```\n!setattr --sel --evaluate --temp_hp|%hp% / 2\n```\n\n### Resetting to Maximum\n\nReset an attribute to its maximum value:\n\n```\n!setattr --sel --hp|%hp_max%\n```\n\n## Global Configuration\n\nThe script has four global configuration options that can be toggled with `!setattr-config`:\n\n### --players-can-modify\n\nAllows players to modify attributes on characters they don't control.\n\n```\n!setattr-config --players-can-modify\n```\n\n### --players-can-evaluate\n\nAllows players to use the `--evaluate` option.\n\n```\n!setattr-config --players-can-evaluate\n```\n\n### --players-can-target-party\n\nAllows players to use the `--party` target option. **GM only by default**.\n\n```\n!setattr-config --players-can-target-party\n```\n\n### --use-workers\n\nToggles whether the script triggers sheet workers when setting attributes.\n\n```\n!setattr-config --use-workers\n```\n\n## Complete Examples\n\n### Basic Combat Example\n\nReduce a character's HP and status after taking damage:\n\n```\n!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"\n```\n\n### Leveling Up a Character\n\nUpdate multiple stats when a character gains a level:\n\n```\n!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public\n```\n\n### Create New Item in Inventory\n\nAdd a new item to a character's inventory:\n\n```\n!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\"\n```\n\n### Apply Status Effects During Combat\n\nApply a debuff to selected enemies in the middle of combat:\n\n```\n&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}\n```\n\n### Party Management Examples\n\nGive inspiration to all party members after a great roleplay moment:\n\n```\n!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"\n```\n\nApply a long rest to only party characters among selected tokens:\n\n```\n!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"\n```\n\nSet hostile status for non-party characters among selected tokens:\n\n```\n!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"\n```\n\n## For Developers\n\n### Registering Observers\n\nIf you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:\n\n```\nChatSetAttr.registerObserver(event, observer);\n```\n\nWhere `event` is one of:\n\n- `\"add\"` - Called when attributes are created\n- `\"change\"` - Called when attributes are modified\n- `\"destroy\"` - Called when attributes are deleted\n\nAnd `observer` is an event handler function similar to Roll20's built-in event handlers.\n\nThis allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface.", + "description": "# ChatSetAttr\n\nChatSetAttr is a Roll20 Mod 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.\n\n## Basic Usage\n\nThe script provides several command formats:\n\n- `!setattr [--options]` - Create or modify attributes\n- `!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values)\n- `!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds)\n- `!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values)\n- `!delattr [--options]` - Delete attributes\n\nEach command requires a target selection option and one or more attributes to modify.\n\n**Basic structure:**\n\n```\n!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2\n```\n\n## Available Commands\n\n### !setattr\n\nCreates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified).\n\n**Example:**\n\n```\n!setattr --sel --hp|25|50 --hp_temp|8\n```\n\nThis would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8.\n\n### !modattr\n\nAdds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`.\n\n**Example:**\n\n```\n!modattr --sel --hp_temp|-5 --hp|6\n```\n\nThis subtracts 5 from `hp_temp` and adds 6 to `hp`.\n\n### !modbattr\n\nAdds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`.\n\n**Example:**\n\n```\n!modbattr --sel --hp_temp|-5 --hp|25\n```\n\nThis subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`.\n\n### !resetattr\n\nResets attributes to their maximum value. Shorthand for `!setattr --reset`.\n\n**Example:**\n\n```\n!resetattr --sel --hp\n```\n\nThis resets `hp` to its maximum value.\n\n### !delattr\n\nDeletes the specified attributes.\n\n**Example:**\n\n```\n!delattr --sel --hp --hp_temp\n```\n\nThis removes the `hp` and `hp_temp` attributes.\n\n## Beacon Computed Values\n\nBeacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.\n\nSome Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.\n\nFor player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired.\n\n**Example:**\n\n```\n!setattr --sel --spellpoints|18\n```\n\nThis will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute.\n\n## Target Selection\n\nOne of these options must be specified to determine which characters will be affected:\n\n### --all\n\nAffects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns.\n\n**Example:**\n\n```\n!resetattr --all --hp\n```\n\n### --allgm\n\nAffects all characters without player controllers (typically NPCs). **GM only**.\n\n**Example:**\n\n```\n!setattr --allgm --reset --hp\n```\n\n### --allplayers\n\nAffects all characters with player controllers (typically PCs).\n\n**Example:**\n\n```\n!setattr --allplayers --mod --hp|-15\n```\n\n### --charid\n\nAffects characters with the specified character IDs. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --charid --hp|150\n```\n\n### --name\n\nAffects characters with the specified names. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"\n```\n\n### --sel\n\nAffects characters represented by currently selected tokens.\n\n**Example:**\n\n```\n!setattr --sel --hp|25 --hp_temp|8\n```\n\n### --sel-party\n\nAffects only party characters represented by currently selected tokens (characters with `inParty` set to true).\n\n**Example:**\n\n```\n!setattr --sel-party --inspiration|1\n```\n\n### --sel-noparty\n\nAffects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set).\n\n**Example:**\n\n```\n!setattr --sel-noparty --npc_status|\"Hostile\"\n```\n\n### --party\n\nAffects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration.\n\n**Example:**\n\n```\n!setattr --party --rest_complete|1\n```\n\n## Attribute Syntax\n\nThe syntax for specifying attributes is:\n\n```\n--attributeName|currentValue|maxValue\n```\n\n- `attributeName` is the name of the attribute to modify\n- `currentValue` is the value to set (optional for some commands)\n- `maxValue` is the maximum value to set (optional)\n\n### Examples:\n\n1. Set current value only:\n```\n--strength|15\n```\n2. Set both current and maximum values:\n```\n--hp|27|35\n```\n3. Set only the maximum value (leave current unchanged):\n```\n--hp||50\n```\n4. Create empty attribute or set to empty:\n```\n--notes|\n```\n5. Use `#` instead of `|` (useful in roll queries):\n```\n--strength#15\n```\n\n## Modifier Options\n\nThese options change how attributes are processed:\n\n### --mod\n\nSee `!modattr` command.\n\n### --modb\n\nSee `!modbattr` command.\n\n### --reset\n\nSee `!resetattr` command.\n\n### --nocreate\n\nPrevents creation of new attributes, only updates existing ones.\n\n**Example:**\n\n```\n!setattr --sel --nocreate --perception|20 --hp|15\n```\n\nThis will only update `perception` or `hp` if it already exists.\n\n### --evaluate\n\nEvaluates JavaScript expressions in attribute values. **GM only by default**.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --hp|2 * 3\n```\n\nThis will set the `hp` attribute to 6.\n\n### --replace\n\nReplaces special characters to prevent Roll20 from evaluating them:\n\n- < becomes [\n- > becomes ]\n- ~ becomes -\n- ; becomes ?\n- ` becomes @\n\nAlso supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?.\n\n**Example:**\n\n```\n!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"\n```\n\nThis stores \"Roll [[1d6]] to succeed\" without evaluating the roll.\n\n## Output Control Options\n\nThese options control the feedback messages generated by the script:\n\n### --silent\n\nSuppresses normal output messages (error messages will still appear).\n\n**Example:**\n\n```\n!setattr --sel --silent --stealth|20\n```\n\n### --mute\n\nSuppresses all output messages, including errors.\n\n**Example:**\n\n```\n!setattr --sel --mute --nocreate --new_value|42\n```\n\n### --fb-public\n\nSends output publicly to the chat instead of whispering to the command sender.\n\n**Example:**\n\n```\n!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"\n```\n\n### --fb-from \n\nChanges the name of the sender for output messages (default is \"ChatSetAttr\").\n\n**Example:**\n\n```\n!setattr --sel --fb-from \"Healing Potion\" --hp|25\n```\n\n### --fb-header \n\nCustomizes the header of the output message.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5\n```\n\n### --fb-content \n\nCustomizes the content of the output message.\n\n**Example:**\n\n```\n!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10\n```\n\n### Special Placeholders\n\nFor use in `--fb-header` and `--fb-content`:\n\n- `_NAMEJ_` - Name of the Jth attribute being changed\n- `_TCURJ_` - Target current value of the Jth attribute\n- `_TMAXJ_` - Target maximum value of the Jth attribute\n\nFor use in `--fb-content` only:\n\n- `_CHARNAME_` - Name of the character\n- `_CURJ_` - Final current value of the Jth attribute\n- `_MAXJ_` - Final maximum value of the Jth attribute\n\n**Important:** The Jth index starts with 0 at the first item.\n\n**Example:**\n\n```\n!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10\n```\n\n## Inline Roll Integration\n\nChatSetAttr can be used within roll templates or combined with inline rolls:\n\n### Within Roll Templates\n\nPlace the command between roll template properties and end it with `!!!`:\n\n```\n&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}\n```\n\n### Using Inline Rolls in Values\n\nInline rolls can be used for attribute values:\n\n```\n!setattr --sel --hp|[[2d6+5]]\n```\n\n### Roll Queries\n\nRoll queries can determine attribute values:\n\n```\n!setattr --sel --hp|?{Set strength to what value?|100}\n```\n\n## Repeating Section Support\n\nChatSetAttr supports working with repeating sections:\n\n### Creating New Repeating Items\n\nUse `CREATE` to create a new row in a repeating section:\n\n```\n!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2\n```\n\n### Modifying Existing Repeating Items\n\nAccess by row ID:\n\n```\n!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"\n```\n\nAccess by index (starts at 0):\n\n```\n!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"\n```\n\n### Deleting Repeating Rows\n\nDelete by row ID:\n\n```\n!delattr --sel --repeating_inventory_ID\n```\n\nDelete by index:\n\n```\n!delattr --sel --repeating_inventory_$0\n```\n\n> **Note:** repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.\n\n## Special Value Expressions\n\n### Attribute References\n\nReference other attribute values using `%attribute_name%`:\n\n```\n!setattr --sel --evaluate --temp_hp|%hp% / 2\n```\n\n### Resetting to Maximum\n\nReset an attribute to its maximum value:\n\n```\n!setattr --sel --hp|%hp_max%\n```\n\n## Global Configuration\n\nThe script has four global configuration options that can be toggled with `!setattr-config`:\n\n### --players-can-modify\n\nAllows players to modify attributes on characters they don't control.\n\n```\n!setattr-config --players-can-modify\n```\n\n### --players-can-evaluate\n\nAllows players to use the `--evaluate` option.\n\n```\n!setattr-config --players-can-evaluate\n```\n\n### --players-can-target-party\n\nAllows players to use the `--party` target option. **GM only by default**.\n\n```\n!setattr-config --players-can-target-party\n```\n\n### --use-workers\n\nToggles whether the script triggers sheet workers when setting attributes.\n\n```\n!setattr-config --use-workers\n```\n\n## Complete Examples\n\n### Basic Combat Example\n\nReduce a character's HP and status after taking damage:\n\n```\n!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"\n```\n\n### Leveling Up a Character\n\nUpdate multiple stats when a character gains a level:\n\n```\n!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public\n```\n\n### Create New Item in Inventory\n\nAdd a new item to a character's inventory:\n\n```\n!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\"\n```\n\n### Apply Status Effects During Combat\n\nApply a debuff to selected enemies in the middle of combat:\n\n```\n&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}\n```\n\n### Party Management Examples\n\nGive inspiration to all party members after a great roleplay moment:\n\n```\n!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"\n```\n\nApply a long rest to only party characters among selected tokens:\n\n```\n!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"\n```\n\nSet hostile status for non-party characters among selected tokens:\n\n```\n!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"\n```\n\n## For Developers\n\n### Registering Observers\n\nIf you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:\n\n```\nChatSetAttr.registerObserver(event, observer);\n```\n\nWhere `event` is one of:\n\n- `\"add\"` - Called when attributes are created\n- `\"change\"` - Called when attributes are modified\n- `\"destroy\"` - Called when attributes are deleted\n\nAnd `observer` is an event handler function similar to Roll20's built-in event handlers.\n\nThis allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface.", "authors": [ "Jakob", "GUD Team" diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index 69e2b8191d..1562661120 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -382,6 +382,76 @@ describe("ChatSetAttr Integration Tests", () => { }); }); + it("should subtract template roll damage with --mod from a roll template macro", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "hp1", _characterid: "char1", name: "hp", current: "50" }); + + executeCommand( + "&{template:default} {{name=Fireball Damage}} !setattr --charid char1 --silent --mod --hp|-{{damage=$[[0]]}}!!! {{effect=Fire damage}}", + { + type: "general", + inlinerolls: [{ + expression: "8d6", + results: { + resultType: "sum", + total: 34, + type: "V", + rolls: [{ + dice: 8, + sides: 6, + type: "R", + results: [], + }], + }, + rollid: "roll-fireball", + signature: "sig-fireball", + }], + }, + ); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "hp"); + expect(String(hp)).toBe("16"); + expect(String(hp)).not.toContain("{{damage"); + }); + }); + + it("should set negative template roll damage without --mod from a roll template macro", async () => { + const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + createLegacyCharacter({ _id: "char1", name: "Character 1", controlledby: player.id }); + createObj("attribute", { _id: "hp1", _characterid: "char1", name: "hp", current: "50" }); + + executeCommand( + "&{template:default} {{name=Fireball Damage}} !setattr --charid char1 --silent --hp|-{{damage=$[[0]]}}!!! {{effect=Fire damage}}", + { + type: "general", + inlinerolls: [{ + expression: "8d6", + results: { + resultType: "sum", + total: 34, + type: "V", + rolls: [{ + dice: 8, + sides: 6, + type: "R", + results: [], + }], + }, + rollid: "roll-fireball", + signature: "sig-fireball", + }], + }, + ); + + await vi.waitFor(async () => { + const hp = await libSmartAttributes.getAttribute("char1", "hp"); + expect(String(hp)).toBe("-34"); + expect(String(hp)).not.toContain("{{damage"); + }); + }); + it("should substitute rollable table inline roll placeholders in attribute values", async () => { const player = createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); createObj("character", { _id: "char1", name: "Character 1", controlledby: player.id }); diff --git a/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts b/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts index 2a3d01a32a..e5b0cf2b28 100644 --- a/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts +++ b/ChatSetAttr/src/__tests__/unit/inlinerolls.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { processInlinerolls } from "../../modules/inlinerolls"; +import { + normalizeTemplateRollProperties, + processInlinerolls, +} from "../../modules/inlinerolls"; function makeDiceRoll(total: number): RollData { return { @@ -102,3 +105,55 @@ describe("processInlinerolls", () => { expect(result).toBe("!setattr --sel --hp|0"); }); }); + +function processCommandContent( + content: string, + inlinerolls?: RollData[], +): string { + return processInlinerolls({ + content: normalizeTemplateRollProperties(content), + inlinerolls, + }); +} + +describe("normalizeTemplateRollProperties", () => { + it("should unwrap template properties containing inline roll placeholders", () => { + const content = "!setattr --charid char1 --mod --hp|-{{damage=$[[0]]}}"; + expect(normalizeTemplateRollProperties(content)).toBe( + "!setattr --charid char1 --mod --hp|-$[[0]]", + ); + }); + + it("should unwrap resolved template property values", () => { + const content = "!setattr --charid char1 --hp|-{{damage=34}}"; + expect(normalizeTemplateRollProperties(content)).toBe( + "!setattr --charid char1 --hp|-34", + ); + }); +}); + +describe("template roll pipeline", () => { + it("should resolve mod damage from template inline roll placeholders", () => { + const result = processCommandContent( + "!setattr --charid char1 --mod --hp|-{{damage=$[[0]]}}", + [makeDiceRoll(34)], + ); + expect(result).toBe("!setattr --charid char1 --mod --hp|-34"); + }); + + it("should resolve setattr damage from template inline roll placeholders", () => { + const result = processCommandContent( + "!setattr --charid char1 --hp|-{{damage=$[[0]]}}", + [makeDiceRoll(34)], + ); + expect(result).toBe("!setattr --charid char1 --hp|-34"); + }); + + it("should resolve setattr damage from already-resolved template properties", () => { + const result = processCommandContent( + "!setattr --charid char1 --hp|-{{damage=34}}", + [makeDiceRoll(34)], + ); + expect(result).toBe("!setattr --charid char1 --hp|-34"); + }); +}); diff --git a/ChatSetAttr/src/modules/inlinerolls.ts b/ChatSetAttr/src/modules/inlinerolls.ts index 6ca44cb1e6..021c4d4aa5 100644 --- a/ChatSetAttr/src/modules/inlinerolls.ts +++ b/ChatSetAttr/src/modules/inlinerolls.ts @@ -27,6 +27,18 @@ function inlineRollValue(roll: RollData): string | number { return (tableText.length && tableText) || roll.results.total || 0; } +export function normalizeTemplateRollProperties(content: string): string { + return content + .replace( + /\{\{[^}[\]]+=\$?\[\[(\d+)\]\].*?\}\}/g, + (_, index) => `$[[${index}]]`, + ) + .replace( + /\{\{[^}=]+=([^}]+)\}\}/g, + (_, value: string) => value.trim(), + ); +} + export function processInlinerolls( msg: Pick, ): string { diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 84a70806f6..814eb580a1 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -11,7 +11,7 @@ import { } from "./feedback"; import { checkHelpMessage, handleHelpCommand } from "./help"; import { getCharName } from "./helpers"; -import { processInlinerolls } from "./inlinerolls"; +import { processInlinerolls, normalizeTemplateRollProperties } from "./inlinerolls"; import { extractMessageFromRollTemplate, parseMessage, validateMessage } from "./message"; import { processModifications } from "./modifications"; import { checkPermissions } from "./permissions"; @@ -229,6 +229,7 @@ export function registerHandlers() { if (!inlineMessage) return; msg.content = inlineMessage; } + msg.content = normalizeTemplateRollProperties(msg.content); msg.content = processInlinerolls(msg); const debugReset = msg.content.startsWith("!setattrs-debugreset"); if (debugReset) { From 0db09e4cb2a1712ddfe98e02b55163f486029834 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Tue, 16 Jun 2026 19:59:19 -0500 Subject: [PATCH 34/38] Updated documentation for multiple IDs and Character names requiring a comma separator. --- ChatSetAttr/2.0/ChatSetAttr.js | 8 ++++---- ChatSetAttr/ChatSetAttr.js | 8 ++++---- ChatSetAttr/README.md | 6 +++--- ChatSetAttr/docs/help/content.json | 6 +++--- ChatSetAttr/docs/help/content.revision.json | 4 ++-- ChatSetAttr/script.json | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index bb18eac382..32ee08ce35 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -1480,7 +1480,7 @@ var ChatSetAttr = (function (exports) { blocks: [ { type: "paragraph", - text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control." + text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control. Multiple IDs must be separated by a comma." }, { type: "paragraph", @@ -1489,7 +1489,7 @@ var ChatSetAttr = (function (exports) { { type: "codeBlock", lines: [ - "!setattr --charid --hp|150" + "!setattr --charid , --hp|150" ] } ] @@ -1500,7 +1500,7 @@ var ChatSetAttr = (function (exports) { blocks: [ { type: "paragraph", - text: "Affects characters with the specified names. Non-GM players can only affect characters they control." + text: "Affects characters with the specified names. Non-GM players can only affect characters they control. Multiple character names must be separated by a comma." }, { type: "paragraph", @@ -2486,7 +2486,7 @@ var ChatSetAttr = (function (exports) { return renderHelpHtml(loadHelpDocument(), handoutID); } - var updatedAt = 1781656733311; + var updatedAt = 1781657828941; var contentRevision = { updatedAt: updatedAt }; diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index bb18eac382..32ee08ce35 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1480,7 +1480,7 @@ var ChatSetAttr = (function (exports) { blocks: [ { type: "paragraph", - text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control." + text: "Affects characters with the specified character IDs. Non-GM players can only affect characters they control. Multiple IDs must be separated by a comma." }, { type: "paragraph", @@ -1489,7 +1489,7 @@ var ChatSetAttr = (function (exports) { { type: "codeBlock", lines: [ - "!setattr --charid --hp|150" + "!setattr --charid , --hp|150" ] } ] @@ -1500,7 +1500,7 @@ var ChatSetAttr = (function (exports) { blocks: [ { type: "paragraph", - text: "Affects characters with the specified names. Non-GM players can only affect characters they control." + text: "Affects characters with the specified names. Non-GM players can only affect characters they control. Multiple character names must be separated by a comma." }, { type: "paragraph", @@ -2486,7 +2486,7 @@ var ChatSetAttr = (function (exports) { return renderHelpHtml(loadHelpDocument(), handoutID); } - var updatedAt = 1781656733311; + var updatedAt = 1781657828941; var contentRevision = { updatedAt: updatedAt }; diff --git a/ChatSetAttr/README.md b/ChatSetAttr/README.md index d9f7ec3cd0..72e646c387 100644 --- a/ChatSetAttr/README.md +++ b/ChatSetAttr/README.md @@ -152,17 +152,17 @@ Affects all characters with player controllers (typically PCs). ### --charid -Affects characters with the specified character IDs. Non-GM players can only affect characters they control. +Affects characters with the specified character IDs. Non-GM players can only affect characters they control. Multiple IDs must be separated by a comma. **Example:** ``` -!setattr --charid --hp|150 +!setattr --charid , --hp|150 ``` ### --name -Affects characters with the specified names. Non-GM players can only affect characters they control. +Affects characters with the specified names. Non-GM players can only affect characters they control. Multiple character names must be separated by a comma. **Example:** diff --git a/ChatSetAttr/docs/help/content.json b/ChatSetAttr/docs/help/content.json index 09be30f8c5..7df266ac50 100644 --- a/ChatSetAttr/docs/help/content.json +++ b/ChatSetAttr/docs/help/content.json @@ -271,7 +271,7 @@ "blocks": [ { "type": "paragraph", - "text": "Affects characters with the specified character IDs. Non-GM players can only affect characters they control." + "text": "Affects characters with the specified character IDs. Non-GM players can only affect characters they control. Multiple IDs must be separated by a comma." }, { "type": "paragraph", @@ -280,7 +280,7 @@ { "type": "codeBlock", "lines": [ - "!setattr --charid --hp|150" + "!setattr --charid , --hp|150" ] } ] @@ -291,7 +291,7 @@ "blocks": [ { "type": "paragraph", - "text": "Affects characters with the specified names. Non-GM players can only affect characters they control." + "text": "Affects characters with the specified names. Non-GM players can only affect characters they control. Multiple character names must be separated by a comma." }, { "type": "paragraph", diff --git a/ChatSetAttr/docs/help/content.revision.json b/ChatSetAttr/docs/help/content.revision.json index f18d6010af..6afaae9ae5 100644 --- a/ChatSetAttr/docs/help/content.revision.json +++ b/ChatSetAttr/docs/help/content.revision.json @@ -1,4 +1,4 @@ { - "contentHash": "4399ea631338695ed2401276e0254f96157de7ac70993e8140ee89b773fd1d60", - "updatedAt": 1781656733311 + "contentHash": "a1bc3c61b57651b9747d86913c90944645f5c0087b0fa2739887b9d6d069e9fc", + "updatedAt": 1781657828941 } diff --git a/ChatSetAttr/script.json b/ChatSetAttr/script.json index 77bd1ddcee..52ec92da0c 100644 --- a/ChatSetAttr/script.json +++ b/ChatSetAttr/script.json @@ -2,7 +2,7 @@ "name": "ChatSetAttr", "script": "ChatSetAttr.js", "version": "2.0", - "description": "# ChatSetAttr\n\nChatSetAttr is a Roll20 Mod 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.\n\n## Basic Usage\n\nThe script provides several command formats:\n\n- `!setattr [--options]` - Create or modify attributes\n- `!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values)\n- `!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds)\n- `!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values)\n- `!delattr [--options]` - Delete attributes\n\nEach command requires a target selection option and one or more attributes to modify.\n\n**Basic structure:**\n\n```\n!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2\n```\n\n## Available Commands\n\n### !setattr\n\nCreates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified).\n\n**Example:**\n\n```\n!setattr --sel --hp|25|50 --hp_temp|8\n```\n\nThis would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8.\n\n### !modattr\n\nAdds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`.\n\n**Example:**\n\n```\n!modattr --sel --hp_temp|-5 --hp|6\n```\n\nThis subtracts 5 from `hp_temp` and adds 6 to `hp`.\n\n### !modbattr\n\nAdds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`.\n\n**Example:**\n\n```\n!modbattr --sel --hp_temp|-5 --hp|25\n```\n\nThis subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`.\n\n### !resetattr\n\nResets attributes to their maximum value. Shorthand for `!setattr --reset`.\n\n**Example:**\n\n```\n!resetattr --sel --hp\n```\n\nThis resets `hp` to its maximum value.\n\n### !delattr\n\nDeletes the specified attributes.\n\n**Example:**\n\n```\n!delattr --sel --hp --hp_temp\n```\n\nThis removes the `hp` and `hp_temp` attributes.\n\n## Beacon Computed Values\n\nBeacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.\n\nSome Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.\n\nFor player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired.\n\n**Example:**\n\n```\n!setattr --sel --spellpoints|18\n```\n\nThis will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute.\n\n## Target Selection\n\nOne of these options must be specified to determine which characters will be affected:\n\n### --all\n\nAffects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns.\n\n**Example:**\n\n```\n!resetattr --all --hp\n```\n\n### --allgm\n\nAffects all characters without player controllers (typically NPCs). **GM only**.\n\n**Example:**\n\n```\n!setattr --allgm --reset --hp\n```\n\n### --allplayers\n\nAffects all characters with player controllers (typically PCs).\n\n**Example:**\n\n```\n!setattr --allplayers --mod --hp|-15\n```\n\n### --charid\n\nAffects characters with the specified character IDs. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --charid --hp|150\n```\n\n### --name\n\nAffects characters with the specified names. Non-GM players can only affect characters they control.\n\n**Example:**\n\n```\n!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"\n```\n\n### --sel\n\nAffects characters represented by currently selected tokens.\n\n**Example:**\n\n```\n!setattr --sel --hp|25 --hp_temp|8\n```\n\n### --sel-party\n\nAffects only party characters represented by currently selected tokens (characters with `inParty` set to true).\n\n**Example:**\n\n```\n!setattr --sel-party --inspiration|1\n```\n\n### --sel-noparty\n\nAffects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set).\n\n**Example:**\n\n```\n!setattr --sel-noparty --npc_status|\"Hostile\"\n```\n\n### --party\n\nAffects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration.\n\n**Example:**\n\n```\n!setattr --party --rest_complete|1\n```\n\n## Attribute Syntax\n\nThe syntax for specifying attributes is:\n\n```\n--attributeName|currentValue|maxValue\n```\n\n- `attributeName` is the name of the attribute to modify\n- `currentValue` is the value to set (optional for some commands)\n- `maxValue` is the maximum value to set (optional)\n\n### Examples:\n\n1. Set current value only:\n```\n--strength|15\n```\n2. Set both current and maximum values:\n```\n--hp|27|35\n```\n3. Set only the maximum value (leave current unchanged):\n```\n--hp||50\n```\n4. Create empty attribute or set to empty:\n```\n--notes|\n```\n5. Use `#` instead of `|` (useful in roll queries):\n```\n--strength#15\n```\n\n## Modifier Options\n\nThese options change how attributes are processed:\n\n### --mod\n\nSee `!modattr` command.\n\n### --modb\n\nSee `!modbattr` command.\n\n### --reset\n\nSee `!resetattr` command.\n\n### --nocreate\n\nPrevents creation of new attributes, only updates existing ones.\n\n**Example:**\n\n```\n!setattr --sel --nocreate --perception|20 --hp|15\n```\n\nThis will only update `perception` or `hp` if it already exists.\n\n### --evaluate\n\nEvaluates JavaScript expressions in attribute values. **GM only by default**.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --hp|2 * 3\n```\n\nThis will set the `hp` attribute to 6.\n\n### --replace\n\nReplaces special characters to prevent Roll20 from evaluating them:\n\n- < becomes [\n- > becomes ]\n- ~ becomes -\n- ; becomes ?\n- ` becomes @\n\nAlso supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?.\n\n**Example:**\n\n```\n!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"\n```\n\nThis stores \"Roll [[1d6]] to succeed\" without evaluating the roll.\n\n## Output Control Options\n\nThese options control the feedback messages generated by the script:\n\n### --silent\n\nSuppresses normal output messages (error messages will still appear).\n\n**Example:**\n\n```\n!setattr --sel --silent --stealth|20\n```\n\n### --mute\n\nSuppresses all output messages, including errors.\n\n**Example:**\n\n```\n!setattr --sel --mute --nocreate --new_value|42\n```\n\n### --fb-public\n\nSends output publicly to the chat instead of whispering to the command sender.\n\n**Example:**\n\n```\n!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"\n```\n\n### --fb-from \n\nChanges the name of the sender for output messages (default is \"ChatSetAttr\").\n\n**Example:**\n\n```\n!setattr --sel --fb-from \"Healing Potion\" --hp|25\n```\n\n### --fb-header \n\nCustomizes the header of the output message.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5\n```\n\n### --fb-content \n\nCustomizes the content of the output message.\n\n**Example:**\n\n```\n!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10\n```\n\n### Special Placeholders\n\nFor use in `--fb-header` and `--fb-content`:\n\n- `_NAMEJ_` - Name of the Jth attribute being changed\n- `_TCURJ_` - Target current value of the Jth attribute\n- `_TMAXJ_` - Target maximum value of the Jth attribute\n\nFor use in `--fb-content` only:\n\n- `_CHARNAME_` - Name of the character\n- `_CURJ_` - Final current value of the Jth attribute\n- `_MAXJ_` - Final maximum value of the Jth attribute\n\n**Important:** The Jth index starts with 0 at the first item.\n\n**Example:**\n\n```\n!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10\n```\n\n## Inline Roll Integration\n\nChatSetAttr can be used within roll templates or combined with inline rolls:\n\n### Within Roll Templates\n\nPlace the command between roll template properties and end it with `!!!`:\n\n```\n&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}\n```\n\n### Using Inline Rolls in Values\n\nInline rolls can be used for attribute values:\n\n```\n!setattr --sel --hp|[[2d6+5]]\n```\n\n### Roll Queries\n\nRoll queries can determine attribute values:\n\n```\n!setattr --sel --hp|?{Set strength to what value?|100}\n```\n\n## Repeating Section Support\n\nChatSetAttr supports working with repeating sections:\n\n### Creating New Repeating Items\n\nUse `CREATE` to create a new row in a repeating section:\n\n```\n!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2\n```\n\n### Modifying Existing Repeating Items\n\nAccess by row ID:\n\n```\n!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"\n```\n\nAccess by index (starts at 0):\n\n```\n!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"\n```\n\n### Deleting Repeating Rows\n\nDelete by row ID:\n\n```\n!delattr --sel --repeating_inventory_ID\n```\n\nDelete by index:\n\n```\n!delattr --sel --repeating_inventory_$0\n```\n\n> **Note:** repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.\n\n## Special Value Expressions\n\n### Attribute References\n\nReference other attribute values using `%attribute_name%`:\n\n```\n!setattr --sel --evaluate --temp_hp|%hp% / 2\n```\n\n### Resetting to Maximum\n\nReset an attribute to its maximum value:\n\n```\n!setattr --sel --hp|%hp_max%\n```\n\n## Global Configuration\n\nThe script has four global configuration options that can be toggled with `!setattr-config`:\n\n### --players-can-modify\n\nAllows players to modify attributes on characters they don't control.\n\n```\n!setattr-config --players-can-modify\n```\n\n### --players-can-evaluate\n\nAllows players to use the `--evaluate` option.\n\n```\n!setattr-config --players-can-evaluate\n```\n\n### --players-can-target-party\n\nAllows players to use the `--party` target option. **GM only by default**.\n\n```\n!setattr-config --players-can-target-party\n```\n\n### --use-workers\n\nToggles whether the script triggers sheet workers when setting attributes.\n\n```\n!setattr-config --use-workers\n```\n\n## Complete Examples\n\n### Basic Combat Example\n\nReduce a character's HP and status after taking damage:\n\n```\n!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"\n```\n\n### Leveling Up a Character\n\nUpdate multiple stats when a character gains a level:\n\n```\n!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public\n```\n\n### Create New Item in Inventory\n\nAdd a new item to a character's inventory:\n\n```\n!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\"\n```\n\n### Apply Status Effects During Combat\n\nApply a debuff to selected enemies in the middle of combat:\n\n```\n&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}\n```\n\n### Party Management Examples\n\nGive inspiration to all party members after a great roleplay moment:\n\n```\n!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"\n```\n\nApply a long rest to only party characters among selected tokens:\n\n```\n!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"\n```\n\nSet hostile status for non-party characters among selected tokens:\n\n```\n!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"\n```\n\n## For Developers\n\n### Registering Observers\n\nIf you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:\n\n```\nChatSetAttr.registerObserver(event, observer);\n```\n\nWhere `event` is one of:\n\n- `\"add\"` - Called when attributes are created\n- `\"change\"` - Called when attributes are modified\n- `\"destroy\"` - Called when attributes are deleted\n\nAnd `observer` is an event handler function similar to Roll20's built-in event handlers.\n\nThis allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface.", + "description": "# ChatSetAttr\n\nChatSetAttr is a Roll20 Mod 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.\n\n## Basic Usage\n\nThe script provides several command formats:\n\n- `!setattr [--options]` - Create or modify attributes\n- `!modattr [--options]` - Shortcut for `!setattr --mod` (adds to existing values)\n- `!modbattr [--options]` - Shortcut for `!setattr --modb` (adds to values with bounds)\n- `!resetattr [--options]` - Shortcut for `!setattr --reset` (resets to max values)\n- `!delattr [--options]` - Delete attributes\n\nEach command requires a target selection option and one or more attributes to modify.\n\n**Basic structure:**\n\n```\n!setattr --[target selection] --attribute1|value1 --attribute2|value2|max2\n```\n\n## Available Commands\n\n### !setattr\n\nCreates or updates attributes on the selected target(s). If the attribute doesn't exist, it will be created (unless `--nocreate` is specified).\n\n**Example:**\n\n```\n!setattr --sel --hp|25|50 --hp_temp|8\n```\n\nThis would set `hp` to 25, `hp_max` to 50, `hp_temp` to 8.\n\n### !modattr\n\nAdds to existing attribute values (works only with numeric values). Shorthand for `!setattr --mod`.\n\n**Example:**\n\n```\n!modattr --sel --hp_temp|-5 --hp|6\n```\n\nThis subtracts 5 from `hp_temp` and adds 6 to `hp`.\n\n### !modbattr\n\nAdds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`.\n\n**Example:**\n\n```\n!modbattr --sel --hp_temp|-5 --hp|25\n```\n\nThis subtracts 5 from `hp_temp` but won't reduce it below 0 and increase `hp` by 25, but won't increase it above `mp_xp`.\n\n### !resetattr\n\nResets attributes to their maximum value. Shorthand for `!setattr --reset`.\n\n**Example:**\n\n```\n!resetattr --sel --hp\n```\n\nThis resets `hp` to its maximum value.\n\n### !delattr\n\nDeletes the specified attributes.\n\n**Example:**\n\n```\n!delattr --sel --hp --hp_temp\n```\n\nThis removes the `hp` and `hp_temp` attributes.\n\n## Beacon Computed Values\n\nBeacon character sheets don't have attributes, they have Computed values. All Computeds for a sheet exist when the sheet starts up, you can't create more or remove existing ones. If you try to delete a computed, you will get an error message, but it is otherewise safe to try.\n\nSome Computed values are read-only and cannot be set. Attempting to set or modify them will result in an error message.\n\nFor player created attributes, Beacon sheets have a system called User Attributes. If you attempt to add a new attribute to a Beacon sheet, it will create a User Attribute by that name. User Attributes are prefaced with `user.` like `user.spellpoints`. They function like attributes and can be created, removed, set, reset, and modified as desired.\n\n**Example:**\n\n```\n!setattr --sel --spellpoints|18\n```\n\nThis will create the `user.spellpoints` User Attribute, which can be referenced as either `@{selected|user.spellpoints}` or `@{selected|spellpoints}` and operates like an attribute.\n\n## Target Selection\n\nOne of these options must be specified to determine which characters will be affected:\n\n### --all\n\nAffects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns.\n\n**Example:**\n\n```\n!resetattr --all --hp\n```\n\n### --allgm\n\nAffects all characters without player controllers (typically NPCs). **GM only**.\n\n**Example:**\n\n```\n!setattr --allgm --reset --hp\n```\n\n### --allplayers\n\nAffects all characters with player controllers (typically PCs).\n\n**Example:**\n\n```\n!setattr --allplayers --mod --hp|-15\n```\n\n### --charid\n\nAffects characters with the specified character IDs. Non-GM players can only affect characters they control. Multiple IDs must be separated by a comma.\n\n**Example:**\n\n```\n!setattr --charid , --hp|150\n```\n\n### --name\n\nAffects characters with the specified names. Non-GM players can only affect characters they control. Multiple character names must be separated by a comma.\n\n**Example:**\n\n```\n!setattr --name Gandalf, Frodo Baggins --party|\"Fellowship of the Ring\"\n```\n\n### --sel\n\nAffects characters represented by currently selected tokens.\n\n**Example:**\n\n```\n!setattr --sel --hp|25 --hp_temp|8\n```\n\n### --sel-party\n\nAffects only party characters represented by currently selected tokens (characters with `inParty` set to true).\n\n**Example:**\n\n```\n!setattr --sel-party --inspiration|1\n```\n\n### --sel-noparty\n\nAffects only non-party characters represented by currently selected tokens (characters with `inParty` set to false or not set).\n\n**Example:**\n\n```\n!setattr --sel-noparty --npc_status|\"Hostile\"\n```\n\n### --party\n\nAffects all characters marked as party members (characters with `inParty` set to true). **GM only by default**, but can be enabled for players with configuration.\n\n**Example:**\n\n```\n!setattr --party --rest_complete|1\n```\n\n## Attribute Syntax\n\nThe syntax for specifying attributes is:\n\n```\n--attributeName|currentValue|maxValue\n```\n\n- `attributeName` is the name of the attribute to modify\n- `currentValue` is the value to set (optional for some commands)\n- `maxValue` is the maximum value to set (optional)\n\n### Examples:\n\n1. Set current value only:\n```\n--strength|15\n```\n2. Set both current and maximum values:\n```\n--hp|27|35\n```\n3. Set only the maximum value (leave current unchanged):\n```\n--hp||50\n```\n4. Create empty attribute or set to empty:\n```\n--notes|\n```\n5. Use `#` instead of `|` (useful in roll queries):\n```\n--strength#15\n```\n\n## Modifier Options\n\nThese options change how attributes are processed:\n\n### --mod\n\nSee `!modattr` command.\n\n### --modb\n\nSee `!modbattr` command.\n\n### --reset\n\nSee `!resetattr` command.\n\n### --nocreate\n\nPrevents creation of new attributes, only updates existing ones.\n\n**Example:**\n\n```\n!setattr --sel --nocreate --perception|20 --hp|15\n```\n\nThis will only update `perception` or `hp` if it already exists.\n\n### --evaluate\n\nEvaluates JavaScript expressions in attribute values. **GM only by default**.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --hp|2 * 3\n```\n\nThis will set the `hp` attribute to 6.\n\n### --replace\n\nReplaces special characters to prevent Roll20 from evaluating them:\n\n- < becomes [\n- > becomes ]\n- ~ becomes -\n- ; becomes ?\n- ` becomes @\n\nAlso supports \\lbrak, \\rbrak, \\n, \\at, and \\ques for [, ], newline, @, and ?.\n\n**Example:**\n\n```\n!setattr --sel --replace --notes|\"Roll <<1d6>> to succeed\"\n```\n\nThis stores \"Roll [[1d6]] to succeed\" without evaluating the roll.\n\n## Output Control Options\n\nThese options control the feedback messages generated by the script:\n\n### --silent\n\nSuppresses normal output messages (error messages will still appear).\n\n**Example:**\n\n```\n!setattr --sel --silent --stealth|20\n```\n\n### --mute\n\nSuppresses all output messages, including errors.\n\n**Example:**\n\n```\n!setattr --sel --mute --nocreate --new_value|42\n```\n\n### --fb-public\n\nSends output publicly to the chat instead of whispering to the command sender.\n\n**Example:**\n\n```\n!setattr --sel --fb-public --hp|25|25 --status|\"Healed\"\n```\n\n### --fb-from \n\nChanges the name of the sender for output messages (default is \"ChatSetAttr\").\n\n**Example:**\n\n```\n!setattr --sel --fb-from \"Healing Potion\" --hp|25\n```\n\n### --fb-header \n\nCustomizes the header of the output message.\n\n**Example:**\n\n```\n!setattr --sel --evaluate --fb-header \"Combat Effects Applied\" --status|\"Poisoned\" --hp|%hp%-5\n```\n\n### --fb-content \n\nCustomizes the content of the output message.\n\n**Example:**\n\n```\n!setattr --sel --fb-content \"Increasing Hitpoints\" --hp|10\n```\n\n### Special Placeholders\n\nFor use in `--fb-header` and `--fb-content`:\n\n- `_NAMEJ_` - Name of the Jth attribute being changed\n- `_TCURJ_` - Target current value of the Jth attribute\n- `_TMAXJ_` - Target maximum value of the Jth attribute\n\nFor use in `--fb-content` only:\n\n- `_CHARNAME_` - Name of the character\n- `_CURJ_` - Final current value of the Jth attribute\n- `_MAXJ_` - Final maximum value of the Jth attribute\n\n**Important:** The Jth index starts with 0 at the first item.\n\n**Example:**\n\n```\n!setattr --sel --fb-header \"Healing Effects\" --fb-content \"_CHARNAME_ healed by _CUR0_ hitpoints --hp|10\n```\n\n## Inline Roll Integration\n\nChatSetAttr can be used within roll templates or combined with inline rolls:\n\n### Within Roll Templates\n\nPlace the command between roll template properties and end it with `!!!`:\n\n```\n&{template:default} {{name=Fireball Damage}} !setattr --mod --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}}\n```\n\n### Using Inline Rolls in Values\n\nInline rolls can be used for attribute values:\n\n```\n!setattr --sel --hp|[[2d6+5]]\n```\n\n### Roll Queries\n\nRoll queries can determine attribute values:\n\n```\n!setattr --sel --hp|?{Set strength to what value?|100}\n```\n\n## Repeating Section Support\n\nChatSetAttr supports working with repeating sections:\n\n### Creating New Repeating Items\n\nUse `CREATE` to create a new row in a repeating section:\n\n```\n!setattr --sel --repeating_inventory_CREATE_itemname|\"Magic Sword\" --repeating_inventory_CREATE_itemweight|2\n```\n\n### Modifying Existing Repeating Items\n\nAccess by row ID:\n\n```\n!setattr --sel --repeating_inventory_ID_itemname|\"Enchanted Magic Sword\"\n```\n\nAccess by index (starts at 0):\n\n```\n!setattr --sel --repeating_inventory_$0_itemname|\"First Item\"\n```\n\n### Deleting Repeating Rows\n\nDelete by row ID:\n\n```\n!delattr --sel --repeating_inventory_ID\n```\n\nDelete by index:\n\n```\n!delattr --sel --repeating_inventory_$0\n```\n\n> **Note:** repeating sections for Beacon sheets are currently not supported. They are read-only which prevents ChatSetAttr from being able to modify them.\n\n## Special Value Expressions\n\n### Attribute References\n\nReference other attribute values using `%attribute_name%`:\n\n```\n!setattr --sel --evaluate --temp_hp|%hp% / 2\n```\n\n### Resetting to Maximum\n\nReset an attribute to its maximum value:\n\n```\n!setattr --sel --hp|%hp_max%\n```\n\n## Global Configuration\n\nThe script has four global configuration options that can be toggled with `!setattr-config`:\n\n### --players-can-modify\n\nAllows players to modify attributes on characters they don't control.\n\n```\n!setattr-config --players-can-modify\n```\n\n### --players-can-evaluate\n\nAllows players to use the `--evaluate` option.\n\n```\n!setattr-config --players-can-evaluate\n```\n\n### --players-can-target-party\n\nAllows players to use the `--party` target option. **GM only by default**.\n\n```\n!setattr-config --players-can-target-party\n```\n\n### --use-workers\n\nToggles whether the script triggers sheet workers when setting attributes.\n\n```\n!setattr-config --use-workers\n```\n\n## Complete Examples\n\n### Basic Combat Example\n\nReduce a character's HP and status after taking damage:\n\n```\n!modattr --sel --evaluate --hp|-15 --fb-header \"Combat Result\" --fb-content \"_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!\"\n```\n\n### Leveling Up a Character\n\nUpdate multiple stats when a character gains a level:\n\n```\n!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from \"Level Up\" --fb-header \"Character Advanced\" --fb-public\n```\n\n### Create New Item in Inventory\n\nAdd a new item to a character's inventory:\n\n```\n!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\"\n```\n\n### Apply Status Effects During Combat\n\nApply a debuff to selected enemies in the middle of combat:\n\n```\n&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|\"Restrained\"!!! {{duration=1d4 rounds}}\n```\n\n### Party Management Examples\n\nGive inspiration to all party members after a great roleplay moment:\n\n```\n!setattr --party --inspiration|1 --fb-public --fb-header \"Inspiration Awarded\" --fb-content \"All party members receive inspiration for excellent roleplay!\"\n```\n\nApply a long rest to only party characters among selected tokens:\n\n```\n!setattr --sel-party --hp|%hp_max% --spell_slots_reset|1 --fb-header \"Long Rest Complete\"\n```\n\nSet hostile status for non-party characters among selected tokens:\n\n```\n!setattr --sel-noparty --attitude|\"Hostile\" --fb-from \"DM\" --fb-content \"Enemies are now hostile!\"\n```\n\n## For Developers\n\n### Registering Observers\n\nIf you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr:\n\n```\nChatSetAttr.registerObserver(event, observer);\n```\n\nWhere `event` is one of:\n\n- `\"add\"` - Called when attributes are created\n- `\"change\"` - Called when attributes are modified\n- `\"destroy\"` - Called when attributes are deleted\n\nAnd `observer` is an event handler function similar to Roll20's built-in event handlers.\n\nThis allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface.", "authors": [ "Jakob", "GUD Team" From ae53117041ca7100d087c091a0527ab5e602c9a7 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 17 Jun 2026 11:34:50 -0500 Subject: [PATCH 35/38] Fix long running query notice going to public --- ChatSetAttr/2.0/ChatSetAttr.js | 6 +++--- ChatSetAttr/ChatSetAttr.js | 6 +++--- .../__tests__/integration/legacyAttributes.test.ts | 12 +++++------- ChatSetAttr/src/__tests__/unit/chat.test.ts | 14 +++++++++++++- ChatSetAttr/src/modules/chat.ts | 12 ++++++++++-- ChatSetAttr/src/modules/main.ts | 2 +- 6 files changed, 35 insertions(+), 17 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 32ee08ce35..c4fade481a 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -186,12 +186,12 @@ var ChatSetAttr = (function (exports) { const newMessage = createErrorMessage(header, errors); sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); } - function sendDelayMessage(output) { + function sendDelayMessage(playerID, output) { if (output?.silent) { return; } const delayMessage = createDelayMessage(); - sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${delayMessage}`, undefined, { noarchive: true }); } function sendNotification(title, content, archive) { const notifyMessage = createNotifyMessage(title, content); @@ -3412,7 +3412,7 @@ var ChatSetAttr = (function (exports) { const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); const output = normalizeCommandOutputOptions(options); // Start Timer - startTimer("chatsetattr", 8000, () => sendDelayMessage(output)); + startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); // Check Config and Permissions const config = getConfig(); const isAPI = "API" === msg.playerid; diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 32ee08ce35..c4fade481a 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -186,12 +186,12 @@ var ChatSetAttr = (function (exports) { const newMessage = createErrorMessage(header, errors); sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); } - function sendDelayMessage(output) { + function sendDelayMessage(playerID, output) { if (output?.silent) { return; } const delayMessage = createDelayMessage(); - sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${delayMessage}`, undefined, { noarchive: true }); } function sendNotification(title, content, archive) { const notifyMessage = createNotifyMessage(title, content); @@ -3412,7 +3412,7 @@ var ChatSetAttr = (function (exports) { const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); const output = normalizeCommandOutputOptions(options); // Start Timer - startTimer("chatsetattr", 8000, () => sendDelayMessage(output)); + startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); // Check Config and Permissions const config = getConfig(); const isAPI = "API" === msg.playerid; diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index 1562661120..c22bc1d2b1 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -1808,14 +1808,12 @@ describe("ChatSetAttr Integration Tests", () => { 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, - }) + const delayCall = vi.mocked(sendChat).mock.calls.find(call => + typeof call[1] === "string" && + /long time to execute/i.test(call[1]), ); + expect(delayCall).toBeDefined(); + expect(delayCall![1]).toMatch(/^\/w "Test Player" /); }); }); }); diff --git a/ChatSetAttr/src/__tests__/unit/chat.test.ts b/ChatSetAttr/src/__tests__/unit/chat.test.ts index 8a94ed217d..b02b93e478 100644 --- a/ChatSetAttr/src/__tests__/unit/chat.test.ts +++ b/ChatSetAttr/src/__tests__/unit/chat.test.ts @@ -370,8 +370,20 @@ describe("chat", () => { expect(mockSendChat).not.toHaveBeenCalled(); }); + it("should whisper delay notice to the command runner", () => { + sendDelayMessage("player123"); + + expect(mockCreateDelayMessage).toHaveBeenCalled(); + expect(mockSendChat).toHaveBeenCalledWith( + "ChatSetAttr", + '/w "Test Player" delay-message', + undefined, + { noarchive: true }, + ); + }); + it("should suppress delay notice when mute is set", () => { - sendDelayMessage({ mute: true, silent: true }); + sendDelayMessage("player123", { mute: true, silent: true }); expect(mockCreateDelayMessage).not.toHaveBeenCalled(); expect(mockSendChat).not.toHaveBeenCalled(); diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index 43b3a02d12..8d421a1e24 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -72,13 +72,21 @@ export function sendErrors( sendChat(sender, `${getWhisperPrefix(playerID)}${newMessage}`); }; -export function sendDelayMessage(output?: NormalizedCommandOutputOptions): void { +export function sendDelayMessage( + playerID: string, + output?: NormalizedCommandOutputOptions, +): void { if (output?.silent) { return; } const delayMessage = createDelayMessage(); - sendChat("ChatSetAttr", delayMessage, undefined, { noarchive: true }); + sendChat( + "ChatSetAttr", + `${getWhisperPrefix(playerID)}${delayMessage}`, + undefined, + { noarchive: true }, + ); }; export function sendNotification(title: string, content: string, archive?: boolean): void { diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 814eb580a1..2d7b8e1362 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -57,7 +57,7 @@ async function acceptMessage(msg: Roll20ChatMessage) { const output = normalizeCommandOutputOptions(options); // Start Timer - startTimer("chatsetattr", 8000, () => sendDelayMessage(output)); + startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); // Check Config and Permissions const config = getConfig(); From 25f123a6334825e5bcb11b54c22cb7cf3452aa96 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Wed, 17 Jun 2026 12:02:30 -0500 Subject: [PATCH 36/38] Styling for long running query message, message for Beacon not supported on default sandbox. --- ChatSetAttr/2.0/ChatSetAttr.js | 49 ++++++++++++++---- ChatSetAttr/ChatSetAttr.js | 49 ++++++++++++++---- .../integration/legacyAttributes.test.ts | 1 + .../src/__tests__/integration/startup.test.ts | 50 +++++++++++++++++++ ChatSetAttr/src/__tests__/unit/chat.test.ts | 39 ++++++++++++--- ChatSetAttr/src/modules/chat.ts | 27 ++++++++-- ChatSetAttr/src/modules/main.ts | 7 ++- ChatSetAttr/src/templates/delay.tsx | 17 ------- ChatSetAttr/src/templates/styles.ts | 7 +++ ChatSetAttr/vitest.setup.ts | 1 + 10 files changed, 198 insertions(+), 49 deletions(-) delete mode 100644 ChatSetAttr/src/templates/delay.tsx diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index c4fade481a..5fddaec66c 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -74,6 +74,12 @@ var ChatSetAttr = (function (exports) { padding: "8px", backgroundColor: "rgba(59, 130, 246, 0.1)", }; + const frameStyleNotice = { + border: "1px solid rgba(245, 158, 11, 0.55)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(245, 158, 11, 0.18)", + }; const frameStyleError = { border: "1px solid rgba(239, 68, 68, 0.4)", backgroundColor: "rgba(239, 68, 68, 0.1)", @@ -83,14 +89,6 @@ var ChatSetAttr = (function (exports) { marginBottom: "0.5em", }; - const DELAY_WRAPPER_STYLE = s(frameStyleBase); - const DELAY_HEADER_STYLE = s(headerStyleBase); - function createDelayMessage() { - return (h("div", { style: DELAY_WRAPPER_STYLE }, - h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), - h("div", null, "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."))).html; - } - const CHAT_WRAPPER_STYLE = s(frameStyleBase); const CHAT_HEADER_STYLE = s(headerStyleBase); const CHAT_BODY_STYLE = s({ @@ -129,6 +127,14 @@ var ChatSetAttr = (function (exports) { }); } + const NOTICE_WRAPPER_STYLE = s(frameStyleNotice); + const NOTICE_HEADER_STYLE = s(headerStyleBase); + function createNoticeMessage(title, content) { + return (h("div", { style: NOTICE_WRAPPER_STYLE }, + h("div", { style: NOTICE_HEADER_STYLE }, title), + h("div", null, content))).html; + } + const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); const NOTIFY_HEADER_STYLE = s(headerStyleBase); function createNotifyMessage(title, content) { @@ -153,6 +159,13 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattr-help", style: buttonStyle }, "Create Journal Handout")))).html; } + const BEACON_UNSUPPORTED_NOTICE_TITLE = "Notice: Beacon Support Disabled"; + const BEACON_UNSUPPORTED_NOTICE_BODY = "Beacon character sheets are not supported on this Mod API Sandbox. " + + "Please be sure you have the correct Sandbox selected on the Mod API Scripts Page " + + "and restart the Mod API Server."; + const LONG_RUNNING_QUERY_TITLE = "Long Running Query"; + const LONG_RUNNING_QUERY_BODY = "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."; function getWhisperPrefix(playerID) { const player = getPlayerName(playerID); return `/w "${player || "GM"}" `; @@ -190,8 +203,12 @@ var ChatSetAttr = (function (exports) { if (output?.silent) { return; } - const delayMessage = createDelayMessage(); - sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${delayMessage}`, undefined, { noarchive: true }); + const noticeMessage = createNoticeMessage(LONG_RUNNING_QUERY_TITLE, LONG_RUNNING_QUERY_BODY); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${noticeMessage}`, undefined, { noarchive: true }); + } + function sendBeaconUnsupportedNotice() { + const message = createNoticeMessage(BEACON_UNSUPPORTED_NOTICE_TITLE, BEACON_UNSUPPORTED_NOTICE_BODY); + sendChat("ChatSetAttr", "/w gm " + message, undefined, { noarchive: true }); } function sendNotification(title, content, archive) { const notifyMessage = createNotifyMessage(title, content); @@ -860,6 +877,15 @@ var ChatSetAttr = (function (exports) { return attributes; } + function isBeaconSupported() { + try { + return !!Campaign().computedSummary; + } + catch { + return false; + } + } + function cleanValue(value) { return value.trim().replace(/^['"](.*)['"]$/g, "$1"); } @@ -3529,6 +3555,9 @@ var ChatSetAttr = (function (exports) { if (!checkDependencies()) { return; } + if (!isBeaconSupported()) { + sendBeaconUnsupportedNotice(); + } on("chat:message", (msg) => { if (msg.type !== "api") { const inlineMessage = extractMessageFromRollTemplate(msg); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index c4fade481a..5fddaec66c 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -74,6 +74,12 @@ var ChatSetAttr = (function (exports) { padding: "8px", backgroundColor: "rgba(59, 130, 246, 0.1)", }; + const frameStyleNotice = { + border: "1px solid rgba(245, 158, 11, 0.55)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(245, 158, 11, 0.18)", + }; const frameStyleError = { border: "1px solid rgba(239, 68, 68, 0.4)", backgroundColor: "rgba(239, 68, 68, 0.1)", @@ -83,14 +89,6 @@ var ChatSetAttr = (function (exports) { marginBottom: "0.5em", }; - const DELAY_WRAPPER_STYLE = s(frameStyleBase); - const DELAY_HEADER_STYLE = s(headerStyleBase); - function createDelayMessage() { - return (h("div", { style: DELAY_WRAPPER_STYLE }, - h("div", { style: DELAY_HEADER_STYLE }, "Long Running Query"), - h("div", null, "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."))).html; - } - const CHAT_WRAPPER_STYLE = s(frameStyleBase); const CHAT_HEADER_STYLE = s(headerStyleBase); const CHAT_BODY_STYLE = s({ @@ -129,6 +127,14 @@ var ChatSetAttr = (function (exports) { }); } + const NOTICE_WRAPPER_STYLE = s(frameStyleNotice); + const NOTICE_HEADER_STYLE = s(headerStyleBase); + function createNoticeMessage(title, content) { + return (h("div", { style: NOTICE_WRAPPER_STYLE }, + h("div", { style: NOTICE_HEADER_STYLE }, title), + h("div", null, content))).html; + } + const NOTIFY_WRAPPER_STYLE = s(frameStyleBase); const NOTIFY_HEADER_STYLE = s(headerStyleBase); function createNotifyMessage(title, content) { @@ -153,6 +159,13 @@ var ChatSetAttr = (function (exports) { h("a", { href: "!setattr-help", style: buttonStyle }, "Create Journal Handout")))).html; } + const BEACON_UNSUPPORTED_NOTICE_TITLE = "Notice: Beacon Support Disabled"; + const BEACON_UNSUPPORTED_NOTICE_BODY = "Beacon character sheets are not supported on this Mod API Sandbox. " + + "Please be sure you have the correct Sandbox selected on the Mod API Scripts Page " + + "and restart the Mod API Server."; + const LONG_RUNNING_QUERY_TITLE = "Long Running Query"; + const LONG_RUNNING_QUERY_BODY = "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."; function getWhisperPrefix(playerID) { const player = getPlayerName(playerID); return `/w "${player || "GM"}" `; @@ -190,8 +203,12 @@ var ChatSetAttr = (function (exports) { if (output?.silent) { return; } - const delayMessage = createDelayMessage(); - sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${delayMessage}`, undefined, { noarchive: true }); + const noticeMessage = createNoticeMessage(LONG_RUNNING_QUERY_TITLE, LONG_RUNNING_QUERY_BODY); + sendChat("ChatSetAttr", `${getWhisperPrefix(playerID)}${noticeMessage}`, undefined, { noarchive: true }); + } + function sendBeaconUnsupportedNotice() { + const message = createNoticeMessage(BEACON_UNSUPPORTED_NOTICE_TITLE, BEACON_UNSUPPORTED_NOTICE_BODY); + sendChat("ChatSetAttr", "/w gm " + message, undefined, { noarchive: true }); } function sendNotification(title, content, archive) { const notifyMessage = createNotifyMessage(title, content); @@ -860,6 +877,15 @@ var ChatSetAttr = (function (exports) { return attributes; } + function isBeaconSupported() { + try { + return !!Campaign().computedSummary; + } + catch { + return false; + } + } + function cleanValue(value) { return value.trim().replace(/^['"](.*)['"]$/g, "$1"); } @@ -3529,6 +3555,9 @@ var ChatSetAttr = (function (exports) { if (!checkDependencies()) { return; } + if (!isBeaconSupported()) { + sendBeaconUnsupportedNotice(); + } on("chat:message", (msg) => { if (msg.type !== "api") { const inlineMessage = extractMessageFromRollTemplate(msg); diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index c22bc1d2b1..deae510e64 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -1814,6 +1814,7 @@ describe("ChatSetAttr Integration Tests", () => { ); expect(delayCall).toBeDefined(); expect(delayCall![1]).toMatch(/^\/w "Test Player" /); + expect(delayCall![1]).toContain("rgba(245, 158, 11"); }); }); }); diff --git a/ChatSetAttr/src/__tests__/integration/startup.test.ts b/ChatSetAttr/src/__tests__/integration/startup.test.ts index 6118e5af46..b2a009db11 100644 --- a/ChatSetAttr/src/__tests__/integration/startup.test.ts +++ b/ChatSetAttr/src/__tests__/integration/startup.test.ts @@ -168,3 +168,53 @@ describe("startup state persistence", () => { expect(persisted.scriptVersion).toBe("2.0"); }); }); + +describe("Beacon unsupported notice", () => { + const originalLibSmartAttributes = global.libSmartAttributes; + + beforeEach(() => { + vi.clearAllMocks(); + global.libSmartAttributes = originalLibSmartAttributes; + global.libUUID = { + generateRowID: vi.fn(() => "unique-rowid-1234"), + generateUUID: vi.fn(() => "unique-libUUID-5678"), + }; + }); + + it("should whisper Beacon unsupported notice when computedSummary is missing", () => { + global.Campaign = vi.fn(() => ({})) as typeof Campaign; + + registerHandlers(); + + const beaconCall = vi.mocked(sendChat).mock.calls.find(call => + typeof call[1] === "string" && call[1].includes("Beacon Support Disabled"), + ); + expect(beaconCall).toBeDefined(); + expect(beaconCall![1]).toMatch(/^\/w gm /); + expect(beaconCall![1]).toContain("rgba(245, 158, 11"); + expect(beaconCall![1]).toContain("Mod API Sandbox"); + }); + + it("should not send Beacon notice when computedSummary is present", () => { + global.Campaign = vi.fn(() => ({ computedSummary: {} })) as typeof Campaign; + + registerHandlers(); + + const beaconCall = vi.mocked(sendChat).mock.calls.find(call => + typeof call[1] === "string" && call[1].includes("Beacon Support Disabled"), + ); + expect(beaconCall).toBeUndefined(); + }); + + it("should not send Beacon notice when dependencies are missing", () => { + global.Campaign = vi.fn(() => ({})) as typeof Campaign; + global.libSmartAttributes = undefined as unknown as typeof libSmartAttributes; + + registerHandlers(); + + const beaconCall = vi.mocked(sendChat).mock.calls.find(call => + typeof call[1] === "string" && call[1].includes("Beacon Support Disabled"), + ); + expect(beaconCall).toBeUndefined(); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/chat.test.ts b/ChatSetAttr/src/__tests__/unit/chat.test.ts index b02b93e478..6f1bb69f50 100644 --- a/ChatSetAttr/src/__tests__/unit/chat.test.ts +++ b/ChatSetAttr/src/__tests__/unit/chat.test.ts @@ -4,7 +4,12 @@ import { sendMessages, sendErrors, sendDelayMessage, + sendBeaconUnsupportedNotice, normalizeCommandOutputOptions, + BEACON_UNSUPPORTED_NOTICE_TITLE, + BEACON_UNSUPPORTED_NOTICE_BODY, + LONG_RUNNING_QUERY_TITLE, + LONG_RUNNING_QUERY_BODY, } from "../../modules/chat"; // Mock the templates @@ -13,8 +18,8 @@ vi.mock("../../templates/messages", () => ({ createErrorMessage: vi.fn(), })); -vi.mock("../../templates/delay", () => ({ - createDelayMessage: vi.fn(), +vi.mock("../../templates/notice", () => ({ + createNoticeMessage: vi.fn(), })); // Mock Roll20 globals @@ -29,10 +34,10 @@ global.getObj = mockGetObj; global.sendChat = mockSendChat; import { createChatMessage, createErrorMessage } from "../../templates/messages"; -import { createDelayMessage } from "../../templates/delay"; +import { createNoticeMessage } from "../../templates/notice"; const mockCreateChatMessage = vi.mocked(createChatMessage); const mockCreateErrorMessage = vi.mocked(createErrorMessage); -const mockCreateDelayMessage = vi.mocked(createDelayMessage); +const mockCreateNoticeMessage = vi.mocked(createNoticeMessage); describe("chat", () => { beforeEach(() => { @@ -339,7 +344,7 @@ describe("chat", () => { mockGetObj.mockReturnValue(mockPlayer); mockCreateChatMessage.mockReturnValue("formatted-chat-message"); mockCreateErrorMessage.mockReturnValue("formatted-error-message"); - mockCreateDelayMessage.mockReturnValue("delay-message"); + mockCreateNoticeMessage.mockReturnValue("notice-message"); }); it("should suppress errors when mute is set", () => { @@ -373,10 +378,28 @@ describe("chat", () => { it("should whisper delay notice to the command runner", () => { sendDelayMessage("player123"); - expect(mockCreateDelayMessage).toHaveBeenCalled(); + expect(mockCreateNoticeMessage).toHaveBeenCalledWith( + LONG_RUNNING_QUERY_TITLE, + LONG_RUNNING_QUERY_BODY, + ); + expect(mockSendChat).toHaveBeenCalledWith( + "ChatSetAttr", + '/w "Test Player" notice-message', + undefined, + { noarchive: true }, + ); + }); + + it("should whisper Beacon unsupported notice to the GM", () => { + sendBeaconUnsupportedNotice(); + + expect(mockCreateNoticeMessage).toHaveBeenCalledWith( + BEACON_UNSUPPORTED_NOTICE_TITLE, + BEACON_UNSUPPORTED_NOTICE_BODY, + ); expect(mockSendChat).toHaveBeenCalledWith( "ChatSetAttr", - '/w "Test Player" delay-message', + "/w gm notice-message", undefined, { noarchive: true }, ); @@ -385,7 +408,7 @@ describe("chat", () => { it("should suppress delay notice when mute is set", () => { sendDelayMessage("player123", { mute: true, silent: true }); - expect(mockCreateDelayMessage).not.toHaveBeenCalled(); + expect(mockCreateNoticeMessage).not.toHaveBeenCalled(); expect(mockSendChat).not.toHaveBeenCalled(); }); }); diff --git a/ChatSetAttr/src/modules/chat.ts b/ChatSetAttr/src/modules/chat.ts index 8d421a1e24..6e56abfa51 100644 --- a/ChatSetAttr/src/modules/chat.ts +++ b/ChatSetAttr/src/modules/chat.ts @@ -1,8 +1,21 @@ -import { createDelayMessage } from "../templates/delay"; import { createChatMessage, createErrorMessage } from "../templates/messages"; +import { createNoticeMessage } from "../templates/notice"; import { createNotifyMessage } from "../templates/notification"; import { createWelcomeMessage } from "../templates/welcome"; +export const BEACON_UNSUPPORTED_NOTICE_TITLE = "Notice: Beacon Support Disabled"; + +export const BEACON_UNSUPPORTED_NOTICE_BODY = + "Beacon character sheets are not supported on this Mod API Sandbox. " + + "Please be sure you have the correct Sandbox selected on the Mod API Scripts Page " + + "and restart the Mod API Server."; + +export const LONG_RUNNING_QUERY_TITLE = "Long Running Query"; + +export const LONG_RUNNING_QUERY_BODY = + "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."; + export type CommandOutputOptions = { silent?: boolean; mute?: boolean; @@ -80,15 +93,23 @@ export function sendDelayMessage( return; } - const delayMessage = createDelayMessage(); + const noticeMessage = createNoticeMessage(LONG_RUNNING_QUERY_TITLE, LONG_RUNNING_QUERY_BODY); sendChat( "ChatSetAttr", - `${getWhisperPrefix(playerID)}${delayMessage}`, + `${getWhisperPrefix(playerID)}${noticeMessage}`, undefined, { noarchive: true }, ); }; +export function sendBeaconUnsupportedNotice(): void { + const message = createNoticeMessage( + BEACON_UNSUPPORTED_NOTICE_TITLE, + BEACON_UNSUPPORTED_NOTICE_BODY, + ); + sendChat("ChatSetAttr", "/w gm " + message, 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 }); diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index 2d7b8e1362..ca27483245 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -1,7 +1,8 @@ import scriptJson from "../../script.json" assert { type: "json" }; import type { Attribute, AttributeRecord } from "../types"; import { getAttributes } from "./attributes"; -import { sendDelayMessage, sendErrors, sendMessages, normalizeCommandOutputOptions } from "./chat"; +import { sendDelayMessage, sendBeaconUnsupportedNotice, sendErrors, sendMessages, normalizeCommandOutputOptions } from "./chat"; +import { isBeaconSupported } from "./beaconSupport"; import { handlers } from "./commands"; import { checkConfigMessage, getConfig, handleConfigCommand, hasFlag } from "./config"; import { @@ -223,6 +224,10 @@ export function registerHandlers() { return; } + if (!isBeaconSupported()) { + sendBeaconUnsupportedNotice(); + } + on("chat:message", (msg) => { if (msg.type !== "api") { const inlineMessage = extractMessageFromRollTemplate(msg); diff --git a/ChatSetAttr/src/templates/delay.tsx b/ChatSetAttr/src/templates/delay.tsx deleted file mode 100644 index b9ca0a78de..0000000000 --- a/ChatSetAttr/src/templates/delay.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { s } from "../utils/chat"; -import { frameStyleBase, headerStyleBase } from "./styles"; - -const DELAY_WRAPPER_STYLE = s(frameStyleBase); - -const DELAY_HEADER_STYLE = s(headerStyleBase); - -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. -
-
- ).html; -}; \ No newline at end of file diff --git a/ChatSetAttr/src/templates/styles.ts b/ChatSetAttr/src/templates/styles.ts index e37f6b7b9a..978a500d95 100644 --- a/ChatSetAttr/src/templates/styles.ts +++ b/ChatSetAttr/src/templates/styles.ts @@ -25,6 +25,13 @@ export const frameStyleWarning = { backgroundColor: "rgba(245, 158, 11, 0.1)", }; +export const frameStyleNotice = { + border: "1px solid rgba(245, 158, 11, 0.55)", + borderRadius: "8px", + padding: "8px", + backgroundColor: "rgba(245, 158, 11, 0.18)", +}; + export const frameStyleError = { border: "1px solid rgba(239, 68, 68, 0.4)", backgroundColor: "rgba(239, 68, 68, 0.1)", diff --git a/ChatSetAttr/vitest.setup.ts b/ChatSetAttr/vitest.setup.ts index f75a71f7eb..8fab72cf76 100644 --- a/ChatSetAttr/vitest.setup.ts +++ b/ChatSetAttr/vitest.setup.ts @@ -58,6 +58,7 @@ global.setSheetItem = setSheetItem; // region Utility Functions global.playerIsGM = vi.fn(); global.sendChat = vi.fn(); +global.Campaign = vi.fn(() => ({ computedSummary: {} })) as typeof Campaign; // region Requirements global.libSmartAttributes = SA; From e729b85530b0f7f44490fdb59801ff0aa543866e Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 19 Jun 2026 14:57:02 -0500 Subject: [PATCH 37/38] Cleanup for bad argument handling. --- ChatSetAttr/2.0/ChatSetAttr.js | 27 ++++++++--- ChatSetAttr/ChatSetAttr.js | 27 ++++++++--- ChatSetAttr/src/__mocks__/apiObjects.mock.ts | 4 ++ .../integration/legacyAttributes.test.ts | 17 +++++++ .../src/__tests__/integration/startup.test.ts | 8 ++-- ChatSetAttr/src/__tests__/unit/help.test.ts | 2 +- .../src/__tests__/unit/message.test.ts | 45 +++++++++++++++++++ ChatSetAttr/src/env.d.ts | 5 +++ ChatSetAttr/src/modules/help.ts | 4 +- ChatSetAttr/src/modules/main.ts | 12 ++++- ChatSetAttr/src/modules/message.ts | 18 ++++++-- ChatSetAttr/tsconfig.json | 2 +- ChatSetAttr/tsconfig.script.json | 4 +- ChatSetAttr/vitest.setup.ts | 4 +- 14 files changed, 155 insertions(+), 24 deletions(-) diff --git a/ChatSetAttr/2.0/ChatSetAttr.js b/ChatSetAttr/2.0/ChatSetAttr.js index 5fddaec66c..edb3a5a0e7 100644 --- a/ChatSetAttr/2.0/ChatSetAttr.js +++ b/ChatSetAttr/2.0/ChatSetAttr.js @@ -879,7 +879,8 @@ var ChatSetAttr = (function (exports) { function isBeaconSupported() { try { - return !!Campaign().computedSummary; + const campaign = Campaign(); + return !!campaign.computedSummary; } catch { return false; @@ -2703,12 +2704,24 @@ var ChatSetAttr = (function (exports) { log("Empty Command."); return; } - const command = parts.shift().slice(1); // remove the leading '!' - const isValidCommand = isCommand(command); - if (!isValidCommand) { + const commandPart = parts.shift(); + const tokens = commandPart.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + log("Empty Command."); + return; + } + if (!tokens[0].startsWith("!")) { + log("Invalid Command."); + return; + } + const command = tokens[0].slice(1); + if (!isCommand(command)) { log("Invalid Command."); return; } + if (tokens.length > 1) { + parts.unshift(tokens.slice(1).join(" ")); + } return command; } function extractReferences(value) { @@ -3435,7 +3448,11 @@ var ChatSetAttr = (function (exports) { const messages = []; const result = {}; // Parse Message - const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); + const parsed = parseMessage(msg.content); + if (!parsed) { + return errorOut("Could not parse command. Check that command options use -- (double dash).", msg.playerid, errors, normalizeCommandOutputOptions()); + } + const { operation, targeting, options, changes, references, feedback, } = parsed; const output = normalizeCommandOutputOptions(options); // Start Timer startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index 5fddaec66c..edb3a5a0e7 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -879,7 +879,8 @@ var ChatSetAttr = (function (exports) { function isBeaconSupported() { try { - return !!Campaign().computedSummary; + const campaign = Campaign(); + return !!campaign.computedSummary; } catch { return false; @@ -2703,12 +2704,24 @@ var ChatSetAttr = (function (exports) { log("Empty Command."); return; } - const command = parts.shift().slice(1); // remove the leading '!' - const isValidCommand = isCommand(command); - if (!isValidCommand) { + const commandPart = parts.shift(); + const tokens = commandPart.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + log("Empty Command."); + return; + } + if (!tokens[0].startsWith("!")) { + log("Invalid Command."); + return; + } + const command = tokens[0].slice(1); + if (!isCommand(command)) { log("Invalid Command."); return; } + if (tokens.length > 1) { + parts.unshift(tokens.slice(1).join(" ")); + } return command; } function extractReferences(value) { @@ -3435,7 +3448,11 @@ var ChatSetAttr = (function (exports) { const messages = []; const result = {}; // Parse Message - const { operation, targeting, options, changes, references, feedback, } = parseMessage(msg.content); + const parsed = parseMessage(msg.content); + if (!parsed) { + return errorOut("Could not parse command. Check that command options use -- (double dash).", msg.playerid, errors, normalizeCommandOutputOptions()); + } + const { operation, targeting, options, changes, references, feedback, } = parsed; const output = normalizeCommandOutputOptions(options); // Start Timer startTimer("chatsetattr", 8000, () => sendDelayMessage(msg.playerid, output)); diff --git a/ChatSetAttr/src/__mocks__/apiObjects.mock.ts b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts index b4f8dd2ec0..97d984982b 100644 --- a/ChatSetAttr/src/__mocks__/apiObjects.mock.ts +++ b/ChatSetAttr/src/__mocks__/apiObjects.mock.ts @@ -9,6 +9,10 @@ export function resetAllObjects(): void { resetBeaconAttributes(); } +export function mockCampaign(value: { computedSummary?: unknown }): typeof Campaign { + return vi.fn(() => value) as unknown as typeof Campaign; +} + function createRandomId(): string { return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10); }; diff --git a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts index deae510e64..ab2ca587bf 100644 --- a/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts +++ b/ChatSetAttr/src/__tests__/integration/legacyAttributes.test.ts @@ -1818,4 +1818,21 @@ describe("ChatSetAttr Integration Tests", () => { }); }); }); + + describe("Malformed commands", () => { + it("should not crash on single-dash target shorthand", 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(); + + expect(() => executeCommand("!setattr -all --hp|1")).not.toThrow(); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + typeof call[1] === "string" && /No valid targets found/i.test(call[1]), + ); + expect(errorCall).toBeDefined(); + }); + }); + }); }); diff --git a/ChatSetAttr/src/__tests__/integration/startup.test.ts b/ChatSetAttr/src/__tests__/integration/startup.test.ts index b2a009db11..db4dac54f1 100644 --- a/ChatSetAttr/src/__tests__/integration/startup.test.ts +++ b/ChatSetAttr/src/__tests__/integration/startup.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockCampaign } from "../../__mocks__/apiObjects.mock"; + import { checkGlobalConfig, getConfig, persistStateVersionMetadata, syncScriptVersion } from "../../modules/config"; import { syncHelpHandoutOnStartup } from "../../modules/help"; import { registerHandlers } from "../../modules/main"; @@ -182,7 +184,7 @@ describe("Beacon unsupported notice", () => { }); it("should whisper Beacon unsupported notice when computedSummary is missing", () => { - global.Campaign = vi.fn(() => ({})) as typeof Campaign; + global.Campaign = mockCampaign({}); registerHandlers(); @@ -196,7 +198,7 @@ describe("Beacon unsupported notice", () => { }); it("should not send Beacon notice when computedSummary is present", () => { - global.Campaign = vi.fn(() => ({ computedSummary: {} })) as typeof Campaign; + global.Campaign = mockCampaign({ computedSummary: {} }); registerHandlers(); @@ -207,7 +209,7 @@ describe("Beacon unsupported notice", () => { }); it("should not send Beacon notice when dependencies are missing", () => { - global.Campaign = vi.fn(() => ({})) as typeof Campaign; + global.Campaign = mockCampaign({}); global.libSmartAttributes = undefined as unknown as typeof libSmartAttributes; registerHandlers(); diff --git a/ChatSetAttr/src/__tests__/unit/help.test.ts b/ChatSetAttr/src/__tests__/unit/help.test.ts index 31febb3827..7c97119aa2 100644 --- a/ChatSetAttr/src/__tests__/unit/help.test.ts +++ b/ChatSetAttr/src/__tests__/unit/help.test.ts @@ -106,7 +106,7 @@ describe("help", () => { it("should write rendered content and persist bundled revision in state", () => { const handout = mockHandout("apply-handout"); - applyHelpContentToHandout(handout as Roll20Object); + applyHelpContentToHandout(handout as Roll20Handout); expect(mockSet).toHaveBeenCalledWith({ inplayerjournals: "all", diff --git a/ChatSetAttr/src/__tests__/unit/message.test.ts b/ChatSetAttr/src/__tests__/unit/message.test.ts index 6751437ff9..bcf2560649 100644 --- a/ChatSetAttr/src/__tests__/unit/message.test.ts +++ b/ChatSetAttr/src/__tests__/unit/message.test.ts @@ -167,6 +167,51 @@ describe("message", () => { it("should return undefined for invalid command", () => { expect(parseMessage("!invalidcmd --test")).toBeUndefined(); }); + + it("should extract command when extra tokens follow on the same segment", () => { + const result = parse("!setattr -all --hp|1"); + expect(result.operation).toBe("setattr"); + expect(result.changes).toEqual( + expect.arrayContaining([ + { name: "-all" }, + { name: "hp", current: "1" }, + ]), + ); + }); + + it("should extract command when multiple words follow before first --", () => { + const result = parse("!setattr foo bar --hp|1"); + expect(result.operation).toBe("setattr"); + expect(result.changes).toEqual( + expect.arrayContaining([ + { name: "foobar" }, + { name: "hp", current: "1" }, + ]), + ); + }); + }); + + describe("aberrant input", () => { + it("should return undefined when the command segment has no leading bang", () => { + expect(parseMessage("setattr --hp|1")).toBeUndefined(); + }); + + it("should return undefined when the first token is not a known command", () => { + expect(parseMessage("!notacommand --hp|1")).toBeUndefined(); + }); + + it("should parse setattr with only the operation and no segments", () => { + const result = parse("!setattr"); + expect(result.operation).toBe("setattr"); + expect(result.targeting).toEqual([]); + expect(result.changes).toEqual([]); + }); + + it("should treat single-dash tokens as attribute names, not options", () => { + const result = parse("!setattr -all --hp|1"); + expect(result.options).toEqual({}); + expect(result.targeting).toEqual([]); + }); }); describe("command option overrides", () => { diff --git a/ChatSetAttr/src/env.d.ts b/ChatSetAttr/src/env.d.ts index 6472a9acbc..67ad026ba9 100644 --- a/ChatSetAttr/src/env.d.ts +++ b/ChatSetAttr/src/env.d.ts @@ -34,4 +34,9 @@ declare global { }; } } + + /** Present on Beacon-capable Mod API sandboxes; not yet in @roll20/api-types. */ + interface BeaconCampaignMarker { + computedSummary?: unknown; + } } diff --git a/ChatSetAttr/src/modules/help.ts b/ChatSetAttr/src/modules/help.ts index 1437ce1a6a..59b10973af 100644 --- a/ChatSetAttr/src/modules/help.ts +++ b/ChatSetAttr/src/modules/help.ts @@ -9,14 +9,14 @@ export function checkHelpMessage(msg: string): boolean { return msg.trim().toLowerCase().startsWith(HELP_COMMAND); } -export function findHelpHandout(): Roll20Object | undefined { +export function findHelpHandout(): Roll20Handout | undefined { return findObjs({ _type: "handout", name: HELP_HANDOUT_NAME, })[0]; } -export function applyHelpContentToHandout(handout: Roll20Object): void { +export function applyHelpContentToHandout(handout: Roll20Handout): void { const helpContent = createHelpHandout(handout.id); const bundledAt = getBundledHelpContentUpdatedAt(); diff --git a/ChatSetAttr/src/modules/main.ts b/ChatSetAttr/src/modules/main.ts index ca27483245..548eaa5cdd 100644 --- a/ChatSetAttr/src/modules/main.ts +++ b/ChatSetAttr/src/modules/main.ts @@ -46,6 +46,16 @@ async function acceptMessage(msg: Roll20ChatMessage) { const result: Record = {}; // Parse Message + const parsed = parseMessage(msg.content); + if (!parsed) { + return errorOut( + "Could not parse command. Check that command options use -- (double dash).", + msg.playerid, + errors, + normalizeCommandOutputOptions(), + ); + } + const { operation, targeting, @@ -53,7 +63,7 @@ async function acceptMessage(msg: Roll20ChatMessage) { changes, references, feedback, - } = parseMessage(msg.content); + } = parsed; const output = normalizeCommandOutputOptions(options); diff --git a/ChatSetAttr/src/modules/message.ts b/ChatSetAttr/src/modules/message.ts index 5c6d17834d..99391f9f33 100644 --- a/ChatSetAttr/src/modules/message.ts +++ b/ChatSetAttr/src/modules/message.ts @@ -42,12 +42,24 @@ function extractOperation(parts: string[]): Command | undefined { log("Empty Command."); return; } - const command = parts.shift()!.slice(1); // remove the leading '!' - const isValidCommand = isCommand(command); - if (!isValidCommand) { + const commandPart = parts.shift()!; + const tokens = commandPart.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) { + log("Empty Command."); + return; + } + if (!tokens[0].startsWith("!")) { log("Invalid Command."); return; } + const command = tokens[0].slice(1); + if (!isCommand(command)) { + log("Invalid Command."); + return; + } + if (tokens.length > 1) { + parts.unshift(tokens.slice(1).join(" ")); + } return command; }; diff --git a/ChatSetAttr/tsconfig.json b/ChatSetAttr/tsconfig.json index 42f0d3a30f..668acb0a9c 100644 --- a/ChatSetAttr/tsconfig.json +++ b/ChatSetAttr/tsconfig.json @@ -12,6 +12,6 @@ "jsx": "react", "jsxFactory": "h" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/env.d.ts", "rollup.config.ts", "vitest.setup.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/env.d.ts", "rollup.config.ts"], "exclude": ["node_modules", "src/__tests__/**", "**/*.test.ts", "**/*.spec.ts", "**/*.mock.ts"] } diff --git a/ChatSetAttr/tsconfig.script.json b/ChatSetAttr/tsconfig.script.json index 8287dd6e9e..6ff8d52135 100644 --- a/ChatSetAttr/tsconfig.script.json +++ b/ChatSetAttr/tsconfig.script.json @@ -17,6 +17,8 @@ "src/__tests__/**", "**/*.test.ts", "**/*.spec.ts", - "**/*.mock.ts" + "**/*.mock.ts", + "vitest.setup.ts", + "vitest.config.ts" ] } diff --git a/ChatSetAttr/vitest.setup.ts b/ChatSetAttr/vitest.setup.ts index 8fab72cf76..a702ac76d1 100644 --- a/ChatSetAttr/vitest.setup.ts +++ b/ChatSetAttr/vitest.setup.ts @@ -4,7 +4,7 @@ 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 { mockCreateObj, mockFindObjs, mockGetAllObjs, mockGetAttrByName, mockGetObj, mockCampaign } 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"; @@ -58,7 +58,7 @@ global.setSheetItem = setSheetItem; // region Utility Functions global.playerIsGM = vi.fn(); global.sendChat = vi.fn(); -global.Campaign = vi.fn(() => ({ computedSummary: {} })) as typeof Campaign; +global.Campaign = mockCampaign({ computedSummary: {} }); // region Requirements global.libSmartAttributes = SA; From f0ef1991c54cbbe9425cd03630f408d3eaea6741 Mon Sep 17 00:00:00 2001 From: "Aaron C. Meadows" Date: Fri, 19 Jun 2026 15:09:09 -0500 Subject: [PATCH 38/38] updates for bad argument handling. --- .../integration/malformedCommands.test.ts | 45 ++++++++++++ .../src/__tests__/templates/notice.test.ts | 69 +++++++++++++++++++ .../src/__tests__/unit/beaconSupport.test.ts | 41 +++++++++++ ChatSetAttr/src/modules/beaconSupport.ts | 8 +++ ChatSetAttr/src/templates/notice.tsx | 15 ++++ 5 files changed, 178 insertions(+) create mode 100644 ChatSetAttr/src/__tests__/integration/malformedCommands.test.ts create mode 100644 ChatSetAttr/src/__tests__/templates/notice.test.ts create mode 100644 ChatSetAttr/src/__tests__/unit/beaconSupport.test.ts create mode 100644 ChatSetAttr/src/modules/beaconSupport.ts create mode 100644 ChatSetAttr/src/templates/notice.tsx diff --git a/ChatSetAttr/src/__tests__/integration/malformedCommands.test.ts b/ChatSetAttr/src/__tests__/integration/malformedCommands.test.ts new file mode 100644 index 0000000000..7b19b26129 --- /dev/null +++ b/ChatSetAttr/src/__tests__/integration/malformedCommands.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { resetAllObjects } from "../../__mocks__/apiObjects.mock"; +import { resetAllCallbacks } from "../../__mocks__/eventHandling.mock"; + +describe("Malformed command handling", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetModules(); + vi.doUnmock("../../modules/message"); + resetAllObjects(); + resetAllCallbacks(); + }); + + it("should report a parse error instead of crashing when parseMessage fails", async () => { + vi.resetModules(); + vi.doMock("../../modules/message", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + parseMessage: vi.fn(() => undefined), + }; + }); + + const ChatSetAttr = await import("../../modules/main"); + ChatSetAttr.registerHandlers(); + + createObj("player", { _id: "example-player-id", _displayname: "Test Player" }); + vi.mocked(global.playerIsGM).mockReturnValue(true); + vi.mocked(sendChat).mockClear(); + + expect(() => executeCommand("!setattr --sel --hp|1")).not.toThrow(); + + await vi.waitFor(() => { + const parseErrorCall = vi.mocked(sendChat).mock.calls.find(call => + typeof call[1] === "string" && + /Could not parse command/i.test(call[1]), + ); + expect(parseErrorCall).toBeDefined(); + }); + }); +}); diff --git a/ChatSetAttr/src/__tests__/templates/notice.test.ts b/ChatSetAttr/src/__tests__/templates/notice.test.ts new file mode 100644 index 0000000000..9cb777429d --- /dev/null +++ b/ChatSetAttr/src/__tests__/templates/notice.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; + +import { createNoticeMessage } from "../../templates/notice"; +import { createNotifyMessage } from "../../templates/notification"; +import { + BEACON_UNSUPPORTED_NOTICE_BODY, + BEACON_UNSUPPORTED_NOTICE_TITLE, + LONG_RUNNING_QUERY_BODY, + LONG_RUNNING_QUERY_TITLE, +} from "../../modules/chat"; + +describe("notice", () => { + it("should render Beacon unsupported notice copy", () => { + const message = createNoticeMessage( + BEACON_UNSUPPORTED_NOTICE_TITLE, + BEACON_UNSUPPORTED_NOTICE_BODY, + ); + + expect(message).toContain("Notice: Beacon Support Disabled"); + expect(message).toContain("Mod API Sandbox"); + expect(message).toContain("Mod API Scripts Page"); + expect(message).not.toContain("Default"); + expect(message).not.toContain("Production"); + expect(message).not.toContain("Experimental"); + }); + + it("should render Long Running Query notice copy", () => { + const message = createNoticeMessage(LONG_RUNNING_QUERY_TITLE, LONG_RUNNING_QUERY_BODY); + + expect(message).toContain("Long Running Query"); + expect(message).toContain("long time to execute"); + }); + + it("should use yellow notice styling instead of blue notification styling", () => { + const message = createNoticeMessage("Test Notice", "Body text"); + + expect(message).toContain("rgba(245, 158, 11"); + expect(message).not.toContain("rgba(59, 130, 246"); + }); + + it("should look distinct from notification messages for the same title and body", () => { + const title = "Shared Title"; + const body = "Shared body text"; + + const notice = createNoticeMessage(title, body); + const notification = createNotifyMessage(title, body); + + expect(notice).toContain("rgba(245, 158, 11"); + expect(notification).toContain("rgba(59, 130, 246"); + expect(notice).not.toContain("rgba(59, 130, 246"); + expect(notification).not.toContain("rgba(245, 158, 11"); + }); + + it("should escape the title and plain-text body", () => { + const message = createNoticeMessage( + 'Title with ', + 'Body with tags', + ); + + expect(message).toContain("<script>alert("x")</script>"); + expect(message).toContain("<em>tags</em>"); + }); + + it("should include wrapper, header, and body structure", () => { + const message = createNoticeMessage("Header", "Content"); + + expect(message).toMatch(/]*>.*]*>Header<\/div>.*]*>Content<\/div>.*<\/div>/s); + }); +}); diff --git a/ChatSetAttr/src/__tests__/unit/beaconSupport.test.ts b/ChatSetAttr/src/__tests__/unit/beaconSupport.test.ts new file mode 100644 index 0000000000..83db0281c7 --- /dev/null +++ b/ChatSetAttr/src/__tests__/unit/beaconSupport.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mockCampaign } from "../../__mocks__/apiObjects.mock"; +import { isBeaconSupported } from "../../modules/beaconSupport"; + +describe("isBeaconSupported", () => { + const originalCampaign = global.Campaign; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + global.Campaign = originalCampaign; + }); + + it("should return false when computedSummary is missing", () => { + global.Campaign = mockCampaign({}); + + expect(isBeaconSupported()).toBe(false); + }); + + it("should return false when computedSummary is undefined", () => { + global.Campaign = mockCampaign({ computedSummary: undefined }); + + expect(isBeaconSupported()).toBe(false); + }); + + it("should return true when computedSummary is present", () => { + global.Campaign = mockCampaign({ computedSummary: {} }); + + expect(isBeaconSupported()).toBe(true); + }); + + it("should return false when Campaign throws", () => { + global.Campaign = vi.fn(() => { + throw new Error("Campaign unavailable"); + }) as unknown as typeof Campaign; + + expect(isBeaconSupported()).toBe(false); + }); +}); diff --git a/ChatSetAttr/src/modules/beaconSupport.ts b/ChatSetAttr/src/modules/beaconSupport.ts new file mode 100644 index 0000000000..9fda4376ac --- /dev/null +++ b/ChatSetAttr/src/modules/beaconSupport.ts @@ -0,0 +1,8 @@ +export function isBeaconSupported(): boolean { + try { + const campaign = Campaign() as Roll20Campaign & BeaconCampaignMarker; + return !!campaign.computedSummary; + } catch { + return false; + } +} diff --git a/ChatSetAttr/src/templates/notice.tsx b/ChatSetAttr/src/templates/notice.tsx new file mode 100644 index 0000000000..ccfc30279c --- /dev/null +++ b/ChatSetAttr/src/templates/notice.tsx @@ -0,0 +1,15 @@ +import { s } from "../utils/chat"; +import { frameStyleNotice, headerStyleBase } from "./styles"; + +const NOTICE_WRAPPER_STYLE = s(frameStyleNotice); + +const NOTICE_HEADER_STYLE = s(headerStyleBase); + +export function createNoticeMessage(title: string, content: string): string { + return ( +
+
{title}
+
{content}
+
+ ).html; +}