diff --git a/ChatSetAttr/1.11/ChatSetAttr.js b/ChatSetAttr/1.11/ChatSetAttr.js new file mode 100644 index 0000000000..5e1bf79ba6 --- /dev/null +++ b/ChatSetAttr/1.11/ChatSetAttr.js @@ -0,0 +1,1683 @@ +var ChatSetAttr = function() { + "use strict"; + const ObserverTypes = { + ADD: "add", + CHANGE: "change", + DESTROY: "destroy" + }; + const ObserverTypeValues = Object.values(ObserverTypes); + class SubscriptionManager { + subscriptions = /* @__PURE__ */ new Map(); + constructor() { + } + subscribe(event, callback) { + var _a; + if (typeof callback !== "function") { + log(`event registration unsuccessful: ${event} - callback is not a function`); + } + if (!ObserverTypeValues.includes(event)) { + log(`event registration unsuccessful: ${event} - event is not a valid observer type`); + } + if (!this.subscriptions.has(event)) { + this.subscriptions.set(event, []); + } + (_a = this.subscriptions.get(event)) == null ? void 0 : _a.push(callback); + log(`event registration successful: ${event}`); + } + unsubscribe(event, callback) { + if (!this.subscriptions.has(event)) { + return; + } + const callbacks = this.subscriptions.get(event); + const index = callbacks == null ? void 0 : callbacks.indexOf(callback); + if (index !== void 0 && index !== -1) { + callbacks == null ? void 0 : callbacks.splice(index, 1); + } + } + publish(event, ...args) { + if (!this.subscriptions.has(event)) { + return; + } + const callbacks = this.subscriptions.get(event); + callbacks == null ? void 0 : callbacks.forEach((callback) => callback(...args)); + } + } + const globalSubscribeManager = new SubscriptionManager(); + class APIWrapper { + // #region Attributes + static async getAllAttrs(character) { + const attrs = findObjs({ + _type: "attribute", + _characterid: character == null ? void 0 : character.id + }); + return attrs; + } + static async getAttr(character, attributeName) { + const attr = findObjs({ + _type: "attribute", + _characterid: character == null ? void 0 : character.id, + name: attributeName + })[0]; + if (!attr) { + return null; + } + return attr; + } + static async getAttributes(character, attributeList) { + const attributes = {}; + for (const attribute of attributeList) { + const value2 = await APIWrapper.getAttribute(character, attribute); + if (!value2) { + continue; + } + attributes[attribute] = value2; + } + return attributes; + } + static async getAttribute(character, attributeName) { + if (!character) { + return null; + } + const value2 = await getSheetItem(character.id, attributeName, "current"); + const max = await getSheetItem(character.id, attributeName, "max"); + const attributeValue = {}; + if (value2) { + attributeValue.value = value2; + } + if (max) { + attributeValue.max = max; + } + if (!value2 && !max) { + return null; + } + return attributeValue; + } + static async createAttribute(character, name, value2, max) { + const errors = []; + const messages = []; + const newObjProps = { + name, + _characterid: character == null ? void 0 : character.id + }; + const newAttr = createObj("attribute", newObjProps); + await APIWrapper.setAttribute(character, name, value2, max); + globalSubscribeManager.publish("add", newAttr); + globalSubscribeManager.publish("change", newAttr, newAttr); + messages.push(`Created attribute ${name} with value ${value2} for character ${character == null ? void 0 : character.get("name")}.`); + return [{ messages, errors }]; + } + static async updateAttribute(character, name, value2, max) { + const errors = []; + const messages = []; + const attr = await APIWrapper.getAttribute(character, name); + if (!attr) { + errors.push(`Attribute ${name} does not exist for character ${character.get("name")}.`); + return [{ messages, errors }]; + } + const oldAttr = JSON.parse(JSON.stringify(attr)); + await APIWrapper.setAttribute(character, name, value2, max); + if (value2 && max) { + messages.push(`Setting ${name} to ${value2} and ${name}_max to ${max} for character ${character == null ? void 0 : character.get("name")}.`); + } else if (value2) { + messages.push(`Setting ${name} to ${value2} for character ${character == null ? void 0 : character.get("name")}, max remains unchanged.`); + } else if (max) { + messages.push(`Setting ${name}_max to ${max} for character ${character == null ? void 0 : character.get("name")}, current remains unchanged.`); + } + globalSubscribeManager.publish("change", { name, current: value2, max }, { name, current: oldAttr.value, max: oldAttr.max }); + return [{ messages, errors }]; + } + static async setAttributeOld(attr, value2, max) { + if (state.ChatSetAttr.useWorkers) { + attr.setWithWorker({ current: value2 }); + } else { + attr.set({ current: value2 }); + } + if (max) { + if (state.ChatSetAttr.useWorkers) { + attr.setWithWorker({ max }); + } else { + attr.set({ max }); + } + } + } + static async setWithWorker(characterID, attr, value2, max) { + const attrObj = await APIWrapper.getAttr( + { id: characterID }, + attr + ); + if (!attrObj) { + return; + } + attrObj.setWithWorker({ + current: value2 + }); + if (max) { + attrObj.setWithWorker({ + max + }); + } + } + static async setAttribute(character, attr, value2, max) { + if (state.ChatSetAttr.useWorkers) { + await APIWrapper.setWithWorker(character.id, attr, value2, max); + return; + } + if (value2) { + await setSheetItem(character.id, attr, value2, "current"); + log(`Setting ${attr} to ${value2} for character with ID ${character.get("name")}.`); + } + if (max) { + await setSheetItem(character.id, attr, max, "max"); + log(`Setting ${attr} max to ${max} for character ${character.get("name")}.`); + } + } + static async setAttributesOnly(character, attributes) { + const errors = []; + const messages = []; + const entries = Object.entries(attributes); + for (const [key, value2] of entries) { + const attribute = await APIWrapper.getAttribute(character, key); + if (!(attribute == null ? void 0 : attribute.value)) { + errors.push(`Attribute ${key} does not exist for character ${character.get("name")}.`); + return [{ messages, errors }]; + } + const stringValue = value2.value ? value2.value.toString() : void 0; + const stringMax = value2.max ? value2.max.toString() : void 0; + const [response] = await APIWrapper.updateAttribute(character, key, stringValue, stringMax); + messages.push(...response.messages); + errors.push(...response.errors); + } + return [{ messages, errors }]; + } + static async setAttributes(character, attributes) { + const errors = []; + const messages = []; + const entries = Object.entries(attributes); + for (const [key, value2] of entries) { + const stringValue = value2.value ? value2.value.toString() : ""; + const stringMax = value2.max ? value2.max.toString() : void 0; + const attribute = await APIWrapper.getAttribute(character, key); + if (!(attribute == null ? void 0 : attribute.value)) { + const [response] = await APIWrapper.createAttribute(character, key, stringValue, stringMax); + messages.push(...response.messages); + errors.push(...response.errors); + } else { + const [response] = await APIWrapper.updateAttribute(character, key, stringValue, stringMax); + messages.push(...response.messages); + errors.push(...response.errors); + } + } + return [{ messages, errors }]; + } + static async deleteAttributes(character, attributes) { + const errors = []; + const messages = []; + for (const attribute of attributes) { + const { section, repeatingID, attribute: attrName } = APIWrapper.extractRepeatingDetails(attribute) || {}; + if (section && repeatingID && !attrName) { + return APIWrapper.deleteRepeatingRow(character, section, repeatingID); + } + const attr = await APIWrapper.getAttr(character, attribute); + if (!attr) { + errors.push(`Attribute ${attribute} does not exist for character ${character.get("name")}.`); + continue; + } + const oldAttr = JSON.parse(JSON.stringify(attr)); + attr.remove(); + globalSubscribeManager.publish("destroy", oldAttr); + messages.push(`Attribute ${attribute} deleted for character ${character.get("name")}.`); + } + return [{ messages, errors }]; + } + static async deleteRepeatingRow(character, section, repeatingID) { + const errors = []; + const messages = []; + const repeatingAttrs = findObjs({ + _type: "attribute", + _characterid: character.id + }).filter((attr) => { + const name = attr.get("name"); + return name.startsWith(`repeating_${section}_${repeatingID}_`); + }); + if (repeatingAttrs.length === 0) { + errors.push(`No repeating attributes found for section ${section} and ID ${repeatingID} for character ${character.get("name")}.`); + return [{ messages, errors }]; + } + for (const attr of repeatingAttrs) { + const oldAttr = JSON.parse(JSON.stringify(attr)); + attr.remove(); + globalSubscribeManager.publish("destroy", oldAttr); + messages.push(`Repeating attribute ${attr.get("name")} deleted for character ${character.get("name")}.`); + } + return [{ messages, errors }]; + } + static async resetAttributes(character, attributes) { + const errors = []; + const messages = []; + for (const attribute of attributes) { + const attr = await APIWrapper.getAttribute(character, attribute); + const value2 = attr == null ? void 0 : attr.value; + if (!value2) { + errors.push(`Attribute ${attribute} does not exist for character ${character.get("name")}.`); + continue; + } + const max = attr.max; + if (!max) { + continue; + } + const oldAttr = JSON.parse(JSON.stringify(attr)); + APIWrapper.setAttribute(character, attribute, max); + globalSubscribeManager.publish("change", attr, oldAttr); + messages.push(`Attribute ${attribute} reset for character ${character.get("name")}.`); + } + return [{ messages, errors }]; + } + // #endregion Attributes + // #region Repeating Attributes + static extractRepeatingDetails(attributeName) { + const [, section, repeatingID, ...attributeParts] = attributeName.split("_"); + const attribute = attributeParts.join("_"); + return { + section: section || void 0, + repeatingID: repeatingID || void 0, + attribute: attribute || void 0 + }; + } + static hasRepeatingAttributes(attributes) { + return Object.keys(attributes).some((key) => key.startsWith("repeating_")); + } + } + const CommandType = { + NONE: -1, + API: 0, + INLINE: 1 + }; + const Commands = { + SET_ATTR_CONFIG: "setattr-config", + RESET_ATTR: "resetattr", + SET_ATTR: "setattr", + DEL_ATTR: "delattr", + MOD_ATTR: "modattr", + MOD_B_ATTR: "modbattr" + }; + const Flags = { + // Targeting Modes + ALL: "all", + ALL_GM: "allgm", + ALL_PLAYERS: "allplayers", + CHAR_ID: "charid", + CHAR_NAME: "name", + SELECTED: "sel", + // Command Modes + MOD: "mod", + MOD_B: "modb", + RESET: "reset", + DEL: "del", + // Modifiers + SILENT: "silent", + MUTE: "mute", + REPLACE: "replace", + NO_CREATE: "nocreate", + EVAL: "evaluate", + // Feedback + FB_PUBLIC: "fb-public", + FB_FROM: "fb-from", + FB_HEADER: "fb-header", + FB_CONTENT: "fb-content", + // Config + PLAYERS_CAN_MODIFY: "players-can-modify", + PLAYERS_CAN_EVALUATE: "players-can-evaluate", + USE_WORKERS: "use-workers" + }; + class InputParser { + commands = Object.values(Commands); + flags = Object.values(Flags); + commandPrefix = "!"; + commandSuffix = "!!!"; + optionPrefix = "--"; + constructor() { + } + parse(message) { + log(`InputParser.parse: message: ${JSON.stringify(message)}`); + let input = this.processInlineRolls(message); + for (const command of this.commands) { + const commandString = `${this.commandPrefix}${command}`; + if (input.startsWith(commandString)) { + return this.parseAPICommand(command, input, CommandType.API); + } + const regex = new RegExp(`(${this.commandPrefix}${command}.*)${this.commandSuffix}`); + const match = input.match(regex); + log(`InputParser.parse: command: ${command}, match: ${JSON.stringify(match)}`); + if (match) { + return this.parseAPICommand(command, match[1], CommandType.INLINE); + } + } + return { + commandType: CommandType.NONE, + command: null, + flags: [], + attributes: {} + }; + } + parseAPICommand(command, input, type) { + const { flags, attributes } = this.extractOptions(input, command); + return { + commandType: type, + command, + flags, + attributes + }; + } + extractOptions(input, command) { + const attributes = {}; + const flags = []; + const commandString = `${this.commandPrefix}${command} `; + const optionsString = input.slice(commandString.length).trim(); + const allOptions = optionsString.split(this.optionPrefix).map((opt) => opt.trim()).filter((opt) => !!opt); + for (const option of allOptions) { + const isFlag = this.flags.some((flag) => option.startsWith(flag)); + if (isFlag) { + const flag = this.parseFlag(option); + if (flag) { + flags.push(flag); + } + } else { + const { name, attribute } = this.parseAttribute(option) ?? {}; + if (attribute && name) { + attributes[name] = attribute; + } + } + } + return { flags, attributes }; + } + parseFlag(option) { + const [name, ...values] = option.split(" ").map((opt) => opt.trim()); + const value2 = values.join(" "); + return { + name: this.stripChars(name), + value: this.stripChars(value2) + }; + } + parseAttribute(option) { + const split = option.split(/(? opt.trim()); + const rationalized = split.map((p) => { + p = this.stripChars(p); + if (!p || p === "") { + return null; + } + return p; + }); + const [name, value2, max] = rationalized; + if (!name) { + return null; + } + const attribute = {}; + if (value2) { + attribute.value = value2; + } + if (max) { + attribute.max = max; + } + return { + attribute, + name + }; + } + stripQuotes(str) { + return str.replace(/["'](.*)["']/g, "$1"); + } + stripBackslashes(str) { + return str.replace(/\\/g, ""); + } + stripChars(str) { + const noSlashes = this.stripBackslashes(str); + const noQuotes = this.stripQuotes(noSlashes); + return noQuotes; + } + processInlineRolls(message) { + const { inlinerolls } = message; + if (!inlinerolls || Object.keys(inlinerolls).length === 0) { + return message.content; + } + let content = this.removeRollTemplates(message.content); + for (const key in inlinerolls) { + const roll = inlinerolls[key]; + if (roll.results && roll.results.total !== void 0) { + const rollValue = roll.results.total; + content = content.replace(`$[[${key}]]`, rollValue.toString()); + } else { + content = content.replace(`$[[${key}]]`, ""); + } + } + return content; + } + removeRollTemplates(input) { + return input.replace(/{{[^}[\]]+\$\[\[(\d+)\]\].*?}}/g, (_, number) => { + return `$[[${number}]]`; + }); + } + } + function convertCamelToKebab(camel) { + return camel.replace(/([a-z])([A-Z])/g, `$1-$2`).toLowerCase(); + } + function createStyle(styleObject) { + let style = ``; + for (const [key, value2] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value2};`; + } + return style; + } + function checkOpt(options, type) { + return options.some((opt) => opt.name === type); + } + function asyncTimeout(ms) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); + } + class UUID { + static base64Chars = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; + static base = 64; + static previousTime = 0; + static counter = new Array(12).fill(0); + static toBase64(num, length) { + let result = ""; + for (let i = 0; i < length; i++) { + result = this.base64Chars[num % this.base] + result; + num = Math.floor(num / this.base); + } + return result; + } + static generateRandomBase64(length) { + let result = ""; + for (let i = 0; i < length; i++) { + result += this.base64Chars[Math.floor(Math.random() * this.base)]; + } + return result; + } + static generateUUID() { + const currentTime = Date.now(); + const timeBase64 = this.toBase64(currentTime, 8); + let randomOrCounterBase64 = ""; + if (currentTime === this.previousTime) { + for (let i = this.counter.length - 1; i >= 0; i--) { + this.counter[i]++; + if (this.counter[i] < this.base) { + break; + } else { + this.counter[i] = 0; + } + } + randomOrCounterBase64 = this.counter.map((index) => this.base64Chars[index]).join(""); + } else { + randomOrCounterBase64 = this.generateRandomBase64(12); + this.counter.fill(0); + this.previousTime = currentTime; + } + return timeBase64 + randomOrCounterBase64; + } + static generateRowID() { + return this.generateUUID().replace(/_/g, "Z"); + } + } + const REPLACEMENTS = { + "<": "[", + ">": "]", + "lbrak": "[", + "\rbrak": "]", + ";": "?", + "ques": "?", + "`": "@", + "at": "@", + "~": "-", + "dash": "-" + }; + class AttrProcessor { + character; + delta; + final; + attributes; + repeating; + eval; + parse; + modify; + constrain; + replace; + errors = []; + newRowID = null; + static referenceRegex = /%([^%]+)%/g; + static rowNumberRegex = /\$\d+/g; + constructor(character, delta, { + useEval = false, + useParse = true, + useModify = false, + useConstrain = false, + useReplace = false + } = {}) { + this.character = character; + this.delta = delta; + this.final = {}; + this.attributes = []; + this.repeating = this.initializeRepeating(); + this.eval = useEval; + this.parse = useParse; + this.modify = useModify; + this.constrain = useConstrain; + this.replace = useReplace; + if (this.constrain) { + this.modify = true; + } + } + initializeRepeating() { + return { + repeatingData: {}, + repeatingOrders: {} + }; + } + async init() { + this.attributes = await APIWrapper.getAllAttrs(this.character); + this.repeating.repeatingData = await this.getAllRepeatingData(); + this.repeating.repeatingOrders = this.getRepeatingOrders(); + this.final = await this.parseAttributes(); + return this.final; + } + async getAllRepeatingData() { + const repeatingData = {}; + const allAttributes = await APIWrapper.getAllAttrs(this.character); + for (const attr of allAttributes) { + const attributeName = attr.get("name"); + if (!attributeName.startsWith("repeating_")) { + continue; + } + const { section, repeatingID } = APIWrapper.extractRepeatingDetails(attributeName) || {}; + if (!section || !repeatingID) { + this.errors.push(`Invalid repeating attribute name: ${attributeName}`); + continue; + } + repeatingData[section] ??= {}; + repeatingData[section][repeatingID] ??= {}; + const alreadyExists = repeatingData[section][repeatingID][attributeName]; + if (alreadyExists) { + continue; + } + const attributeValue = attr.get("current"); + const attributeMax = attr.get("max"); + const attribute = { + value: attributeValue || "" + }; + if (attributeMax) { + attribute.max = attributeMax; + } + repeatingData[section][repeatingID][attributeName] = attribute; + } + return repeatingData; + } + getRepeatingOrders() { + const repeatingOrders = {}; + const sections = this.attributes.filter((attr) => attr.get("name").startsWith("repeating_")).map((attr) => { + const name = attr.get("name"); + const { section = "null" } = APIWrapper.extractRepeatingDetails(name) ?? {}; + return section; + }).filter((section, index, self) => self.indexOf(section) === index); + const orderAttributes = this.attributes.filter((attr) => attr.get("name").includes("reporder")); + for (const name of sections) { + const attribute = orderAttributes.find((attr) => { + const name2 = attr.get("name"); + const isSection = name2.includes(name2); + return isSection; + }); + const idArray = []; + if (attribute) { + const value2 = attribute.get("current"); + const list = value2.split(",").map((item) => item.trim()).filter((item) => !!item); + idArray.push(...list); + } + const unordered = this.getUnorderedAttributes(name, idArray); + const allIDs = /* @__PURE__ */ new Set([...idArray, ...unordered]); + const ids = Array.from(allIDs); + repeatingOrders[name] ??= []; + repeatingOrders[name].push(...ids); + } + return repeatingOrders; + } + getUnorderedAttributes(section, ids) { + const unordered = this.attributes.filter((attr) => { + const name = attr.get("name"); + const correctSection = name.startsWith(`repeating_${section}_`); + const notOrdered = !ids.find((id) => name === id); + return correctSection && notOrdered; + }); + const idsOnly = unordered.map((attr) => { + const name = attr.get("name"); + const { repeatingID } = APIWrapper.extractRepeatingDetails(name) ?? {}; + return repeatingID || name; + }); + return idsOnly; + } + async parseAttributes() { + const delta = { ...this.delta }; + const final = {}; + for (const key in delta) { + const deltaItem = delta[key]; + let deltaName = key; + let deltaCurrent = deltaItem.value || ""; + let deltaMax = deltaItem.max || ""; + const hasRepeating = deltaName.startsWith("repeating_"); + if (this.replace) { + deltaCurrent = this.replaceMarks(deltaCurrent); + deltaMax = this.replaceMarks(deltaMax); + } + if (hasRepeating) { + deltaName = this.parseRepeating(deltaName); + } + if (this.parse) { + deltaCurrent = this.parseDelta(deltaCurrent); + deltaMax = this.parseDelta(deltaMax); + } + if (this.eval) { + deltaCurrent = this.evalDelta(deltaCurrent); + deltaMax = this.evalDelta(deltaMax); + } + if (this.modify) { + deltaCurrent = this.modifyDelta(deltaCurrent, deltaName); + deltaMax = this.modifyDelta(deltaMax, deltaName, true); + } + if (this.constrain) { + deltaCurrent = this.constrainDelta(deltaCurrent, deltaMax, deltaName); + } + final[deltaName] = { + value: deltaCurrent.toString() + }; + if (deltaMax) { + final[deltaName].max = deltaMax; + } + } + return final; + } + replaceMarks(value2) { + const replacements = Object.entries(REPLACEMENTS); + for (const [oldChar, newChar] of replacements) { + const regex = new RegExp(oldChar, "g"); + value2 = value2.replace(regex, newChar); + } + return value2; + } + parseDelta(value2) { + return value2.replace(AttrProcessor.referenceRegex, (match, rawName) => { + const isMax = rawName.endsWith("_max"); + const isRepeating = rawName.startsWith("repeating_"); + const noMaxName = isMax ? rawName.slice(0, -4) : rawName; + const attributeName = isRepeating ? this.parseRepeating(noMaxName.trim()) : noMaxName.trim(); + const attribute = this.attributes.find((attr) => attr.get("name") === attributeName); + if (attribute) { + const attrValue = attribute.get("current") || ""; + if (isMax) { + const attrMax = attribute.get("max") || ""; + return attrMax ? attrMax : attrValue; + } + return attrValue; + } + this.errors.push(`Attribute ${attributeName} not found.`); + return match; + }); + } + evalDelta(value) { + try { + const evaled = eval(value); + if (evaled) { + return evaled.toString(); + } else { + return value; + } + } catch (error) { + return value; + } + } + modifyDelta(delta, name, isMax = false) { + const attribute = this.attributes.find((attr) => attr.get("name") === name); + if (!attribute) { + this.errors.push(`Attribute ${name} not found for modification.`); + return delta; + } + const current = isMax ? attribute.get("max") : attribute.get("current"); + if (delta === void 0 || delta === null || delta === "") { + if (!isMax) { + this.errors.push(`Attribute ${name} has no value to modify.`); + } + return ""; + } + const deltaAsNumber = Number(delta); + const currentAsNumber = Number(current); + if (isNaN(deltaAsNumber) || isNaN(currentAsNumber)) { + this.errors.push(`Cannot modify attribute ${name}. Either delta: ${delta} or current: ${current} is not number-valued.`); + return current; + } + let modified = deltaAsNumber + currentAsNumber; + return modified.toString(); + } + constrainDelta(value2, maxDelta, name) { + const attribute = this.attributes.find((attr) => attr.get("name") === name); + const max = maxDelta ? maxDelta : attribute == null ? void 0 : attribute.get("max"); + const valueAsNumber = Number(value2); + const maxAsNumber = max === "" ? Infinity : Number(max); + if (isNaN(valueAsNumber) || isNaN(maxAsNumber)) { + this.errors.push(`Invalid value for constraining: ${value2} or max: ${max}`); + return value2; + } + let valueUpper = Math.min(valueAsNumber, maxAsNumber); + let valueLower = Math.max(0, valueUpper); + return valueLower.toString(); + } + useNewRepeatingID() { + if (!this.newRowID) { + this.newRowID = UUID.generateRowID(); + } + return this.newRowID; + } + parseRepeating(name) { + var _a; + const { section, repeatingID, attribute } = APIWrapper.extractRepeatingDetails(name) ?? {}; + if (!section) { + this.errors.push(`Invalid repeating attribute name: ${name}`); + return name; + } + if (repeatingID === "-CREATE") { + const rowID = this.useNewRepeatingID(); + return `repeating_${section}_${rowID}_${attribute}`; + } + const matches = name.match(AttrProcessor.rowNumberRegex); + if (matches) { + const index = Number(matches[0].slice(1)); + const repeatingID2 = (_a = this.repeating.repeatingOrders[section]) == null ? void 0 : _a[index]; + if (repeatingID2 && !attribute) { + return `repeating_${section}_${repeatingID2}`; + } else if (repeatingID2) { + return `repeating_${section}_${repeatingID2}_${attribute}`; + } + } + this.errors.push(`Repeating ID for ${name} not found.`); + return name; + } + replacePlaceholders(message) { + const entries = Object.entries(this.delta); + const finalEntries = Object.entries(this.final); + const newMessage = message.replace(/_NAME(\d+)_/g, (match, index) => { + if (index > entries.length - 1) { + this.errors.push(`Invalid index ${index} in _NAME${index}_ placeholder.`); + return match; + } + const actualIndex = parseInt(index, 10); + const attr = entries[actualIndex]; + return attr[0] ?? ""; + }).replace(/_TCUR(\d+)_/g, (match, index) => { + if (index > entries.length - 1) { + this.errors.push(`Invalid index ${index} in _NAME${index}_ placeholder.`); + return match; + } + const actualIndex = parseInt(index, 10); + const attr = entries[actualIndex]; + return attr[1].value ?? ""; + }).replace(/_TMAX(\d+)_/g, (match, index) => { + if (index > entries.length - 1) { + this.errors.push(`Invalid index ${index} in _NAME${index}_ placeholder.`); + return match; + } + const actualIndex = parseInt(index, 10); + const attr = entries[actualIndex]; + return attr[1].max ?? ""; + }).replace(/_CUR(\d+)_/g, (match, index) => { + if (index > entries.length - 1) { + this.errors.push(`Invalid index ${index} in _NAME${index}_ placeholder.`); + return match; + } + const actualIndex = parseInt(index, 10); + const attr = finalEntries[actualIndex]; + return attr[1].value ?? ""; + }).replace(/_MAX(\d+)_/g, (match, index) => { + if (index > entries.length - 1) { + this.errors.push(`Invalid index ${index} in _NAME${index}_ placeholder.`); + return match; + } + const actualIndex = parseInt(index, 10); + const attr = finalEntries[actualIndex]; + return attr[1].max ?? ""; + }).replace(/_CHARNAME_/g, this.character.get("name") ?? ""); + return newMessage; + } + } + class SetAttrCommand { + name = "setattr"; + description = "Set attributes for a character."; + options = ["nocreate", "evaluate"]; + async execute(options, targets, values) { + var _a; + const messages = []; + const errors = []; + const onlySet = checkOpt(options, Flags.NO_CREATE); + const createMethod = onlySet ? APIWrapper.setAttributesOnly : APIWrapper.setAttributes; + const useEval = checkOpt(options, Flags.EVAL); + const useReplace = checkOpt(options, Flags.REPLACE); + const useFeedback = checkOpt(options, Flags.FB_CONTENT); + const content = (_a = options.find((opt) => opt.name === Flags.FB_CONTENT)) == null ? void 0 : _a.value; + for (const target of targets) { + const processor = new AttrProcessor(target, values, { + useEval, + useReplace, + useParse: true + }); + const processedValues = await processor.init(); + errors.push(...processor.errors); + const [createResponse] = await createMethod(target, processedValues); + errors.push(...createResponse.errors ?? []); + if (useFeedback && content) { + processor.errors = []; + const messageContent = processor.replacePlaceholders(content); + messages.push(messageContent); + errors.push(...processor.errors); + } else { + messages.push(...createResponse.messages ?? []); + } + await asyncTimeout(20); + } + if (errors.length > 0) { + return { + messages: [], + errors + }; + } + return { + messages, + errors + }; + } + help() { + return `!setattr ${this.options.join(" ")} - Set attributes for a character.`; + } + } + class ModAttrCommand { + name = "modattr"; + description = "Modify attributes for a character."; + options = ["evaluate"]; + async execute(options, targets, values) { + const messages = []; + const errors = []; + const shouldEval = checkOpt(options, Flags.EVAL); + const useReplace = checkOpt(options, Flags.REPLACE); + for (const target of targets) { + const processor = new AttrProcessor(target, values, { + useEval: shouldEval, + useReplace, + useParse: true, + useModify: true + }); + const processedValues = await processor.init(); + errors.push(...processor.errors); + const [createResponse] = await APIWrapper.setAttributes(target, processedValues); + messages.push(...createResponse.messages ?? []); + errors.push(...createResponse.errors ?? []); + await asyncTimeout(20); + } + return { + messages, + errors + }; + } + help() { + return `!modattr ${this.options.join(" ")} - Modify attributes for a character.`; + } + } + class ModBAttrCommand { + name = "modbattr"; + description = "Modify attributes for a character bound to upper values of the related max."; + options = ["evaluate"]; + async execute(options, targets, values) { + const messages = []; + const errors = []; + const shouldEval = checkOpt(options, Flags.EVAL); + const useReplace = checkOpt(options, Flags.REPLACE); + for (const target of targets) { + const processor = new AttrProcessor(target, values, { + useReplace, + useEval: shouldEval, + useParse: true, + useModify: true, + useConstrain: true + }); + const processedValues = await processor.init(); + errors.push(...processor.errors); + const [createResponse] = await APIWrapper.setAttributes(target, processedValues); + messages.push(...createResponse.messages ?? []); + errors.push(...createResponse.errors ?? []); + await asyncTimeout(20); + } + return { + messages, + errors + }; + } + help() { + return `!modbattr ${this.options.join(" ")} - Modify attributes for a character bound to upper values of the related max.`; + } + } + class DelAttrCommand { + name = "delattr"; + description = "Delete attributes for a character."; + options = []; + async execute(_, targets, values) { + const messages = []; + const errors = []; + for (const target of targets) { + const processor = new AttrProcessor(target, values, { + useParse: true + }); + const processedValues = await processor.init(); + const attrs = Object.keys(processedValues); + const [response] = await APIWrapper.deleteAttributes(target, attrs); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + await asyncTimeout(20); + } + return { + messages, + errors + }; + } + help() { + return `!delattr - Delete attributes for a character.`; + } + } + class ResetAttrCommand { + name = "resetattr"; + description = "Reset attributes for a character."; + options = []; + async execute(_, targets, values) { + const messages = []; + const errors = []; + for (const target of targets) { + const attrs = Object.keys(values); + const [response] = await APIWrapper.resetAttributes(target, attrs); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + await asyncTimeout(20); + } + return { + messages, + errors + }; + } + help() { + return `!resetattr - Reset attributes for a character.`; + } + } + class ConfigCommand { + name = "setattr-config"; + description = "Configure the SetAttr command."; + options = ["players-can-modify", "players-can-evaluate", "use-workers"]; + async execute(options, _, __, message) { + const messages = []; + const errors = []; + const isGM = playerIsGM(message.playerid); + if (!isGM) { + errors.push("Only the GM can configure ChatSetAttr."); + return { messages, errors }; + } + const config = state.ChatSetAttr; + for (const option of options) { + switch (option.name) { + case Flags.PLAYERS_CAN_MODIFY: + config.playersCanModify = !config.playersCanModify; + messages.push(`Players can modify attributes: ${config.playersCanModify}`); + break; + case Flags.PLAYERS_CAN_EVALUATE: + config.playersCanEvaluate = !config.playersCanEvaluate; + messages.push(`Players can evaluate attributes: ${config.playersCanEvaluate}`); + break; + case Flags.USE_WORKERS: + config.useWorkers = !config.useWorkers; + messages.push(`Using workers for attribute operations: ${config.useWorkers}`); + break; + } + } + const messageContent = this.createMessage(); + messages.push(messageContent); + return { + messages, + errors + }; + } + help() { + return `!setattr-config ${this.options.join(" ")} - Configure the SetAttr command.`; + } + createMessage() { + const localState = state.ChatSetAttr; + let message = ``; + message += `
ChatSetAttr Configuration
`; + message += `!setattr-config can be invoked in the following format:
`; + message += `!setattr-config --option
Specifying an option toggles the current setting.
`; + message += `Current Configuration:
`; + message += this.createMessageRow( + "playersCanModify", + "Determines if players can use --name and --charid to change attributes of characters they do not control", + localState.playersCanModify + ); + message += this.createMessageRow( + "playersCanEvaluate", + "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.", + localState.playersCanEvaluate + ); + message += this.createMessageRow( + "useWorkers", + "Determines if setting attributes should trigger sheet worker operations.", + localState.useWorkers + ); + return message; + } + messageRowStyle = createStyle({ + padding: "5px 10px", + borderBottom: "1px solid #ccc" + }); + messageRowIndicatorStyleOn = createStyle({ + float: "right", + margin: "3px", + padding: "3px", + border: "1px solid #000", + backgroundColor: "#ffc", + color: "#f00" + }); + messageRowIndicatorStyleOff = createStyle({ + float: "right", + margin: "3px", + padding: "3px", + border: "1px solid #000", + backgroundColor: "#ffc", + color: "#666" + }); + createMessageRow(property, description, value2) { + const indicatorStyle = value2 ? this.messageRowIndicatorStyleOn : this.messageRowIndicatorStyleOff; + return `${this.content}
`; + } + closeWrapper() { + return `${error2}
`).join(""); + const error = new ChatOutput({ + header: header2, + content: content2, + from, + type: "error", + whisper + }); + error.send(); + this.errors = []; + this.messages = []; + } + const sendMessage = this.messages.length > 0 || feedback; + if (!sendMessage) { + return; + } + const header = (feedback == null ? void 0 : feedback.header) || "ChatSetAttr Info"; + const type = this.errors.length > 0 ? "error" : "info"; + const messageContent = this.messages.map((message2) => message2.startsWith("<") ? message2 : `${message2}
`).join(""); + const content = ((feedback == null ? void 0 : feedback.content) || "") + messageContent; + const message = new ChatOutput({ header, content, from, type, whisper }); + message.send(); + this.errors = []; + this.messages = []; + } + sendDelayMessage() { + const message = new ChatOutput({ + header: "ChatSetAttr", + content: "Your command is taking a long time to execute. Please be patient, the process will finish eventually.", + from: "ChatSetAttr", + type: "info", + whisper: true + }); + message.send(); + } + extractFeedback(flags) { + const hasFeedback = flags.some((flag) => flag.name === Flags.FB_CONTENT || flag.name === Flags.FB_HEADER || flag.name === Flags.FB_FROM || flag.name === Flags.FB_PUBLIC); + if (!hasFeedback) { + return null; + } + const headerFlag = flags.find((flag) => flag.name === Flags.FB_HEADER); + const fromFlag = flags.find((flag) => flag.name === Flags.FB_FROM); + const publicFlag = flags.find((flag) => flag.name === Flags.FB_PUBLIC); + const header = headerFlag == null ? void 0 : headerFlag.value; + const sender = fromFlag == null ? void 0 : fromFlag.value; + const whisper = publicFlag === void 0; + return { + header, + sender, + whisper + }; + } + static checkInstall() { + var _a; + log(`[ChatSetAttr] Version: ${VERSION}`); + if (((_a = state.ChatSetAttr) == null ? void 0 : _a.version) !== SCHEMA_VERSION) { + log(`[ChatSetAttr] Updating Schema to v${SCHEMA_VERSION}`); + state.ChatSetAttr = { + version: SCHEMA_VERSION, + globalconfigCache: { + lastSaved: 0 + }, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true + }; + } + this.checkGlobalConfig(); + } + static checkGlobalConfig() { + var _a; + const localState = state.ChatSetAttr; + const globalState = (globalconfig == null ? void 0 : globalconfig.chatsetattr) ?? {}; + const lastSavedGlobal = (globalState == null ? void 0 : globalState.lastSaved) || 0; + const lastSavedLocal = ((_a = localState.globalconfigCache) == null ? void 0 : _a.lastSaved) || 0; + if (lastSavedGlobal > lastSavedLocal) { + const date = new Date(lastSavedGlobal * 1e3); + log(`[ChatSetAttr] Updating local state from global config (last saved: ${date.toISOString()})`); + } + state.ChatSetAttr = { + ...localState, + playersCanModify: "playersCanModify" === globalState[SCRIPT_STRINGS.CAN_MODIFY], + playersCanEvaluate: "playersCanEvaluate" === globalState[SCRIPT_STRINGS.CAN_EVAL], + useWorkers: "useWorkers" === globalState[SCRIPT_STRINGS.USE_WORKERS], + globalconfigCache: { + ...globalState + } + }; + } + registerEventHandlers() { + on("chat:message", (...args) => this.handleChatMessage(...args)); + ChatSetAttr.checkInstall(); + } + static registerObserver(event, callback) { + globalSubscribeManager.subscribe(event, callback); + } + static unregisterObserver(event, callback) { + globalSubscribeManager.unsubscribe(event, callback); + } + } + on("ready", () => { + new ChatSetAttr(); + }); + const main = { + registerObserver: ChatSetAttr.registerObserver, + unregisterObserver: ChatSetAttr.unregisterObserver + }; + return main; +}(); diff --git a/ChatSetAttr/ChatSetAttr.js b/ChatSetAttr/ChatSetAttr.js index e4cd91e789..ae492662bd 100644 --- a/ChatSetAttr/ChatSetAttr.js +++ b/ChatSetAttr/ChatSetAttr.js @@ -1,785 +1,1589 @@ -// 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.join("
")}
!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 + "" + - "
" + (feedback.join("
") || "Nothing to do.") + "
";
- 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 += "
ChatSetAttr Configuration
`; + message += `!setattr-config can be invoked in the following format:
`; + message += `!setattr-config --option
Specifying an option toggles the current setting.
`; + message += `Current Configuration:
`; + message += this.createMessageRow( + "playersCanModify", + "Determines if players can use --name and --charid to change attributes of characters they do not control", + localState.playersCanModify + ); + message += this.createMessageRow( + "playersCanEvaluate", + "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.", + localState.playersCanEvaluate + ); + message += this.createMessageRow( + "useWorkers", + "Determines if setting attributes should trigger sheet worker operations.", + localState.useWorkers + ); + return message; + } + messageRowStyle = createStyle({ + padding: "5px 10px", + borderBottom: "1px solid #ccc" + }); + messageRowIndicatorStyleOn = createStyle({ + float: "right", + margin: "3px", + padding: "3px", + border: "1px solid #000", + backgroundColor: "#ffc", + color: "#f00" + }); + messageRowIndicatorStyleOff = createStyle({ + float: "right", + margin: "3px", + padding: "3px", + border: "1px solid #000", + backgroundColor: "#ffc", + color: "#666" + }); + createMessageRow(property, description, value2) { + const indicatorStyle = value2 ? this.messageRowIndicatorStyleOn : this.messageRowIndicatorStyleOff; + return `${this.content}
`; + } + closeWrapper() { + return `${error2}
`).join(""); + const error = new ChatOutput({ + header: header2, + content: content2, + from, + type: "error", + whisper: true + }); + error.send(); + this.errors = []; + this.messages = []; + return; + } + const sendMessage = this.messages.length > 0 || feedback; + if (!sendMessage) { + return; + } + const header = (feedback == null ? void 0 : feedback.header) || "ChatSetAttr Info"; + const type = this.errors.length > 0 ? "error" : "info"; + const messageContent = this.messages.map((message2) => message2.startsWith("<") ? message2 : `${message2}
`).join(""); + const content = ((feedback == null ? void 0 : feedback.content) || "") + messageContent; + const message = new ChatOutput({ header, content, from, type, whisper }); + message.send(); + this.errors = []; + this.messages = []; + } + sendDelayMessage() { + const message = new ChatOutput({ + header: "ChatSetAttr", + content: "Your command is taking a long time to execute. Please be patient, the process will finish eventually.", + from: "ChatSetAttr", + type: "info", + whisper: true + }); + message.send(); + } + extractFeedback(flags) { + const hasFeedback = flags.some((flag) => flag.name === Flags.FB_CONTENT || flag.name === Flags.FB_HEADER || flag.name === Flags.FB_FROM || flag.name === Flags.FB_PUBLIC); + if (!hasFeedback) { + return null; + } + const headerFlag = flags.find((flag) => flag.name === Flags.FB_HEADER); + const fromFlag = flags.find((flag) => flag.name === Flags.FB_FROM); + const publicFlag = flags.find((flag) => flag.name === Flags.FB_PUBLIC); + const header = headerFlag == null ? void 0 : headerFlag.value; + const sender = fromFlag == null ? void 0 : fromFlag.value; + const whisper = (publicFlag == null ? void 0 : publicFlag.value) === "false" ? false : true; + return { + header, + sender, + whisper + }; + } + static checkInstall() { + var _a; + log(`[ChatSetAttr] Version: ${VERSION}`); + if (((_a = state.ChatSetAttr) == null ? void 0 : _a.version) !== SCHEMA_VERSION) { + log(`[ChatSetAttr] Updating Schema to v${SCHEMA_VERSION}`); + state.ChatSetAttr = { + version: SCHEMA_VERSION, + globalconfigCache: { + lastSaved: 0 + }, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true + }; + } + this.checkGlobalConfig(); + } + static checkGlobalConfig() { + var _a; + const localState = state.ChatSetAttr; + const globalState = (globalconfig == null ? void 0 : globalconfig.chatsetattr) ?? {}; + const lastSavedGlobal = (globalState == null ? void 0 : globalState.lastSaved) || 0; + const lastSavedLocal = ((_a = localState.globalconfigCache) == null ? void 0 : _a.lastSaved) || 0; + if (lastSavedGlobal > lastSavedLocal) { + const date = new Date(lastSavedGlobal * 1e3); + log(`[ChatSetAttr] Updating local state from global config (last saved: ${date.toISOString()})`); + } + state.ChatSetAttr = { + ...localState, + playersCanModify: "playersCanModify" === globalState[SCRIPT_STRINGS.CAN_MODIFY], + playersCanEvaluate: "playersCanEvaluate" === globalState[SCRIPT_STRINGS.CAN_EVAL], + useWorkers: "useWorkers" === globalState[SCRIPT_STRINGS.USE_WORKERS], + globalconfigCache: { + ...globalState + } + }; + } + registerEventHandlers() { + on("chat:message", (...args) => this.handleChatMessage(...args)); + ChatSetAttr.checkInstall(); + } + static registerObserver(event, callback) { + globalSubscribeManager.subscribe(event, callback); + } + static unregisterObserver(event, callback) { + globalSubscribeManager.unsubscribe(event, callback); + } + } + on("ready", () => { + new ChatSetAttr(); + }); + const main = { + registerObserver: ChatSetAttr.registerObserver, + unregisterObserver: ChatSetAttr.unregisterObserver + }; + return main; +}(); diff --git a/ChatSetAttr/README.md b/ChatSetAttr/README.md index 44e317d0d4..4a9e42860e 100644 --- a/ChatSetAttr/README.md +++ b/ChatSetAttr/README.md @@ -1,94 +1,490 @@ # 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 powerful 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 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 subtracts 5 from `hp` and adds 100 to `xp`. -## Additional options +### !modbattr -These options will have no effect on **!delattr**, except for **--silent**. +Adds to existing attribute values but keeps the result between 0 and the maximum value. Shorthand for `!setattr --modb`. -* **--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. +**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. -## Feedback options +### !delattr -The script accepts several options that modify the feedback messages sent by the script. +Deletes the specified attributes. -* **--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 \${this.content}
`; + }; + + private closeWrapper() { + return `${error}
`).join(""); + const error = new ChatOutput({ + header, + content, + from, + type: "error", + whisper, + }); + error.send(); + this.errors = []; + this.messages = []; + } + const sendMessage = this.messages.length > 0 || feedback; + if (!sendMessage) { + return; + } + const header = feedback?.header || "ChatSetAttr Info"; + const type = this.errors.length > 0 ? "error" : "info"; + const messageContent = this.messages.map(message => message.startsWith("<") ? message : `${message}
`).join(""); + const content = (feedback?.content || "") + messageContent; + const message = new ChatOutput({ header, content, from, type, whisper }); + message.send(); + this.errors = []; + this.messages = []; + }; + + private sendDelayMessage() { + const message = new ChatOutput({ + header: "ChatSetAttr", + content: "Your command is taking a long time to execute. Please be patient, the process will finish eventually.", + from: "ChatSetAttr", + type: "info", + whisper: true + }); + message.send(); + }; + + private extractFeedback(flags: Option[]): Feedback | null { + const hasFeedback = flags.some(flag => flag.name === Flags.FB_CONTENT || flag.name === Flags.FB_HEADER || flag.name === Flags.FB_FROM || flag.name === Flags.FB_PUBLIC); + if (!hasFeedback) { + return null; + } + const headerFlag = flags.find(flag => flag.name === Flags.FB_HEADER); + const fromFlag = flags.find(flag => flag.name === Flags.FB_FROM); + const publicFlag = flags.find(flag => flag.name === Flags.FB_PUBLIC); + const header = headerFlag?.value; + const sender = fromFlag?.value; + const whisper = publicFlag === undefined; + return { + header, + sender, + whisper, + }; + }; + + public static checkInstall() { + log(`[ChatSetAttr] Version: ${VERSION}`); + if (state.ChatSetAttr?.version !== SCHEMA_VERSION) { + log(`[ChatSetAttr] Updating Schema to v${SCHEMA_VERSION}`); + state.ChatSetAttr = { + version: SCHEMA_VERSION, + globalconfigCache: { + lastSaved: 0, + }, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + }; + } + this.checkGlobalConfig(); + }; + + private static checkGlobalConfig() { + const localState = state.ChatSetAttr; + const globalState = globalconfig?.chatsetattr ?? {}; + const lastSavedGlobal = globalState?.lastSaved || 0; + const lastSavedLocal = localState.globalconfigCache?.lastSaved || 0; + if (lastSavedGlobal > lastSavedLocal) { + const date = new Date(lastSavedGlobal * 1000); + log(`[ChatSetAttr] Updating local state from global config (last saved: ${date.toISOString()})`); + } + state.ChatSetAttr = { + ...localState, + playersCanModify: "playersCanModify" === globalState[SCRIPT_STRINGS.CAN_MODIFY], + playersCanEvaluate: "playersCanEvaluate" === globalState[SCRIPT_STRINGS.CAN_EVAL], + useWorkers: "useWorkers" === globalState[SCRIPT_STRINGS.USE_WORKERS], + globalconfigCache: { + ...globalState + } + } + }; + + private registerEventHandlers() { + on("chat:message", (...args) => this.handleChatMessage(...args)); + ChatSetAttr.checkInstall(); + }; + + public static registerObserver(event: string, callback: Function) { + globalSubscribeManager.subscribe(event, callback); + }; + + public static unregisterObserver(event: string, callback: Function) { + globalSubscribeManager.unsubscribe(event, callback); + }; +}; diff --git a/ChatSetAttr/build/src/classes/Commands.ts b/ChatSetAttr/build/src/classes/Commands.ts new file mode 100644 index 0000000000..1d0c588932 --- /dev/null +++ b/ChatSetAttr/build/src/classes/Commands.ts @@ -0,0 +1,355 @@ +import { APIWrapper, type DeltasObject, type ErrorResponse } from "./APIWrapper"; +import { Flags, type Option } from "./InputParser"; +import { createStyle } from "../utils/createStyle"; +import { checkOpt } from "../utils/checkOpt"; +import { asyncTimeout } from "../utils/asyncTimeout"; +import { AttrProcessor } from "./AttrProcessor"; + +export interface CommandStrategy { + name: string; + description: string; + options: string[]; + + execute: ( + options: Option[], + targets: Roll20Character[], + values: DeltasObject, + message: Roll20ChatMessage, + ) => PromiseChatSetAttr Configuration
`; + message += `!setattr-config can be invoked in the following format:
`; + message += `!setattr-config --option
Specifying an option toggles the current setting.
`; + message += `Current Configuration:
`; + message += this.createMessageRow( + "playersCanModify", + "Determines if players can use --name and --charid to change attributes of characters they do not control", + localState.playersCanModify + ); + message += this.createMessageRow( + "playersCanEvaluate", + "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.", + localState.playersCanEvaluate + ); + message += this.createMessageRow( + "useWorkers", + "Determines if setting attributes should trigger sheet worker operations.", + localState.useWorkers + ); + return message; + }; + + messageRowStyle = createStyle({ + padding: "5px 10px", + borderBottom: "1px solid #ccc", + }); + + messageRowIndicatorStyleOn = createStyle({ + float: "right", + margin: "3px", + padding: "3px", + border: "1px solid #000", + backgroundColor: "#ffc", + color: "#f00", + }); + + messageRowIndicatorStyleOff = createStyle({ + float: "right", + margin: "3px", + padding: "3px", + border: "1px solid #000", + backgroundColor: "#ffc", + color: "#666", + }); + + private createMessageRow(property: string, description: string, value: boolean): string { + const indicatorStyle = value ? this.messageRowIndicatorStyleOn : this.messageRowIndicatorStyleOff; + return `${errors.join("
")}
!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 + "" + + "
" + (feedback.join("
") || "Nothing to do.") + "
";
+ 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 += "
Test Content<\/p>/);
+ expect(sendChatArg).toMatch(/<\/div>$/);
+ });
+ });
+});
diff --git a/ChatSetAttr/build/tests/unit/Commands.test.ts b/ChatSetAttr/build/tests/unit/Commands.test.ts
new file mode 100644
index 0000000000..f558e3e689
--- /dev/null
+++ b/ChatSetAttr/build/tests/unit/Commands.test.ts
@@ -0,0 +1,712 @@
+import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"
+import { SetAttrCommand, ModAttrCommand, DelAttrCommand, ModBAttrCommand, ResetAttrCommand, ConfigCommand } from "../../src/classes/Commands";
+import type { Option } from "../../src/classes/InputParser";
+
+describe("SetAttrCommand", () => {
+ let command: SetAttrCommand;
+
+ beforeEach(() => {
+ // Set up test environment
+ global.setupTestEnvironment();
+
+ // Create a new instance of SetAttrCommand for each test
+ command = new SetAttrCommand();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ describe("execute", () => {
+ it("should set attributes for each target character", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "15" },
+ dexterity: { value: "12", max: "18" }
+ };
+ const char1 = createObj("character", { id: "char1" });
+ const char2 = createObj("character", { id: "char2" });
+ const targets = [char1, char2];
+
+ // Act - using new API that expects character objects
+ await command.execute(options, targets, values);
+
+ // Assert - check attributes were created correctly
+ await vi.waitFor(() => {
+ const char1Strength = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: "char1",
+ name: "strength"
+ })[0];
+ expect(char1Strength.get("current")).toBe("15");
+
+ const char1Dexterity = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: "char1",
+ name: "dexterity"
+ })[0];
+ expect(char1Dexterity.get("current")).toBe("12");
+ expect(char1Dexterity.get("max")).toBe("18");
+
+ const char2Strength = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: "char2",
+ name: "strength"
+ })[0];
+ expect(char2Strength.get("current")).toBe("15");
+
+ const char2Dexterity = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: "char2",
+ name: "dexterity"
+ })[0];
+ expect(char2Dexterity.get("current")).toBe("12");
+ expect(char2Dexterity.get("max")).toBe("18");
+ });
+ });
+
+ it("should not create new attributes when nocreate option is provided", async () => {
+ // Arrange
+ const options: Option[] = [
+ { name: "nocreate" }
+ ];
+ const values = {
+ strength: { value: "15" },
+ nonexistent: { value: "20" }
+ };
+ const character = createObj("character", { id: "char1" });
+ const existingAttr = createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "10"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ // Existing attribute should be updated
+ expect(existingAttr.get("current")).toBe("15");
+
+ // Nonexistent attribute should not be created
+ const nonexistentAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "nonexistent"
+ });
+ expect(nonexistentAttr).toHaveLength(0);
+ });
+
+ it("should evaluate attributes when evaluate option is provided", async () => {
+ // Arrange
+ const options: Option[] = [{name: "evaluate"}];
+ const values = {
+ strength: { value: "10 + 5" },
+ dexterity: { value: "8 * 2", max: "20 - 2" }
+ };
+ const character = createObj("character", { id: "char1" });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ const strengthAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "strength"
+ })[0];
+ expect(strengthAttr.get("current")).toBe("15");
+
+ const dexterityAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "dexterity"
+ })[0];
+ expect(dexterityAttr.get("current")).toBe("16");
+ expect(dexterityAttr.get("max")).toBe("18");
+ });
+
+ it("should work with multiple options", async () => {
+ // Arrange
+ const options: Option[] = [{ name: "nocreate" }, { name: "evaluate" }];
+ const values = {
+ strength: { value: "10 + 5" },
+ nonexistent: { value: "20" }
+ };
+ const character = createObj("character", { id: "char1" });
+ const existingAttr = createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "10"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ // Existing attribute should be updated with evaluated value
+ expect(existingAttr.get("current")).toBe("15");
+
+ // Nonexistent attribute should not be created
+ const nonexistentAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "nonexistent"
+ });
+ expect(nonexistentAttr).toHaveLength(0);
+ });
+ });
+
+ describe("help", () => {
+ it("should return help message", () => {
+ // Act
+ const helpText = command.help();
+
+ // Assert
+ expect(helpText).toBe("!setattr nocreate evaluate - Set attributes for a character.");
+ });
+ });
+});
+
+describe("ModAttrCommand", () => {
+ let command: ModAttrCommand;
+
+ beforeEach(() => {
+ // Set up test environment
+ global.setupTestEnvironment();
+
+ // Create a new instance of ModAttrCommand for each test
+ command = new ModAttrCommand();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ describe("execute", () => {
+ it("should modify attributes by adding values", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "5" },
+ dexterity: { value: "10" }
+ };
+ const char = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", { id: "attr1", _characterid: "char1", name: "strength", current: "10" });
+ const dexterity = createObj("attribute", { id: "attr2", _characterid: "char1", name: "dexterity", current: "15" });
+ const targets = [char];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("15");
+ expect(dexterity.get("current")).toBe("25");
+ });
+
+ it("should handle negative modifications", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "-5" },
+ dexterity: { value: "-3" }
+ };
+ const char = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", { id: "attr1", _characterid: "char1", name: "strength", current: "10" });
+ const dexterity = createObj("attribute", { id: "attr2", _characterid: "char1", name: "dexterity", current: "15" });
+ const targets = [char];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("5");
+ expect(dexterity.get("current")).toBe("12");
+ });
+
+ it("should handle attributes with max values", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ dexterity: { value: "10", max: "5" }
+ };
+ const char = createObj("character", { id: "char1" });
+ const dexterity = createObj("attribute", {
+ id: "attr2",
+ _characterid: "char1",
+ name: "dexterity",
+ current:
+ "15",
+ max: "20"
+ });
+ const targets = [char];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(dexterity.get("current")).toBe("25");
+ expect(dexterity.get("max")).toBe("25");
+ });
+
+ it("should keep the current value if modification fails", async () => {
+ // Arrange
+ const options: Option[] = [{ name: "evaluate" }];
+ const values = {
+ strength: { value: "10 + 5" },
+ dexterity: { value: "10", max: "20 - 2" }
+ };
+ const char = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", {
+ id: "attr1",
+ _characterid: "char1",
+ name: "strength",
+ current: "5"
+ });
+ const dexterity = createObj("attribute", {
+ id: "attr2",
+ _characterid: "char1",
+ name: "dexterity",
+ current: "not a number",
+ });
+ const targets = [char];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("20");
+ // Unevaluable values should remain unchanged
+ expect(dexterity.get("current")).toBe("not a number");
+ });
+
+ it("should handle only max values", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ dexterity: { max: "5" }
+ };
+ const char = createObj("character", { id: "char1" });
+ const dexterity = createObj("attribute", { id: "attr2", _characterid: "char1", name: "dexterity", current: "15", max: "20" });
+ const targets = [char];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(dexterity.get("current")).toBe("15");
+ expect(dexterity.get("max")).toBe("25");
+ });
+
+ it("should handle evaluation when evaluate option is provided", async () => {
+ // Arrange
+ const options: Option[] = [{ name: "evaluate" }];
+ const values = {
+ strength: { value: "2 * 3" },
+ dexterity: { value: "5 + 2", max: "3 * 2" }
+ };
+ const char = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", { id: "attr1", _characterid: "char1", name: "strength", current: "10" });
+ const dexterity = createObj("attribute", { id: "attr2", _characterid: "char1", name: "dexterity", current: "20", max: "30" });
+ const targets = [char];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("16");
+ expect(dexterity.get("current")).toBe("27");
+ expect(dexterity.get("max")).toBe("36");
+ });
+
+ it("should gracefully handle attributes that don't exist", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ nonexistent: { value: "5" }
+ };
+ const char = createObj("character", { id: "char1" });
+ const targets = [char];
+
+ // Act & Assert
+ expect(async () => {
+ await command.execute(options, targets, values);
+ }).not.toThrow();
+ });
+ });
+
+ describe("help", () => {
+ it("should return help message", () => {
+ // Act
+ const helpText = command.help();
+
+ // Assert
+ expect(helpText).toBe("!modattr evaluate - Modify attributes for a character.");
+ });
+ });
+});
+
+describe("DelAttrCommand", () => {
+ let command: DelAttrCommand;
+
+ beforeEach(() => {
+ // Set up test environment
+ global.setupTestEnvironment();
+
+ // Create a new instance of DelAttrCommand for each test
+ command = new DelAttrCommand();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ describe("execute", () => {
+ it("should delete attributes for a character", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "" },
+ dexterity: { value: "" }
+ };
+ const character = createObj("character", { id: "char1" });
+ createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "10"
+ });
+ createObj("attribute", {
+ _characterid: character.id,
+ name: "dexterity",
+ current: "15"
+ });
+ createObj("attribute", {
+ _characterid: character.id,
+ name: "wisdom",
+ current: "12"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ // The deleted attributes should no longer exist
+ const strengthAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "strength"
+ });
+ expect(strengthAttr).toHaveLength(0);
+
+ const dexterityAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "dexterity"
+ });
+ expect(dexterityAttr).toHaveLength(0);
+
+ // Other attributes should remain untouched
+ const wisdomAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "wisdom"
+ });
+ expect(wisdomAttr).toHaveLength(1);
+ });
+
+ it("should handle attempts to delete nonexistent attributes", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "" },
+ nonexistent: { value: "" }
+ };
+ const character = createObj("character", { id: "char1" });
+ createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "10"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ // The existing attribute should be deleted
+ const strengthAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "strength"
+ });
+ expect(strengthAttr).toHaveLength(0);
+ });
+
+ it("should delete attributes for multiple characters", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "" }
+ };
+ const char1 = createObj("character", { id: "char1" });
+ const char2 = createObj("character", { id: "char2" });
+ const targets = [char1, char2];
+
+ createObj("attribute", {
+ _characterid: char1.id,
+ name: "strength",
+ current: "10"
+ });
+ createObj("attribute", {
+ _characterid: char2.id,
+ name: "strength",
+ current: "15"
+ });
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ // The attributes should be deleted from both characters
+ const char1Strength = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: char1.id,
+ name: "strength"
+ });
+ expect(char1Strength).toHaveLength(0);
+
+ const char2Strength = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: char2.id,
+ name: "strength"
+ });
+ expect(char2Strength).toHaveLength(0);
+ });
+ });
+
+ describe("help", () => {
+ it("should return help message", () => {
+ // Act
+ const helpText = command.help();
+
+ // Assert
+ expect(helpText).toBe("!delattr - Delete attributes for a character.");
+ });
+ });
+});
+
+describe("ModBAttrCommand", () => {
+ let command: ModBAttrCommand;
+
+ beforeEach(() => {
+ // Set up test environment
+ global.setupTestEnvironment();
+
+ // Create a new instance of ModBAttrCommand for each test
+ command = new ModBAttrCommand();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ describe("execute", () => {
+ it("should modify existing attributes if they exist", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "5" },
+ dexterity: { value: "10" }
+ };
+ const character = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "10"
+ });
+ const dexterity = createObj("attribute", {
+ _characterid: character.id,
+ name: "dexterity",
+ current: "15"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("15"); // 10 + 5
+ expect(dexterity.get("current")).toBe("25"); // 15 + 10
+ });
+
+ it("should handle max values for both new and existing attributes", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: { value: "5", max: "10" },
+ dexterity: { max: "15" }
+ };
+ const character = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "10",
+ max: "20"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("15"); // 10 + 5
+ expect(strength.get("max")).toBe("30"); // 20 + 10
+
+ const dexterityAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "dexterity"
+ })[0];
+ expect(dexterityAttr.get("current")).toBe("0"); // Default for new attribute
+ expect(dexterityAttr.get("max")).toBe("15");
+ });
+
+ it("should evaluate expressions when evaluate option is provided", async () => {
+ // Arrange
+ const options: Option[] = [{ name: "evaluate" }];
+ const values = {
+ strength: { value: "2 * 3" },
+ dexterity: { value: "5 + 2" }
+ };
+ const character = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "10"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("16"); // 10 + 6
+
+ const dexterityAttr = findObjs<"attribute">({
+ _type: "attribute",
+ _characterid: character.id,
+ name: "dexterity"
+ })[0];
+ expect(dexterityAttr.get("current")).toBe("7"); // 0 + 7
+ });
+ });
+});
+
+describe("ResetAttrCommand", () => {
+ let command: ResetAttrCommand;
+
+ beforeEach(() => {
+ // Set up test environment
+ global.setupTestEnvironment();
+
+ // Create a new instance of ResetAttrCommand for each test
+ command = new ResetAttrCommand();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ describe("execute", () => {
+ it("should reset attributes to default values", async () => {
+ // Arrange
+ const options: Option[] = [];
+ const values = {
+ strength: {},
+ dexterity: {}
+ };
+ const character = createObj("character", { id: "char1" });
+ const strength = createObj("attribute", {
+ _characterid: character.id,
+ name: "strength",
+ current: "15",
+ max: "20"
+ });
+ const dexterity = createObj("attribute", {
+ _characterid: character.id,
+ name: "dexterity",
+ current: "20",
+ max: "24"
+ });
+ const targets = [character];
+
+ // Act
+ await command.execute(options, targets, values);
+
+ // Assert
+ expect(strength.get("current")).toBe("20");
+ expect(dexterity.get("current")).toBe("24");
+ });
+ });
+});
+
+describe("ConfigCommand", () => {
+ let command: ConfigCommand;
+
+ beforeEach(() => {
+ // Set up test environment
+ global.setupTestEnvironment();
+
+ // Ensure state has required properties
+ global.state.ChatSetAttr = {
+ version: 3,
+ globalconfigCache: { lastsaved: 0 },
+ playersCanModify: false,
+ playersCanEvaluate: false,
+ useWorkers: false
+ };
+
+ // Create a new instance of ConfigCommand for each test
+ command = new ConfigCommand();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ describe("execute", () => {
+ it("should toggle configuration values", async () => {
+ // Arrange
+ global.state.ChatSetAttr.playersCanModify = true;
+ global.state.ChatSetAttr.playersCanEvaluate = true;
+ global.state.ChatSetAttr.useWorkers = true;
+
+ const options: Option[] = [
+ { name: "players-can-modify" },
+ { name: "use-workers" }
+ ];
+ const values = {};
+ const targets: Roll20Character[] = [];
+ const message = { playerid: "12345" } as Roll20ChatMessage;
+
+ // Act
+ await command.execute(options, targets, values, message);
+
+ // Assert
+ expect(global.state.ChatSetAttr.playersCanModify).toBe(false); // Toggled
+ expect(global.state.ChatSetAttr.playersCanEvaluate).toBe(true); // Unchanged
+ expect(global.state.ChatSetAttr.useWorkers).toBe(false); // Toggled
+ });
+ });
+});
diff --git a/ChatSetAttr/build/tests/unit/InputParser.test.ts b/ChatSetAttr/build/tests/unit/InputParser.test.ts
new file mode 100644
index 0000000000..8489b4f4bb
--- /dev/null
+++ b/ChatSetAttr/build/tests/unit/InputParser.test.ts
@@ -0,0 +1,1782 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { InputParser, CommandType, Commands, Flags } from "../../src/classes/InputParser";
+
+describe("InputParser", () => {
+ let parser: InputParser;
+
+ beforeEach(() => {
+ parser = new InputParser();
+ });
+
+ describe("parse", () => {
+ it("should correctly parse API commands", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[0].value).toBe("John");
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+ });
+
+ it("should correctly parse inline commands", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "Setting attributes for my character !setattr --name John --strength|15|20!!!",
+ playerid: "player123",
+ who: "John",
+ type: "general",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.INLINE);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[0].value).toBe("John");
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+ });
+
+ it("should return NONE command type if no command is found", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "This is not a command",
+ playerid: "player123",
+ who: "John",
+ type: "general",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.NONE);
+ expect(result.command).toBeNull();
+ expect(result.flags).toHaveLength(0);
+ expect(Object.keys(result.attributes)).toHaveLength(0);
+ });
+
+ it("should handle multiple flags", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --silent --fb-public",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(3);
+ expect(result.flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[0].value).toBe("John");
+ expect(result.flags[1].name).toBe(Flags.SILENT);
+ expect(result.flags[1].value).toBe("");
+ expect(result.flags[2].name).toBe(Flags.FB_PUBLIC);
+ expect(result.flags[2].value).toBe("");
+ });
+
+ it("should handle multiple attributes", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|15|20 --dexterity|12 --wisdom|10|18",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(Object.keys(result.attributes)).toHaveLength(3);
+
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+
+ expect(result.attributes).toHaveProperty("dexterity");
+ expect(result.attributes.dexterity.value).toBe("12");
+ expect(result.attributes.dexterity.max).toBeUndefined();
+
+ expect(result.attributes).toHaveProperty("wisdom");
+ expect(result.attributes.wisdom.value).toBe("10");
+ expect(result.attributes.wisdom.max).toBe("18");
+ });
+
+ it("should handle flags with values", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John Smith --fb-header Welcome to the game",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(2);
+ expect(result.flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[0].value).toBe("John Smith");
+ expect(result.flags[1].name).toBe(Flags.FB_HEADER);
+ expect(result.flags[1].value).toBe("Welcome to the game");
+ });
+
+ it("should handle different command types", () => {
+ // Arrange
+ const inputs: Roll20ChatMessage[] = [
+ {
+ content: "!setattr --name John --strength|15",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!modattr --selected --strength|5",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!delattr --charid abc123 --strength",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!resetattr --all --strength",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ }
+ ];
+
+ // Act & Assert
+ expect(parser.parse(inputs[0]).command).toBe(Commands.SET_ATTR);
+ expect(parser.parse(inputs[1]).command).toBe(Commands.MOD_ATTR);
+ expect(parser.parse(inputs[2]).command).toBe(Commands.DEL_ATTR);
+ expect(parser.parse(inputs[3]).command).toBe(Commands.RESET_ATTR);
+ });
+
+ it("should properly handle empty or malformed attributes", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --emptyAttr| --malformedAttr",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(Object.keys(result.attributes)).toHaveLength(2);
+ expect(result.attributes).toHaveProperty("emptyAttr");
+ expect(result.attributes.emptyAttr.value).toBeUndefined();
+ expect(result.attributes.emptyAttr.max).toBeUndefined();
+ expect(result.attributes).toHaveProperty("malformedAttr");
+ expect(result.attributes.malformedAttr.value).toBeUndefined();
+ expect(result.attributes.malformedAttr.max).toBeUndefined();
+ });
+
+ it("should correctly handle commands with no options", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(0);
+ expect(Object.keys(result.attributes)).toHaveLength(0);
+ });
+
+ it("should handle attribute with no max value", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|15|",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBeUndefined();
+ });
+
+ it("should correctly parse attributes with spaces in values", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --description|This is a long description|Max info",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("description");
+ expect(result.attributes.description.value).toBe("This is a long description");
+ expect(result.attributes.description.max).toBe("Max info");
+ });
+
+ it("should parse attribute names correctly from the format --name|value|max", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+ });
+
+ it("should handle attributes without values in deletion commands", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!delattr --name John --strength",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.DEL_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBeUndefined();
+ });
+ });
+
+ describe("targeting flags", () => {
+ it("should correctly parse the --all flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --all --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.ALL);
+ expect(result.flags[0].value).toBe("");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse the --allgm flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --allgm --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.ALL_GM);
+ expect(result.flags[0].value).toBe("");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse the --charid flag with a single character ID", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --charid abc123 --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.CHAR_ID);
+ expect(result.flags[0].value).toBe("abc123");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse the --charid flag with multiple character IDs", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --charid abc123, def456, ghi789 --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.CHAR_ID);
+ expect(result.flags[0].value).toBe("abc123, def456, ghi789");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse the --name flag with a single character name", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[0].value).toBe("John");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse the --name flag with multiple character names", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John, Jane, Bob --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[0].value).toBe("John, Jane, Bob");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse the --name flag with character names containing spaces", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John Smith, Jane Doe --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[0].value).toBe("John Smith, Jane Doe");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse the --sel flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --sel --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.SELECTED);
+ expect(result.flags[0].value).toBe("");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse commands with multiple targeting flags (though this would be invalid in usage)", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --sel --name John --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(2);
+ expect(result.flags[0].name).toBe(Flags.SELECTED);
+ expect(result.flags[0].value).toBe("");
+ expect(result.flags[1].name).toBe(Flags.CHAR_NAME);
+ expect(result.flags[1].value).toBe("John");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should correctly parse targeting flags in inline commands", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "Setting attributes for !setattr --sel --strength|15|20!!!",
+ playerid: "player123",
+ who: "John",
+ type: "general",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.INLINE);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(1);
+ expect(result.flags[0].name).toBe(Flags.SELECTED);
+ expect(result.flags[0].value).toBe("");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should combine targeting flags with other flags", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --sel --silent --evaluate --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(3);
+ expect(result.flags[0].name).toBe(Flags.SELECTED);
+ expect(result.flags[0].value).toBe("");
+ expect(result.flags[1].name).toBe(Flags.SILENT);
+ expect(result.flags[1].value).toBe("");
+ expect(result.flags[2].name).toBe(Flags.EVAL);
+ expect(result.flags[2].value).toBe("");
+ expect(result.attributes).toHaveProperty("strength");
+ });
+
+ it("should parse targeting flags with different commands", () => {
+ // Arrange
+ const inputs: Roll20ChatMessage[] = [
+ {
+ content: "!setattr --all --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!modattr --allgm --strength|5",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!delattr --charid abc123 --strength",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!resetattr --name John --strength",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ }
+ ];
+
+ // Act & Assert
+ expect(parser.parse(inputs[0]).command).toBe(Commands.SET_ATTR);
+ expect(parser.parse(inputs[0]).flags[0].name).toBe(Flags.ALL);
+
+ expect(parser.parse(inputs[1]).command).toBe(Commands.MOD_ATTR);
+ expect(parser.parse(inputs[1]).flags[0].name).toBe(Flags.ALL_GM);
+
+ expect(parser.parse(inputs[2]).command).toBe(Commands.DEL_ATTR);
+ expect(parser.parse(inputs[2]).flags[0].name).toBe(Flags.CHAR_ID);
+ expect(parser.parse(inputs[2]).flags[0].value).toBe("abc123");
+
+ expect(parser.parse(inputs[3]).command).toBe(Commands.RESET_ATTR);
+ expect(parser.parse(inputs[3]).flags[0].name).toBe(Flags.CHAR_NAME);
+ expect(parser.parse(inputs[3]).flags[0].value).toBe("John");
+ });
+ });
+
+ describe("command modifier flags", () => {
+ it("should correctly parse the --silent flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --silent --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --mute flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --mute --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.MUTE,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --replace flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --replace --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.REPLACE,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --nocreate flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --nocreate --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.NO_CREATE,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --mod flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --mod --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.MOD,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --modb flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --modb --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.MOD_B,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --reset flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --reset --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.RESET,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --evaluate flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --evaluate --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.EVAL,
+ value: ""
+ });
+ });
+
+ it("should handle multiple command modifier flags", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --silent --nocreate --mod --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(4); // name, silent, nocreate, mod
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.NO_CREATE,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.MOD,
+ value: ""
+ });
+ });
+
+ it("should correctly parse flags with the delete command", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!delattr --name John --silent --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.DEL_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ });
+
+ it("should correctly handle the --replace flag with special characters in attributes", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --replace --strength|@{strength+5}",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.REPLACE,
+ value: ""
+ });
+ });
+
+ it("should correctly parse command shortcuts that imply flags", () => {
+ // Arrange
+ const commands: Roll20ChatMessage[] = [
+ {
+ content: "!modattr --name John --strength|5|10",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!modbattr --name John --strength|5|",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ },
+ {
+ content: "!resetattr --name John --strength|5|",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ }
+ ];
+
+ // Act & Assert
+ const modResult = parser.parse(commands[0]);
+ expect(modResult.commandType).toBe(CommandType.API);
+ expect(modResult.command).toBe(Commands.MOD_ATTR);
+
+ const modbResult = parser.parse(commands[1]);
+ expect(modbResult.commandType).toBe(CommandType.API);
+ expect(modbResult.command).toBe(Commands.MOD_B_ATTR);
+
+ const resetResult = parser.parse(commands[2]);
+ expect(resetResult.commandType).toBe(CommandType.API);
+ expect(resetResult.command).toBe(Commands.RESET_ATTR);
+ });
+
+ it("should parse evaluate expressions correctly", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --evaluate --strength|10+5|20-2",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.EVAL,
+ value: ""
+ });
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("10+5");
+ expect(result.attributes.strength.max).toBe("20-2");
+ });
+
+ it("should correctly parse the --silent and --mute flags together", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --silent --mute --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.MUTE,
+ value: ""
+ });
+ });
+
+ it("should parse flags with and without values properly", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John Smith --silent --fb-header Welcome to the game --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(3);
+ expect(result.flags).toContainEqual({
+ name: Flags.CHAR_NAME,
+ value: "John Smith"
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_HEADER,
+ value: "Welcome to the game"
+ });
+ });
+
+ it("should correctly identify command flags in inline commands", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "Setting attributes !setattr --name John --silent --mod --strength|15|20!!!",
+ playerid: "player123",
+ who: "John",
+ type: "general",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.INLINE);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(3);
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.MOD,
+ value: ""
+ });
+ });
+ });
+
+ describe("feedback flags", () => {
+ it("should correctly parse the --fb-public flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --fb-public --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_PUBLIC,
+ value: ""
+ });
+ });
+
+ it("should correctly parse the --fb-from flag with a value", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --fb-from Dungeon Master --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_FROM,
+ value: "Dungeon Master"
+ });
+ });
+
+ it("should correctly parse the --fb-header flag with a value", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --fb-header Character Update --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_HEADER,
+ value: "Character Update"
+ });
+ });
+
+ it("should correctly parse the --fb-content flag with a value", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --fb-content Your character has been updated with new stats! --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_CONTENT,
+ value: "Your character has been updated with new stats!"
+ });
+ });
+
+ it("should handle multiple feedback flags together", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --fb-public --fb-from GM --fb-header Update --fb-content New stats! --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(5); // name + 4 feedback flags
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_PUBLIC,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_FROM,
+ value: "GM"
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_HEADER,
+ value: "Update"
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_CONTENT,
+ value: "New stats!"
+ });
+ });
+
+ it("should handle feedback flags with targeting flags and modifier flags", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --sel --silent --fb-public --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toHaveLength(3);
+ expect(result.flags).toContainEqual({
+ name: Flags.SELECTED,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_PUBLIC,
+ value: ""
+ });
+ });
+ });
+
+ describe("attribute parsing edge cases", () => {
+ it("should strip single quotes surrounding value or max and trailing spaces", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|'15'|'20' --dexterity|'value with spaces '|'max with spaces '",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(Object.keys(result.attributes)).toHaveLength(2);
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+ expect(result.attributes).toHaveProperty("dexterity");
+ expect(result.attributes.dexterity.value).toBe("value with spaces ");
+ expect(result.attributes.dexterity.max).toBe("max with spaces ");
+ });
+
+ it("should preserve spaces at the end when the whole expression is enclosed in quotes", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --description|'This text has spaces at the end '",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("description");
+ expect(result.attributes.description.value).toBe("This text has spaces at the end ");
+ });
+
+ it("should handle escaped pipe and hash characters in values", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --formula|10\\|20\\#30|max",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("formula");
+ expect(result.attributes.formula.value).toBe("10|20#30");
+ expect(result.attributes.formula.max).toBe("max");
+ });
+
+ it("should not change max value when using --name|value format", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|15",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBeUndefined();
+ });
+
+ it("should not change current value when using --name||max format", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength||20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBeUndefined();
+ expect(result.attributes.strength.max).toBe("20");
+ });
+
+ it("should handle empty attributes with --name| format", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBeUndefined();
+ expect(result.attributes.strength.max).toBeUndefined();
+ });
+
+ it("should handle empty attributes with just --name format", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBeUndefined();
+ expect(result.attributes.strength.max).toBeUndefined();
+ });
+
+ it("should ignore value and max for !delattr command", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!delattr --name John --strength|15|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.command).toBe(Commands.DEL_ATTR);
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+ });
+
+ it("should handle empty current and set max with --name|''|max format", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|''|20",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBeUndefined();
+ expect(result.attributes.strength.max).toBe("20");
+ });
+
+ it("should handle repeating attributes by ID", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --repeating_skills_-ABC123_skillname|Acrobatics",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("repeating_skills_-ABC123_skillname");
+ expect(result.attributes["repeating_skills_-ABC123_skillname"].value).toBe("Acrobatics");
+ });
+
+ it("should handle repeating attributes by row index", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --repeating_skills_$0_skillname|Acrobatics",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("repeating_skills_$0_skillname");
+ expect(result.attributes["repeating_skills_$0_skillname"].value).toBe("Acrobatics");
+ });
+
+ it("should handle creating new repeating rows", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --repeating_skills_-CREATE_skillname|Acrobatics",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("repeating_skills_-CREATE_skillname");
+ expect(result.attributes["repeating_skills_-CREATE_skillname"].value).toBe("Acrobatics");
+ });
+
+ it("should handle deleting repeating rows by ID", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!delattr --name John --repeating_skills_-ABC123",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.command).toBe(Commands.DEL_ATTR);
+ expect(result.attributes).toHaveProperty("repeating_skills_-ABC123");
+ });
+
+ it("should handle deleting repeating rows by row number", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!delattr --name John --repeating_skills_$0",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.command).toBe(Commands.DEL_ATTR);
+ expect(result.attributes).toHaveProperty("repeating_skills_$0");
+ });
+
+ it("should handle attribute references with %attribute_name% syntax", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --attr1|%attr2%|%attr2_max%",
+
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.attributes).toHaveProperty("attr1");
+ expect(result.attributes.attr1.value).toBe("%attr2%");
+ expect(result.attributes.attr1.max).toBe("%attr2_max%");
+ });
+
+ it("should handle multiple complex attribute formats in a single command", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|15|20 --dexterity|'18 ' --wisdom||16 --constitution|''|25 --repeating_skills_-CREATE_skillname|Acrobatics",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(Object.keys(result.attributes)).toHaveLength(5);
+
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+
+ expect(result.attributes).toHaveProperty("dexterity");
+ expect(result.attributes.dexterity.value).toBe("18 ");
+ expect(result.attributes.dexterity.max).toBeUndefined();
+
+ expect(result.attributes).toHaveProperty("wisdom");
+ expect(result.attributes.wisdom.value).toBeUndefined();
+ expect(result.attributes.wisdom.max).toBe("16");
+
+ expect(result.attributes).toHaveProperty("constitution");
+ expect(result.attributes.constitution.value).toBeUndefined();
+ expect(result.attributes.constitution.max).toBe("25");
+
+ expect(result.attributes).toHaveProperty("repeating_skills_-CREATE_skillname");
+ expect(result.attributes["repeating_skills_-CREATE_skillname"].value).toBe("Acrobatics");
+ });
+
+ it("should handle combinations of attribute and flag formats", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --silent --nocreate --evaluate --strength|15|20 --dexterity|'18 ' --wisdom||16",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.flags).toHaveLength(4); // name, silent, nocreate, evaluate
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("15");
+ expect(result.attributes.strength.max).toBe("20");
+
+ expect(result.attributes).toHaveProperty("dexterity");
+ expect(result.attributes.dexterity.value).toBe("18 ");
+
+ expect(result.attributes).toHaveProperty("wisdom");
+ expect(result.attributes.wisdom.value).toBeUndefined();
+ expect(result.attributes.wisdom.max).toBe("16");
+ });
+ });
+
+ describe("inline roll parsing", () => {
+ it("should parse simple inline rolls in attribute values", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --sel --food|$[[0]]",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "1d4",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 3
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("food");
+ expect(result.attributes.food.value).toBe("3");
+ expect(result.attributes.food.max).toBeUndefined();
+ });
+
+ it("should parse inline rolls in both value and max", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --strength|$[[0]]|$[[1]]",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "1d6+2",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 5
+ }
+ },
+ {
+ expression: "2d8",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 9
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("strength");
+ expect(result.attributes.strength.value).toBe("5");
+ expect(result.attributes.strength.max).toBe("9");
+ });
+
+ it("should parse multiple inline rolls within a single attribute value", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --damage|$[[0]]+$[[1]]+3",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "1d6",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 4
+ }
+ },
+ {
+ expression: "1d8",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 6
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("damage");
+ expect(result.attributes.damage.value).toBe("4+6+3");
+ expect(result.attributes.damage.max).toBeUndefined();
+ });
+
+ it("should parse inline rolls with the --evaluate flag", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --evaluate --damage|$[[0]]+$[[1]]+3",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "1d6",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 4
+ }
+ },
+ {
+ expression: "2d8",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 6
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.EVAL,
+ value: ""
+ });
+ expect(result.attributes).toHaveProperty("damage");
+ expect(result.attributes.damage.value).toBe("4+6+3");
+ expect(result.attributes.damage.max).toBeUndefined();
+ });
+
+ it("should parse complex templates with embedded inline roll commands", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "&{template:default} {{name=Cthulhu}} !modattr --silent --charid @{target|character_id} --sanity|-{{Sanity damage=$[[0]]}} --corruption|{{Corruption=Corruption increases by $[[1]]}}!!! {{description=Text}}",
+ playerid: "player123",
+ who: "John",
+ type: "general",
+ inlinerolls: [
+ {
+ expression: "2d10+2",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 15
+ }
+ },
+ {
+ expression: "1",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 1
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.INLINE);
+ expect(result.command).toBe(Commands.MOD_ATTR);
+ expect(result.flags).toHaveLength(2);
+ expect(result.flags).toContainEqual({
+ name: Flags.SILENT,
+ value: ""
+ });
+ expect(result.flags).toContainEqual({
+ name: Flags.CHAR_ID,
+ value: "@{target|character_id}"
+ });
+ expect(result.attributes).toHaveProperty("sanity");
+ expect(result.attributes.sanity.value).toBe("-15");
+ expect(result.attributes).toHaveProperty("corruption");
+ expect(result.attributes.corruption.value).toBe("1");
+ });
+
+ it("should handle nested inline rolls with mixed content", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --complex|Total: $[[0]] + Bonus: $[[1]] = $[[2]]",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "1d20",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 18
+ }
+ },
+ {
+ expression: "3d6",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 10
+ }
+ },
+ {
+ expression: "1d20+3d6",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 28
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("complex");
+ expect(result.attributes.complex.value).toBe("Total: 18 + Bonus: 10 = 28");
+ });
+
+ it("should handle inline rolls in repeating attributes", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --repeating_attacks_-CREATE_damage|$[[0]]+3",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "2d6",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 9
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("repeating_attacks_-CREATE_damage");
+ expect(result.attributes["repeating_attacks_-CREATE_damage"].value).toBe("9+3");
+ });
+
+ it("should handle inline rolls in combined with attribute references", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --formula|@{level}+$[[0]]",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "1d6",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 4
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.attributes).toHaveProperty("formula");
+ expect(result.attributes.formula.value).toBe("@{level}+4");
+ });
+
+ it("should parse inline rolls in feedback parameter values", () => {
+ // Arrange
+ const input: Roll20ChatMessage = {
+ content: "!setattr --name John --fb-header Action Result: $[[0]] --strength|15",
+ playerid: "player123",
+ who: "John",
+ type: "api",
+ inlinerolls: [
+ {
+ expression: "1d20",
+ // @ts-expect-error only partially typing the roll result
+ results: {
+ total: 17
+ }
+ }
+ ]
+ };
+
+ // Act
+ const result = parser.parse(input);
+
+ // Assert
+ expect(result.commandType).toBe(CommandType.API);
+ expect(result.command).toBe(Commands.SET_ATTR);
+ expect(result.flags).toContainEqual({
+ name: Flags.FB_HEADER,
+ value: "Action Result: 17"
+ });
+ });
+ });
+});
diff --git a/ChatSetAttr/build/tests/unit/Targets.test.ts b/ChatSetAttr/build/tests/unit/Targets.test.ts
new file mode 100644
index 0000000000..7390ef9812
--- /dev/null
+++ b/ChatSetAttr/build/tests/unit/Targets.test.ts
@@ -0,0 +1,354 @@
+import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"
+import {
+ TargetAllCharacters,
+ TargetAllGMCharacters,
+ TargetByName,
+ TargetByID,
+ TargetBySelection
+} from "../../src/classes/Targets";
+
+describe("TargetAllCharacters", () => {
+ let target: TargetAllCharacters;
+
+ beforeEach(() => {
+ // Set up test environment
+ setupTestEnvironment();
+
+ // Create a new instance of TargetAllCharacters for each test
+ target = new TargetAllCharacters();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ it("should return all characters when user is GM", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player1";
+ vi.mocked(playerIsGM).mockReturnValue(true);
+
+ const char1 = createObj("character", { id: "char1", name: "Character 1" });
+ const char2 = createObj("character", { id: "char2", name: "Character 2" });
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(0);
+ expect(result).toHaveLength(2);
+ expect(result).toContainEqual(char1);
+ expect(result).toContainEqual(char2);
+ });
+
+ it("should return error when user is not GM", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player2";
+ vi.mocked(playerIsGM).mockReturnValue(false);
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("You do not have permission to use the 'all' target");
+ expect(result).toHaveLength(0);
+ });
+
+ it("should return error when targets are provided", () => {
+ // Arrange
+ const targets: string[] = ["char1"];
+ const playerID = "player1";
+ vi.mocked(playerIsGM).mockReturnValue(true);
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("The 'all' target does not accept any targets");
+ expect(result).toHaveLength(0);
+ });
+});
+
+describe("TargetAllGMCharacters", () => {
+ let target: TargetAllGMCharacters;
+
+ beforeEach(() => {
+ // Set up test environment
+ setupTestEnvironment();
+
+ // Create a new instance of TargetAllGMCharacters for each test
+ target = new TargetAllGMCharacters();
+
+ // Set up mock state
+ state.ChatSetAttr = {
+ playersCanModify: false,
+ version: 3,
+ globalconfigCache: { lastsaved: 0 },
+ playersCanEvaluate: true,
+ useWorkers: false
+ };
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ it("should return all GM characters when user is GM", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player1";
+ vi.mocked(playerIsGM).mockReturnValue(true);
+
+ // Create GM character (empty controlledby field)
+ const gmChar = createObj("character", { id: "gmChar", name: "GM Character", controlledby: "" });
+
+ // Create player character (non-empty controlledby field)
+ const playerChar = createObj("character", { id: "playerChar", name: "Player Character", controlledby: "player2" });
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(0);
+ expect(result).toHaveLength(1);
+ expect(result).toContain(gmChar);
+ expect(result).not.toContain(playerChar);
+ });
+
+ it("should return all GM characters when player can modify settings", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player2";
+ vi.mocked(playerIsGM).mockReturnValue(false);
+ state.ChatSetAttr.playersCanModify = true;
+
+ // Create GM character (empty controlledby field)
+ const gmChar = createObj("character", { id: "gmChar", name: "GM Character", controlledby: "" });
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(0);
+ expect(result).toHaveLength(1);
+ expect(result).toContain(gmChar);
+ });
+
+ it("should return error when user is not GM and players cannot modify", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player2";
+ vi.mocked(playerIsGM).mockReturnValue(false);
+ state.ChatSetAttr.playersCanModify = false;
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("You do not have permission to use the 'allgm' target");
+ expect(result).toHaveLength(0);
+ });
+
+ it("should return error when targets are provided", () => {
+ // Arrange
+ const targets: string[] = ["char1"];
+ const playerID = "player1";
+ vi.mocked(playerIsGM).mockReturnValue(true);
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("The 'allgm' target does not accept any targets");
+ expect(result).toHaveLength(0);
+ });
+});
+
+describe("TargetByName", () => {
+ let target: TargetByName;
+
+ beforeEach(() => {
+ // Set up test environment
+ setupTestEnvironment();
+
+ // Create a new instance of TargetByName for each test
+ target = new TargetByName();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ it("should find characters by name and filter by permissions", () => {
+ // Arrange
+ const targets: string[] = ["Character 1", "Character 2", "Character 3"];
+ const playerID = "player1";
+ vi.mocked(playerIsGM).mockReturnValue(false);
+
+ const char1 = createObj("character", { id: "char1", name: "Character 1", controlledby: "player2" });
+ const char2 = createObj("character", { id: "char2", name: "Character 2", controlledby: "player1" });
+ const char3 = createObj("character", { id: "char3", name: "Character 3", controlledby: "player2" });
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(0);
+ expect(result).toHaveLength(1);
+ expect(result).toContain(char2);
+ expect(result).not.toContain(char1);
+ expect(result).not.toContain(char3);
+ });
+
+ it("should report errors for characters that don't exist", () => {
+ // Arrange
+ const targets: string[] = ["Character 1", "Nonexistent Character"];
+ const playerID = "player1";
+
+ createObj("character", { id: "char1", name: "Character 1" });
+
+ // Act
+ const [_, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("Character with name Nonexistent Character does not exist");
+ });
+
+ it("should return error when no targets are provided", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player1";
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("The 'name' target requires at least one target");
+ expect(result).toHaveLength(0);
+ });
+});
+
+describe("TargetByID", () => {
+ let target: TargetByID;
+
+ beforeEach(() => {
+ // Set up test environment
+ setupTestEnvironment();
+
+ // Create a new instance of TargetByID for each test
+ target = new TargetByID();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ it("should filter character IDs by permissions", () => {
+ // Arrange
+ const targets: string[] = ["char1", "char2", "char3", "char4"];
+ const playerID = "player1";
+ vi.mocked(playerIsGM).mockReturnValue(false);
+
+ // Create characters to match the test IDs
+ const char1 = createObj("character", { id: "char1", name: "Character 1", controlledby: "player2" });
+ const char2 = createObj("character", { id: "char2", name: "Character 2", controlledby: "player1" });
+ const char3 = createObj("character", { id: "char3", name: "Character 3", controlledby: "player2" });
+ const char4 = createObj("character", { id: "char4", name: "Character 4", controlledby: "player1" });
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(0);
+ expect(result).toHaveLength(2);
+ expect(result).toContain(char2);
+ expect(result).toContain(char4);
+ expect(result).not.toContain(char1);
+ expect(result).not.toContain(char3);
+ });
+
+ it("should return error when no targets are provided", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player1";
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("The 'id' target requires at least one target");
+ expect(result).toHaveLength(0);
+ });
+});
+
+describe("TargetBySelection", () => {
+ let target: TargetBySelection;
+
+ beforeEach(() => {
+ // Set up test environment
+ setupTestEnvironment();
+
+ // Create a new instance of TargetBySelection for each test
+ target = new TargetBySelection();
+ });
+
+ afterEach(() => {
+ // Clean up test environment
+ vi.clearAllMocks();
+ });
+
+ it("should filter selected token character IDs by permissions", () => {
+ // Arrange
+ const targets: string[] = ["token1", "token2", "token3", "token4"];
+ const playerID = "player1";
+
+ // Create tokens
+ createObj("graphic", { id: "token1", represents: "char1" });
+ createObj("graphic", { id: "token2", represents: "char2" });
+ createObj("graphic", { id: "token3", represents: "char3" });
+ createObj("graphic", { id: "token4", represents: "char4" });
+
+ // Create characters
+ const char1 = createObj("character", { id: "char1", name: "Character 1", controlledby: "player2" });
+ const char2 = createObj("character", { id: "char2", name: "Character 2", controlledby: "player1" });
+ const char3 = createObj("character", { id: "char3", name: "Character 3", controlledby: "player2" });
+ const char4 = createObj("character", { id: "char4", name: "Character 4", controlledby: "player1" });
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(0);
+ expect(result).toHaveLength(2);
+ expect(result).toContain(char2);
+ expect(result).toContain(char4);
+ expect(result).not.toContain(char1);
+ expect(result).not.toContain(char3);
+ });
+
+ it("should return error when no targets are provided", () => {
+ // Arrange
+ const targets: string[] = [];
+ const playerID = "player1";
+
+ // Act
+ const [result, response] = target.parse(targets, playerID);
+
+ // Assert
+ expect(response.errors).toHaveLength(1);
+ expect(response.errors[0]).toContain("The 'target' target requires at least one target");
+ expect(result).toHaveLength(0);
+ });
+});
diff --git a/ChatSetAttr/build/tests/unit/UUIDs.test.ts b/ChatSetAttr/build/tests/unit/UUIDs.test.ts
new file mode 100644
index 0000000000..574eee68a4
--- /dev/null
+++ b/ChatSetAttr/build/tests/unit/UUIDs.test.ts
@@ -0,0 +1,52 @@
+import { it, expect, describe } from "vitest"
+import { UUID } from "../../src/classes/UUIDs"
+
+describe("UUID", () => {
+ it("generates a UUID in the correct format", () => {
+ const uuid = UUID.generateUUID();
+
+ // UUID should be 20 characters long (8 for time + 12 for random/counter)
+ expect(uuid.length).toBe(20);
+
+ // UUID should match the pattern of allowed characters
+ expect(uuid).toMatch(/^-[-0-9A-Z_a-z]{19}$/);
+
+ // Example format: -Lq2RmvjiCI3kixkEAa5
+ // First character is typically a base64 char
+ expect("-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz").toContain(uuid[0]);
+ });
+
+ it("generates unique UUIDs", () => {
+ const uuidCount = 1000;
+ const uuids = new Set