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

`; + message += `

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 `
${value2 ? "ON" : "OFF"}${property}: ${description}
`; + } + } + function filterByPermission(playerID, characters) { + const errors = []; + const messages = []; + const validTargets = []; + for (const character of characters) { + const isGM = playerIsGM(playerID); + const ownedBy = character.get("controlledby"); + const ownedByArray = ownedBy.split(",").map((id) => id.trim()); + const isOwner = ownedByArray.includes(playerID); + const hasPermission = isOwner || isGM; + if (!hasPermission) { + continue; + } + validTargets.push(character); + } + return [validTargets, { messages, errors }]; + } + class TargetAllCharacters { + name = "all"; + description = "All characters in the game."; + parse(targets, playerID) { + const errors = []; + const messages = []; + const canUseAll = playerIsGM(playerID); + if (!canUseAll) { + errors.push("You do not have permission to use the 'all' target."); + return [[], { messages, errors }]; + } + if (targets.length > 0) { + errors.push("The 'all' target does not accept any targets."); + return [[], { messages, errors }]; + } + const allCharacters = findObjs({ + _type: "character" + }); + return [allCharacters, { messages, errors }]; + } + } + class TargetAllPlayerCharacters { + name = "allplayers"; + description = "All characters controlled by players."; + parse(targets, playerID) { + const errors = []; + const messages = []; + const canUseAll = playerIsGM(playerID) || state.ChatSetAttr.playersCanModify; + if (!canUseAll) { + errors.push("You do not have permission to use the 'allplayers' target."); + return [[], { messages, errors }]; + } + if (targets.length > 0) { + errors.push("The 'allplayers' target does not accept any targets."); + return [[], { messages, errors }]; + } + const allPlayerCharacters = findObjs({ + _type: "character" + }).filter((character) => { + const controlledBy = character.get("controlledby"); + return controlledBy && controlledBy !== "" && controlledBy !== "all"; + }); + return [allPlayerCharacters, { messages, errors }]; + } + } + class TargetAllGMCharacters { + name = "allgm"; + description = "All characters not controlled by any player."; + parse(targets, playerID) { + const errors = []; + const messages = []; + const canUseAll = playerIsGM(playerID) || state.ChatSetAttr.playersCanModify; + if (!canUseAll) { + errors.push("You do not have permission to use the 'allgm' target."); + return [[], { messages, errors }]; + } + if (targets.length > 0) { + errors.push("The 'allgm' target does not accept any targets."); + return [[], { messages, errors }]; + } + const allGmCharacters = findObjs({ + _type: "character" + }).filter((character) => { + const controlledBy = character.get("controlledby"); + return controlledBy === "" || controlledBy === "all"; + }); + return [allGmCharacters, { messages, errors }]; + } + } + class TargetByName { + name = "name"; + description = "Target specific character names."; + parse(targets, playerID) { + const errors = []; + const messages = []; + if (targets.length === 0) { + errors.push("The 'name' target requires at least one target."); + return [[], { messages, errors }]; + } + const targetsByName = targets.map((target) => { + const character = findObjs({ + _type: "character", + name: target + })[0]; + if (!character) { + errors.push(`Character with name ${target} does not exist.`); + return null; + } + return character; + }).filter((target) => target !== null); + const [validTargets, response] = filterByPermission(playerID, targetsByName); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + return [validTargets, { messages, errors }]; + } + } + class TargetByID { + name = "id"; + description = "Target specific character IDs."; + parse(targets, playerID) { + const errors = []; + const messages = []; + if (targets.length === 0) { + errors.push("The 'id' target requires at least one target."); + return [[], { messages, errors }]; + } + const targetsByID = targets.map((target) => { + const character = getObj("character", target); + if (!character) { + errors.push(`Character with ID ${target} does not exist.`); + return null; + } + return character; + }).filter((target) => target !== null); + const [validTargets, response] = filterByPermission(playerID, targetsByID); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + if (validTargets.length === 0 && targets.length > 0) { + errors.push("No valid targets found with the provided IDs."); + } + return [validTargets, { messages, errors }]; + } + } + class TargetBySelection { + name = "target"; + description = "Target characters by selected tokens."; + parse(targets, playerID) { + const errors = []; + const messages = []; + if (targets.length === 0) { + errors.push("The 'target' target requires at least one target."); + return [[], { messages, errors }]; + } + const targetsFromSelection = targets.map((target) => { + const graphic = getObj("graphic", target); + if (!graphic) { + errors.push(`Token with ID ${target} does not exist.`); + return null; + } + const represents = graphic.get("represents"); + if (!represents) { + errors.push(`Token with ID ${target} does not represent a character.`); + return null; + } + const character = getObj("character", represents); + if (!character) { + errors.push(`Character with ID ${represents} does not exist.`); + return null; + } + return character; + }).filter((target) => target !== null); + const [validTargets, permissionResponse] = filterByPermission(playerID, targetsFromSelection); + messages.push(...permissionResponse.messages ?? []); + errors.push(...permissionResponse.errors ?? []); + return [validTargets, { messages, errors }]; + } + } + class ChatOutput { + header; + content; + from; + playerID; + type; + whisper; + chatStyle = createStyle({ + border: `1px solid #ccc`, + borderRadius: `5px`, + padding: `5px`, + backgroundColor: `#f9f9f9` + }); + headerStyle = createStyle({ + fontSize: `1.2em`, + fontWeight: `bold`, + marginBottom: `5px` + }); + errorStyle = createStyle({ + border: `1px solid #f00`, + borderRadius: `5px`, + padding: `5px`, + backgroundColor: `#f9f9f9` + }); + errorHeaderStyle = createStyle({ + color: `#f00`, + fontWeight: `bold`, + fontSize: `1.2em` + }); + constructor({ + playerID = "GM", + header = "", + content = "", + from = "ChatOutput", + type = "info", + whisper = false + } = {}) { + this.playerID = playerID; + this.header = header; + this.content = content; + this.from = from; + this.type = type; + this.whisper = whisper; + } + send() { + const noarchive = this.type === "feedback" ? false : true; + let output = ``; + output += this.createWhisper(); + output += this.createWrapper(); + output += this.createHeader(); + output += this.createContent(); + output += this.closeWrapper(); + sendChat(this.from, output, void 0, { noarchive }); + } + createWhisper() { + if (this.whisper === false) { + return ``; + } + if (this.playerID === "GM") { + return `/w GM `; + } + const player = getObj("player", this.playerID); + if (!player) { + return `/w GM `; + } + const playerName = player.get("_displayname"); + if (!playerName) { + return `/w GM `; + } + return `/w "${playerName}" `; + } + createWrapper() { + const style = this.type === "error" ? this.errorStyle : this.chatStyle; + return `
`; + } + createHeader() { + if (!this.header) { + return ``; + } + const style = this.type === "error" ? this.errorHeaderStyle : this.headerStyle; + return `

${this.header}

`; + } + createContent() { + if (!this.content) { + return ``; + } + if (this.content.startsWith("<")) { + return this.content; + } + return `

${this.content}

`; + } + closeWrapper() { + return `
`; + } + } + class TimerManager { + static timers = /* @__PURE__ */ new Map(); + static start(id, duration, callback) { + if (this.timers.has(id)) { + this.stop(id); + } + const timer = setTimeout(() => { + callback(); + this.timers.delete(id); + }, duration); + this.timers.set(id, timer); + } + static stop(id) { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + } + static isRunning(id) { + return this.timers.has(id); + } + } + const VERSION = "1.11"; + const SCHEMA_VERSION = 4; + const SCRIPT_STRINGS = { + CAN_MODIFY: "Players can modify all characters", + CAN_EVAL: "Players can use --evaluate", + USE_WORKERS: "Trigger sheet workers when setting attributes" + }; + class ChatSetAttr { + InputParser; + errors = []; + messages = []; + constructor() { + this.InputParser = new InputParser(); + this.registerEventHandlers(); + } + async handleChatMessage(msg) { + const { + commandType, + command, + flags, + attributes + } = this.InputParser.parse(msg); + if (commandType === CommandType.NONE || !command) { + return; + } + const actualCommand = this.overrideCommandFromOptions(flags, command); + const commandHandler = this.getCommandHandler(actualCommand); + if (!commandHandler) { + this.errors.push(`Command ${actualCommand} not found.`); + this.sendMessages(); + return; + } + const targets = this.getTargets(msg, flags); + if (targets.length === 0 && actualCommand !== Commands.SET_ATTR_CONFIG) { + this.errors.push(`No valid targets found for command ${actualCommand}.`); + this.sendMessages(); + return; + } + TimerManager.start("chatsetattr", 8e3, this.sendDelayMessage); + const response = await commandHandler.execute(flags, targets, attributes, msg); + TimerManager.stop("chatsetattr"); + const feedback = this.extractFeedback(flags); + this.messages.push(...response.messages); + this.errors.push(...response.errors); + const isSilent = flags.some((flag) => flag.name === Flags.SILENT); + if (isSilent) { + this.messages = []; + } + const isMuted = flags.some((flag) => flag.name === Flags.MUTE); + if (isMuted) { + this.messages = []; + this.errors = []; + } + if (response.errors.length > 0 || response.messages.length > 0) { + this.sendMessages(feedback); + return; + } + } + overrideCommandFromOptions(flags, command) { + const commandFlags = [Flags.MOD, Flags.MOD, Flags.MOD_B, Flags.RESET, Flags.DEL]; + const commandOptions = flags.filter((flag) => commandFlags.includes(flag.name)).map((flag) => flag.name); + const commandOverride = commandOptions[0]; + switch (commandOverride) { + case Flags.MOD: + return Commands.MOD_ATTR; + case Flags.MOD_B: + return Commands.MOD_B_ATTR; + case Flags.RESET: + return Commands.RESET_ATTR; + case Flags.DEL: + return Commands.DEL_ATTR; + default: + return command; + } + } + getCommandHandler(command) { + switch (command) { + case Commands.SET_ATTR: + return new SetAttrCommand(); + case Commands.MOD_ATTR: + return new ModAttrCommand(); + case Commands.MOD_B_ATTR: + return new ModBAttrCommand(); + case Commands.RESET_ATTR: + return new ResetAttrCommand(); + case Commands.DEL_ATTR: + return new DelAttrCommand(); + case Commands.SET_ATTR_CONFIG: + return new ConfigCommand(); + default: + throw new Error(`Command ${command} not found.`); + } + } + getTargets(msg, flags) { + const target = this.targetFromOptions(flags); + log(`[ChatSetAttr] Target strategy: ${target}`); + if (!target) { + return []; + } + const targetStrategy = this.getTargetStrategy(target); + log(`[ChatSetAttr] Target message: ${msg.selected}`); + const targets = this.getTargetsFromOptions(target, flags, msg.selected); + log(`[ChatSetAttr] Targets: ${targets.join(", ")}`); + const [validTargets, { messages, errors }] = targetStrategy.parse(targets, msg.playerid); + this.messages.push(...messages); + this.errors.push(...errors); + return validTargets; + } + getTargetsFromOptions(target, flags, selected) { + switch (target) { + case "all": + return []; + case "allgm": + return []; + case "allplayers": + return []; + case "name": + const nameFlag = flags.find((flag) => flag.name === Flags.CHAR_NAME); + if (!(nameFlag == null ? void 0 : nameFlag.value)) { + this.errors.push(`Target 'name' requires a name flag.`); + return []; + } + const names = nameFlag.value.split(",").map((name) => name.trim()); + return names; + case "charid": + const idFlag = flags.find((flag) => flag.name === Flags.CHAR_ID); + if (!(idFlag == null ? void 0 : idFlag.value)) { + this.errors.push(`Target 'charid' requires an ID flag.`); + return []; + } + const ids = idFlag.value.split(",").map((id) => id.trim()); + return ids; + case "sel": + if (!selected || selected.length === 0) { + this.errors.push(`Target 'sel' requires selected tokens.`); + return []; + } + const selectedIDs = this.convertObjectsToIDs(selected); + return selectedIDs; + default: + this.errors.push(`Target strategy ${target} not found.`); + return []; + } + } + convertObjectsToIDs(objects) { + if (!objects) return []; + const ids = objects.map((object) => object._id); + return ids; + } + getTargetStrategy(target) { + switch (target) { + case "all": + return new TargetAllCharacters(); + case "allgm": + return new TargetAllGMCharacters(); + case "allplayers": + return new TargetAllPlayerCharacters(); + case "name": + return new TargetByName(); + case "charid": + return new TargetByID(); + case "sel": + return new TargetBySelection(); + default: + throw new Error(`Target strategy ${target} not found.`); + } + } + targetFromOptions(flags) { + const targetFlags = [Flags.ALL, Flags.ALL_GM, Flags.ALL_PLAYERS, Flags.CHAR_ID, Flags.CHAR_NAME, Flags.SELECTED]; + const targetOptions = flags.filter((flag) => targetFlags.includes(flag.name)).map((flag) => flag.name); + const targetOverride = targetOptions[0]; + return targetOverride || false; + } + sendMessages(feedback) { + const sendErrors = this.errors.length > 0; + const from = (feedback == null ? void 0 : feedback.sender) || "ChatSetAttr"; + const whisper = (feedback == null ? void 0 : feedback.whisper) ?? true; + if (sendErrors) { + const header2 = "ChatSetAttr Error"; + const content2 = this.errors.map((error2) => error2.startsWith("<") ? error2 : `

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

" + - `

${errors.join("
")}

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

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

!setattr-config --option
" + - "

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

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

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

" + - "

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

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

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

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

"; - sendChatMessage(output, opts["fb-from"]); - }, - handleCommand = (content, playerid, selected, pre) => { - // Parsing input - let charIDList = [], - errors = []; - const hasValue = ["charid", "name", "fb-header", "fb-content", "fb-from"], - optsArray = ["all", "allgm", "charid", "name", "allplayers", "sel", "deletemode", - "replace", "nocreate", "mod", "modb", "evaluate", "silent", "reset", "mute", - "fb-header", "fb-content", "fb-from", "fb-public" - ], - opts = parseOpts(content, hasValue), - isGM = playerid === "API" || playerIsGM(playerid), - whisper = getWhisperPrefix(playerid); - opts.mod = opts.mod || (pre === "mod"); - opts.modb = opts.modb || (pre === "modb"); - opts.reset = opts.reset || (pre === "reset"); - opts.silent = opts.silent || opts.mute; - opts.deletemode = (pre === "del"); - // Sanitise feedback - if ("fb-from" in opts) opts["fb-from"] = String(opts["fb-from"]); - // Parse desired attribute values - const [setting, rData] = parseAttributes(Object.keys(_.omit(opts, optsArray)), opts, errors); - // Fill in header info - if ("fb-header" in opts) { - opts["fb-header"] = Object.entries(setting).reduce((m, [n, v], k) => { - return m.replace(`_NAME${k}_`, n) - .replace(`_TCUR${k}_`, htmlReplace(v.current || "")) - .replace(`_TMAX${k}_`, htmlReplace(v.max || "")); - }, String(opts["fb-header"])).replace(/_(?:TCUR|TMAX|NAME)\d*_/g, ""); - } - if (opts.evaluate && !isGM && !state.ChatSetAttr.playersCanEvaluate) { - if (!opts.mute) handleErrors(whisper, ["The --evaluate option is only available to the GM."]); - return; - } - // Get list of character IDs - if (opts.all && isGM) { - charIDList = findObjs({ - _type: "character" - }).map(c => c.id); - } else if (opts.allgm && isGM) { - charIDList = findObjs({ - _type: "character" - }).filter(c => c.get("controlledby") === "") - .map(c => c.id); - } else if (opts.allplayers && isGM) { - charIDList = findObjs({ - _type: "character" - }).filter(c => c.get("controlledby") !== "") - .map(c => c.id); - } else { - if (opts.charid) charIDList.push(...opts.charid.split(/\s*,\s*/)); - if (opts.name) charIDList.push(...getIDsFromNames(opts.name, errors)); - if (opts.sel) charIDList.push(...getIDsFromTokens(selected)); - charIDList = checkPermissions([...new Set(charIDList)], errors, playerid, isGM); - } - if (charIDList.length === 0) { - errors.push("No target characters. You need to supply one of --all, --allgm, --sel," + - " --allplayers, --charid, or --name."); - } - if (Object.keys(setting).length === 0) { - errors.push("No attributes supplied."); - } - // Get attributes - if (!opts.mute) handleErrors(whisper, errors); - // Set or delete attributes - if (charIDList.length > 0 && Object.keys(setting).length > 0) { - if (opts.deletemode) { - delayedDeleteAttributes(whisper, charIDList, setting, errors, rData, opts); - } else { - delayedGetAndSetAttributes(whisper, charIDList, setting, errors, rData, opts); - } - } - }, - handleInlineCommand = (msg) => { - const command = msg.content.match(/!(set|mod|modb)attr .*?!!!/); - - if (command) { - const mode = command[1], - newMsgContent = command[0].slice(0, -3).replace(/{{[^}[\]]+\$\[\[(\d+)\]\].*?}}/g, (_, number) => { - return `$[[${number}]]`; - }); - const newMsg = { - content: newMsgContent, - inlinerolls: msg.inlinerolls, - }; - handleCommand( - processInlinerolls(newMsg), - msg.playerid, - msg.selected, - mode - ); - } - }, - // Main function, called after chat message input - handleInput = function (msg) { - if (msg.type !== "api") handleInlineCommand(msg); - else { - const mode = msg.content.match(/^!(reset|set|del|mod|modb)attr\b(?:-|\s|$)(config)?/); - - if (mode && mode[2]) { - if (playerIsGM(msg.playerid)) { - const whisper = getWhisperPrefix(msg.playerid), - opts = parseOpts(msg.content, []); - if (opts["players-can-modify"]) { - state.ChatSetAttr.playersCanModify = !state.ChatSetAttr.playersCanModify; - } - if (opts["players-can-evaluate"]) { - state.ChatSetAttr.playersCanEvaluate = !state.ChatSetAttr.playersCanEvaluate; - } - if (opts["use-workers"]) { - state.ChatSetAttr.useWorkers = !state.ChatSetAttr.useWorkers; - } - showConfig(whisper); - } - } else if (mode) { - handleCommand( - processInlinerolls(msg), - msg.playerid, - msg.selected, - mode[1] - ); - } - } - return; - }, - notifyObservers = function(event, obj, prev) { - observers[event].forEach(observer => observer(obj, prev)); - }, - registerObserver = function (event, observer) { - if(observer && _.isFunction(observer) && observers.hasOwnProperty(event)) { - observers[event].push(observer); - } else { - log("ChatSetAttr event registration unsuccessful. Please check the documentation."); - } - }, - registerEventHandlers = function () { - on("chat:message", handleInput); - }; - return { - checkInstall, - registerObserver, - registerEventHandlers - }; -}()); - -on("ready", function () { - "use strict"; - ChatSetAttr.checkInstall(); - ChatSetAttr.registerEventHandlers(); -}); \ No newline at end of file +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)) { + log(`event publish unsuccessful: ${event} - no subscribers`); + 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.id, 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(`updateAttribute: Attribute ${name} does not exist for character with ID ${character == null ? void 0 : character.id}.`); + return [{ messages, errors }]; + } + const oldAttr = JSON.parse(JSON.stringify(attr)); + await APIWrapper.setAttribute(character.id, name, value2, max); + messages.push(`Setting ${name} to ${value2} for character ${character == null ? void 0 : character.get("name")}.`); + 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(characterID, attr, value2, max) { + if (state.ChatSetAttr.useWorkers) { + await APIWrapper.setWithWorker(characterID, attr, value2, max); + return; + } + if (value2) { + const newValue = await setSheetItem(characterID, attr, value2, "current"); + log(`setAttribute: Setting ${attr} to ${JSON.stringify(newValue)} for character with ID ${characterID}.`); + } + if (max) { + const newMax = await setSheetItem(characterID, attr, max, "max"); + log(`setAttribute: Setting ${attr} max to ${JSON.stringify(newMax)} for character with ID ${characterID}.`); + } + } + 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(`setAttribute: ${key} does not exist for character with ID ${character == null ? void 0 : character.id}.`); + 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 attr = await APIWrapper.getAttr(character, attribute); + if (!attr) { + errors.push(`deleteAttributes: Attribute ${attribute} does not exist for character with ID ${character == null ? void 0 : character.id}.`); + continue; + } + const oldAttr = JSON.parse(JSON.stringify(attr)); + attr.remove(); + globalSubscribeManager.publish("destroy", oldAttr); + messages.push(`Attribute ${attribute} deleted for character with ID ${character == null ? void 0 : character.id}.`); + } + 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(`resetAttributes: Attribute ${attribute} does not exist for character with ID ${character == null ? void 0 : character.id}.`); + continue; + } + const max = attr.max; + if (!max) { + continue; + } + const oldAttr = JSON.parse(JSON.stringify(attr)); + APIWrapper.setAttribute(character.id, attribute, max); + globalSubscribeManager.publish("change", attr, oldAttr); + messages.push(`Attribute ${attribute} reset for character with ID ${character == null ? void 0 : character.id}.`); + } + return [{ messages, errors }]; + } + // #endregion Attributes + // #region Repeating Attributes + static extractRepeatingDetails(attributeName) { + const [, section, repeatingID, ...attributeParts] = attributeName.split("_"); + const attribute = attributeParts.join("_"); + if (!section || !repeatingID || !attribute) { + return null; + } + return { + section, + repeatingID, + attribute + }; + } + 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(input) { + 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); + 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; + } + } + 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 (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); + } + if (hasRepeating) { + deltaName = this.parseRepeating(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) { + this.errors.push(`Error evaluating expression ${value}`); + 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) { + this.errors.push(`Attribute ${name} has no value to modify.`); + return current; + } + 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, 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 || !attribute) { + 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) { + 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, (_, index) => { + const attr = entries[parseInt(index, 10)]; + return attr[0] ?? ""; + }).replace(/_TCUR(\d+)_/g, (_, index) => { + const attr = entries[parseInt(index, 10)]; + return attr[1].value ?? ""; + }).replace(/_TMAX(\d+)_/g, (_, index) => { + const attr = entries[parseInt(index, 10)]; + return attr[1].max ?? ""; + }).replace(/_CUR(\d+)_/g, (_, index) => { + const attr = finalEntries[parseInt(index, 10)]; + return attr[1].value ?? ""; + }).replace(/_MAX(\d+)_/g, (_, index) => { + const attr = finalEntries[parseInt(index, 10)]; + 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) { + const messageContent = processor.replacePlaceholders(content); + messages.push(messageContent); + } else { + messages.push(...createResponse.messages ?? []); + } + await asyncTimeout(20); + } + log(`SetAttrCommand executed with messages: ${JSON.stringify(messages)}`); + 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

`; + message += `

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 `
${property}: ${description}${value2 ? "ON" : "OFF"}
`; + } + } + function filterByPermission(playerID, characters) { + const errors = []; + const messages = []; + const validTargets = []; + for (const character of characters) { + const isGM = playerIsGM(playerID); + const ownedBy = character.get("controlledby"); + const ownedByArray = ownedBy.split(",").map((id) => id.trim()); + const isOwner = ownedByArray.includes(playerID); + const hasPermission = isOwner || isGM; + if (!hasPermission) { + continue; + } + validTargets.push(character); + } + return [validTargets, { messages, errors }]; + } + class TargetAllCharacters { + name = "all"; + description = "All characters in the game."; + parse(targets, playerID) { + const errors = []; + const messages = []; + const canUseAll = playerIsGM(playerID); + if (!canUseAll) { + errors.push("You do not have permission to use the 'all' target."); + return [[], { messages, errors }]; + } + if (targets.length > 0) { + errors.push("The 'all' target does not accept any targets."); + return [[], { messages, errors }]; + } + const allCharacters = findObjs({ + _type: "character" + }); + return [allCharacters, { messages, errors }]; + } + } + class TargetAllPlayerCharacters { + name = "allplayers"; + description = "All characters controlled by players."; + parse(targets, playerID) { + const errors = []; + const messages = []; + const canUseAll = playerIsGM(playerID) || state.ChatSetAttr.playersCanModify; + if (!canUseAll) { + errors.push("You do not have permission to use the 'allplayers' target."); + return [[], { messages, errors }]; + } + if (targets.length > 0) { + errors.push("The 'allplayers' target does not accept any targets."); + return [[], { messages, errors }]; + } + const allPlayerCharacters = findObjs({ + _type: "character" + }).filter((character) => { + const controlledBy = character.get("controlledby"); + return controlledBy && controlledBy !== "" && controlledBy !== "all"; + }); + return [allPlayerCharacters, { messages, errors }]; + } + } + class TargetAllGMCharacters { + name = "allgm"; + description = "All characters not controlled by any player."; + parse(targets, playerID) { + const errors = []; + const messages = []; + const canUseAll = playerIsGM(playerID) || state.ChatSetAttr.playersCanModify; + if (!canUseAll) { + errors.push("You do not have permission to use the 'allgm' target."); + return [[], { messages, errors }]; + } + if (targets.length > 0) { + errors.push("The 'allgm' target does not accept any targets."); + return [[], { messages, errors }]; + } + const allGmCharacters = findObjs({ + _type: "character" + }).filter((character) => { + const controlledBy = character.get("controlledby"); + return controlledBy === "" || controlledBy === "all"; + }); + return [allGmCharacters, { messages, errors }]; + } + } + class TargetByName { + name = "name"; + description = "Target specific character names."; + parse(targets, playerID) { + const errors = []; + const messages = []; + if (targets.length === 0) { + errors.push("The 'name' target requires at least one target."); + return [[], { messages, errors }]; + } + const targetsByName = targets.map((target) => { + const character = findObjs({ + _type: "character", + name: target + })[0]; + if (!character) { + errors.push(`Character with name ${target} does not exist.`); + return null; + } + return character; + }).filter((target) => target !== null); + const [validTargets, response] = filterByPermission(playerID, targetsByName); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + return [validTargets, { messages, errors }]; + } + } + class TargetByID { + name = "id"; + description = "Target specific character IDs."; + parse(targets, playerID) { + const errors = []; + const messages = []; + if (targets.length === 0) { + errors.push("The 'id' target requires at least one target."); + return [[], { messages, errors }]; + } + const targetsByID = targets.map((target) => { + const character = getObj("character", target); + if (!character) { + errors.push(`Character with ID ${target} does not exist.`); + return null; + } + return character; + }).filter((target) => target !== null); + const [validTargets, response] = filterByPermission(playerID, targetsByID); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + return [validTargets, { messages, errors }]; + } + } + class TargetBySelection { + name = "target"; + description = "Target characters by selected tokens."; + parse(targets, playerID) { + const errors = []; + const messages = []; + if (targets.length === 0) { + errors.push("The 'target' target requires at least one target."); + return [[], { messages, errors }]; + } + const targetsFromSelection = targets.map((target) => { + const graphic = getObj("graphic", target); + if (!graphic) { + errors.push(`Token with ID ${target} does not exist.`); + return null; + } + const represents = graphic.get("represents"); + if (!represents) { + errors.push(`Token with ID ${target} does not represent a character.`); + return null; + } + const character = getObj("character", represents); + if (!character) { + errors.push(`Character with ID ${represents} does not exist.`); + return null; + } + return character; + }).filter((target) => target !== null); + const [validTargets, permissionResponse] = filterByPermission(playerID, targetsFromSelection); + messages.push(...permissionResponse.messages ?? []); + errors.push(...permissionResponse.errors ?? []); + return [validTargets, { messages, errors }]; + } + } + class ChatOutput { + header; + content; + from; + playerID; + type; + whisper; + chatStyle = createStyle({ + border: `1px solid #ccc`, + borderRadius: `5px`, + padding: `5px`, + backgroundColor: `#f9f9f9` + }); + headerStyle = createStyle({ + fontSize: `1.2em`, + fontWeight: `bold`, + marginBottom: `5px` + }); + errorStyle = createStyle({ + border: `1px solid #f00`, + borderRadius: `5px`, + padding: `5px`, + backgroundColor: `#f9f9f9` + }); + errorHeaderStyle = createStyle({ + color: `#f00`, + fontWeight: `bold`, + fontSize: `1.2em` + }); + constructor({ + playerID = "GM", + header = "", + content = "", + from = "ChatOutput", + type = "info", + whisper = false + } = {}) { + this.playerID = playerID; + this.header = header; + this.content = content; + this.from = from; + this.type = type; + this.whisper = whisper; + } + send() { + const noarchive = this.type === "feedback" ? false : true; + let output = ``; + output += this.createWhisper(); + output += this.createWrapper(); + output += this.createHeader(); + output += this.createContent(); + output += this.closeWrapper(); + log(`ChatOutput: ${output}`); + sendChat(this.from, output, void 0, { noarchive }); + } + createWhisper() { + if (!this.whisper) { + return ``; + } + if (this.playerID === "GM") { + return `/w GM `; + } + const player = getObj("player", this.playerID); + if (!player) { + return `/w GM `; + } + const playerName = player.get("_displayname"); + if (!playerName) { + return `/w GM `; + } + return `/w "${playerName}" `; + } + createWrapper() { + const style = this.type === "error" ? this.errorStyle : this.chatStyle; + return `
`; + } + createHeader() { + if (!this.header) { + return ``; + } + const style = this.type === "error" ? this.errorHeaderStyle : this.headerStyle; + return `

${this.header}

`; + } + createContent() { + if (!this.content) { + return ``; + } + if (this.content.startsWith("<")) { + return this.content; + } + return `

${this.content}

`; + } + closeWrapper() { + return `
`; + } + } + class TimerManager { + static timers = /* @__PURE__ */ new Map(); + static start(id, duration, callback) { + if (this.timers.has(id)) { + this.stop(id); + } + const timer = setTimeout(() => { + callback(); + this.timers.delete(id); + }, duration); + this.timers.set(id, timer); + } + static stop(id) { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + } + static isRunning(id) { + return this.timers.has(id); + } + } + const VERSION = "1.11"; + const SCHEMA_VERSION = 4; + const SCRIPT_STRINGS = { + CAN_MODIFY: "Players can modify all characters", + CAN_EVAL: "Players can use --evaluate", + USE_WORKERS: "Trigger sheet workers when setting attributes" + }; + class ChatSetAttr { + InputParser; + errors = []; + messages = []; + constructor() { + this.InputParser = new InputParser(); + this.registerEventHandlers(); + } + async handleChatMessage(msg) { + const { + commandType, + command, + flags, + attributes + } = this.InputParser.parse(msg.content); + if (commandType === CommandType.NONE || !command) { + return; + } + const actualCommand = this.overrideCommandFromOptions(flags, command); + const commandHandler = this.getCommandHandler(actualCommand); + if (!commandHandler) { + this.errors.push(`Command ${actualCommand} not found.`); + this.sendMessages(); + return; + } + const targets = this.getTargets(msg, flags); + TimerManager.start("chatsetattr", 8e3, this.sendDelayMessage); + const response = await commandHandler.execute(flags, targets, attributes, msg); + TimerManager.stop("chatsetattr"); + const feedback = this.extractFeedback(flags); + this.messages.push(...response.messages); + this.errors.push(...response.errors); + const isSilent = flags.some((flag) => flag.name === Flags.SILENT); + if (isSilent) { + this.messages = []; + } + const isMuted = flags.some((flag) => flag.name === Flags.MUTE); + if (isMuted) { + this.errors = []; + } + if (response.errors.length > 0 || response.messages.length > 0) { + this.sendMessages(feedback); + return; + } + } + overrideCommandFromOptions(flags, command) { + const commandFlags = [Flags.MOD, Flags.MOD, Flags.MOD_B, Flags.RESET, Flags.DEL]; + const commandOptions = flags.filter((flag) => commandFlags.includes(flag.name)).map((flag) => flag.name); + const commandOverride = commandOptions[0]; + switch (commandOverride) { + case Flags.MOD: + return Commands.MOD_ATTR; + case Flags.MOD_B: + return Commands.MOD_B_ATTR; + case Flags.RESET: + return Commands.RESET_ATTR; + case Flags.DEL: + return Commands.DEL_ATTR; + default: + return command; + } + } + getCommandHandler(command) { + switch (command) { + case Commands.SET_ATTR: + return new SetAttrCommand(); + case Commands.MOD_ATTR: + return new ModAttrCommand(); + case Commands.MOD_B_ATTR: + return new ModBAttrCommand(); + case Commands.RESET_ATTR: + return new ResetAttrCommand(); + case Commands.DEL_ATTR: + return new DelAttrCommand(); + case Commands.SET_ATTR_CONFIG: + return new ConfigCommand(); + default: + throw new Error(`Command ${command} not found.`); + } + } + getTargets(msg, flags) { + const target = this.targetFromOptions(flags); + if (!target) { + return []; + } + const targetStrategy = this.getTargetStrategy(target); + const targets = this.getTargetsFromOptions(target, flags, msg.selected); + const [validTargets, { messages, errors }] = targetStrategy.parse(targets, msg.playerid); + this.messages.push(...messages); + this.errors.push(...errors); + return validTargets; + } + getTargetsFromOptions(target, flags, selected) { + switch (target) { + case "all": + return []; + case "allgm": + return []; + case "allplayers": + return []; + case "name": + const nameFlag = flags.find((flag) => flag.name === Flags.CHAR_NAME); + if (!(nameFlag == null ? void 0 : nameFlag.value)) { + this.errors.push(`Target 'name' requires a name flag.`); + return []; + } + const names = nameFlag.value.split(",").map((name) => name.trim()); + return names; + case "charid": + const idFlag = flags.find((flag) => flag.name === Flags.CHAR_ID); + if (!(idFlag == null ? void 0 : idFlag.value)) { + this.errors.push(`Target 'charid' requires an ID flag.`); + return []; + } + const ids = idFlag.value.split(",").map((id) => id.trim()); + return ids; + case "sel": + if (!selected || selected.length === 0) { + this.errors.push(`Target 'sel' requires selected tokens.`); + return []; + } + const selectedIDs = this.convertObjectsToIDs(selected); + return selectedIDs; + default: + this.errors.push(`Target strategy ${target} not found.`); + return []; + } + } + convertObjectsToIDs(objects) { + if (!objects) return []; + const ids = objects.map((object) => object._id); + return ids; + } + getTargetStrategy(target) { + switch (target) { + case "all": + return new TargetAllCharacters(); + case "allgm": + return new TargetAllGMCharacters(); + case "allplayers": + return new TargetAllPlayerCharacters(); + case "name": + return new TargetByName(); + case "charid": + return new TargetByID(); + case "sel": + return new TargetBySelection(); + default: + throw new Error(`Target strategy ${target} not found.`); + } + } + targetFromOptions(flags) { + const targetFlags = [Flags.ALL, Flags.ALL_GM, Flags.ALL_PLAYERS, Flags.CHAR_ID, Flags.CHAR_NAME, Flags.SELECTED]; + const targetOptions = flags.filter((flag) => targetFlags.includes(flag.name)).map((flag) => flag.name); + const targetOverride = targetOptions[0]; + return targetOverride || false; + } + sendMessages(feedback) { + const sendErrors = this.errors.length > 0; + const from = (feedback == null ? void 0 : feedback.sender) || "ChatSetAttr"; + const whisper = (feedback == null ? void 0 : feedback.whisper) || true; + if (sendErrors) { + const header2 = "ChatSetAttr Error"; + const content2 = this.errors.map((error2) => error2.startsWith("<") ? error2 : `

${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 \** will modify the name that appears as the sender in chat messages sent by the script. If not specified, this defaults to "ChatSetAttr". -* **--fb-header \** will replace the title of the message sent by the script - normally, "Setting Attributes" or "Deleting Attributes" - with a custom string. -* **--fb-content \** will replace the feedback line for every character with a custom string. This will not work with **!delattr**. +**Example:** +``` +!delattr --sel --hp --xp +``` -You can use the following special character sequences in the values of both **--fb-header** and **--fb-content**. Here, **J** is an integer, starting from 0, and refers to the **J**-th attribute you are changing. They will be dynamically replaced as follows: +This removes the `hp` and `xp` attributes. -* \_NAME**J**\_: will insert the attribute name. -* \_TCUR**J**\_: will insert what you are changing the current value to (or changing by, if you're using **--mod** or **--modb**). -* \_TMAX**J**\_: will insert what you are changing the maximum value to (or changing by, if you're using **--mod** or **--modb**). +## Target Selection -In addition, there are extra insertion sequence that only make sense in the value of **--fb-content**: +One of these options must be specified to determine which characters will be affected: -* \_CHARNAME\_: will insert the character name. -* \_CUR**J**\_: will insert the final current value of the attribute, for this character. -* \_MAX**J**\_: will insert the final maximum value of the attribute, for this character. +### --all + +Affects all characters in the campaign. **GM only** and should be used with caution, especially in large campaigns. + +**Example:** +``` +!setattr --all --hp|15 +``` + +### --allgm + +Affects all characters without player controllers (typically NPCs). **GM only**. + +**Example:** +``` +!setattr --allgm --xp|150 +``` + +### --allplayers + +Affects all characters with player controllers (typically PCs). + +**Example:** +``` +!setattr --allplayers --hp|15 +``` + +### --charid + +Affects characters with the specified character IDs. Non-GM players can only affect characters they control. + +**Example:** +``` +!setattr --charid --xp|150 +``` + +### --name + +Affects characters with the specified names. Non-GM players can only affect characters they control. + +**Example:** +``` +!setattr --name Gandalf, Frodo Baggins --party|"Fellowship of the Ring" +``` + +### --sel + +Affects characters represented by currently selected tokens. + +**Example:** +``` +!setattr --sel --hp|25 --xp|30 +``` ## Attribute Syntax -Attribute options will determine which attributes are set to which value (respectively deleted, in case of !delattr). The syntax for these options is **--name|value** or **--name|value|max**. Here, **name** is the name of the attribute (which is parsed case-insensitively), **value** is the value that the current value of the attribute should be set to, and **max** is the value that the maximum value of the attribute should be set to. Instead of the vertical line ('|'), you may also use '#' (for use inside roll queries, for example). +The syntax for specifying attributes is: +``` +--attributeName|currentValue|maxValue +``` + +* `attributeName` is the name of the attribute to modify +* `currentValue` is the value to set (optional for some commands) +* `maxValue` is the maximum value to set (optional) + +### Examples: + +1. Set current value only: + ``` + --strength|15 + ``` + +2. Set both current and maximum values: + ``` + --hp|27|35 + ``` + +3. Set only the maximum value (leave current unchanged): + ``` + --hp||50 + ``` + +4. Create empty attribute or set to empty: + ``` + --notes| + ``` + +5. Use `#` instead of `|` (useful in roll queries): + ``` + --strength#15 + ``` + +## Modifier Options + +These options change how attributes are processed: + +### --mod + +See `!modattr` command. + +### --modb + +See `!modbattr` command. + +### --reset + +See `!resetattr` command. + +### --nocreate + +Prevents creation of new attributes, only updates existing ones. + +**Example:** +``` +!setattr --sel --nocreate --perception|20 --xp|15 +``` + +This will only update `perception` or `xp` if it already exists. + +### --evaluate + +Evaluates JavaScript expressions in attribute values. **GM only by default**. + +**Example:** +``` +!setattr --sel --evaluate --hp|2 * 3 +``` + +This will set the `hp` attribute to 6. + +### --replace + +Replaces special characters to prevent Roll20 from evaluating them: +- < becomes [ +- > becomes ] +- ~ becomes - +- ; becomes ? +- \` becomes @ + +Also supports \lbrak, \rbrak, \n, \at, and \ques for [, ], newline, @, and ?. + +**Example:** +``` +!setattr --sel --replace --notes|"Roll <<1d6>> to succeed" +``` + +This stores "Roll [[1d6]] to succeed" without evaluating the roll. + +## Output Control Options + +These options control the feedback messages generated by the script: + +### --silent + +Suppresses normal output messages (error messages will still appear). + +**Example:** +``` +!setattr --sel --silent --stealth|20 +``` + +### --mute + +Suppresses all output messages, including errors. + +**Example:** +``` +!setattr --sel --mute --nocreate --new_value|42 +``` + +### --fb-public + +Sends output publicly to the chat instead of whispering to the command sender. + +**Example:** +``` +!setattr --sel --fb-public --hp|25|25 --status|"Healed" +``` + +### --fb-from \ + +Changes the name of the sender for output messages (default is "ChatSetAttr"). + +**Example:** +``` +!setattr --sel --fb-from "Healing Potion" --hp|25 +``` + +### --fb-header \ + +Customizes the header of the output message. + +**Example:** +``` +!setattr --sel --evaluate --fb-header "Combat Effects Applied" --status|"Poisoned" --hp|%hp%-5 +``` + +### --fb-content \ + +Customizes the content of the output message. + +**Example:** +``` +!setattr --sel --fb-content "Increasing Hitpoints" --hp|10 +``` + +### Special Placeholders + +For use in `--fb-header` and `--fb-content`: + +* `_NAMEJ_` - Name of the Jth attribute being changed +* `_TCURJ_` - Target current value of the Jth attribute +* `_TMAXJ_` - Target maximum value of the Jth attribute + +For use in `--fb-content` only: + +* `_CHARNAME_` - Name of the character +* `_CURJ_` - Final current value of the Jth attribute +* `_MAXJ_` - Final maximum value of the Jth attribute + +**Important:** The Jth index starts with 0 at the first item. + +**Example:** +``` +!setattr --sel --fb-header "Healing Effects" --fb-content "_CHARNAME_ healed by _CUR0_ hitpoints --hp|10 +``` + +## Inline Roll Integration + +ChatSetAttr can be used within roll templates or combined with inline rolls: + +### Within Roll Templates + +Place the command between roll template properties and end it with `!!!`: + +``` +&{template:default} {{name=Fireball Damage}} !setattr --name @{target|character_name} --silent --hp|-{{damage=[[8d6]]}}!!! {{effect=Fire damage}} +``` + +### Using Inline Rolls in Values + +Inline rolls can be used for attribute values: + +``` +!setattr --sel --hp|[[2d6+5]] +``` + +### Roll Queries + +Roll queries can determine attribute values: + +``` +!setattr --sel --hp|?{Set strength to what value?|100} +``` + +## Repeating Section Support + +ChatSetAttr supports working with repeating sections: + +### Creating New Repeating Items + +Use `-CREATE` to create a new row in a repeating section: + +``` +!setattr --sel --repeating_inventory_-CREATE_itemname|"Magic Sword" --repeating_inventory_-CREATE_itemweight|2 +``` + +### Modifying Existing Repeating Items + +Access by row ID: + +``` +!setattr --sel --repeating_inventory_-ID_itemname|"Enchanted Magic Sword" +``` + +Access by index (starts at 0): + +``` +!setattr --sel --repeating_inventory_$0_itemname|"First Item" +``` + +### Deleting Repeating Rows -* Single quotes (') surrounding **value** or **max** will be stripped, as will trailing spaces. If you need to include spaces at the end of a value, enclose the whole expression in single quotes. -* If you want to use the '|' or '#' characters inside an attribute value, you may escape them with a backslash: use '\|' or '\#' instead. -* If the option is of the form **--name|value**, then the maximum value will not be changed. -* If it is of the form **--name||max**, then the current value will not be changed. -* You can also just supply **--name|** or **--name** if you just want to create an empty attribute or set it to empty if it already exists, for whatever reason. -* **value** and **max** are ignored for **!delattr**. -* If you want to empty the current attribute and set some maximum, use **--name|''|max**. -* The script can deal with repeating attributes, both by id (e.g. **repeating\_prefix\_-ABC123\_attribute**) and by row index (e.g. **repeating\_prefix\_$0\_attribute**). If you want to create a new repeating row in a repeating section with name **prefix**, use the attribute name **repeating\_prefix\_-CREATE\_name**. If you want to delete a repeating row with **!delattr**, use the attribute name **repeating\_prefix\_ID** or **repeating\_prefix\_$rowNumber**. -* You can insert the values of _other_ attributes into the attributes values to be set via %attribute\_name%. For example, **--attr1|%attr2%|%attr2\_max%** will insert the current and maximum value of **attr2** into those of **attr1**. +Delete by row ID: -## Examples +``` +!delattr --sel --repeating_inventory_-ID +``` + +Delete by index: + +``` +!delattr --sel --repeating_inventory_$0 +``` + +## Special Value Expressions + +### Attribute References + +Reference other attribute values using `%attribute_name%`: + +``` +!setattr --sel --evaluate --temp_hp|%hp% / 2 +``` + +### Resetting to Maximum + +Reset an attribute to its maximum value: -* **!setattr --sel --Strength|15** will set the Strength attribute for 15 for all selected characters. -* **!setattr --name John --HP|17|27 --Dex|10** will set HP to 17 out of 27 and Dex to 10 for the character John (only one of them, if more than one character by this name exists). -* **!delattr --all --gold** will delete the attribute called gold from all characters, if it exists. -* **!setattr --sel --mod --Strength|5** will increase the Strength attribute of all selected characters by 5, provided that Strength is either empty or has a numerical value - it will fail to have an effect if, for example, Strength has the value 'Very big'. -* **!setattr --sel --Ammo|%Ammo\_max%** will reset the Ammo attribute for the selected characters back to its maximum value. -* If the current value of attr1 is 3 and the current value of attr2 is 2, **!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%** will set the current value of attr3 to 15. +``` +!setattr --sel --hp|%hp_max% +``` + +## Global Configuration + +The script has three global configuration options that can be toggled with `!setattr-config`: + +### --players-can-modify + +Allows players to modify attributes on characters they don't control. + +``` +!setattr-config --players-can-modify +``` -## Global configuration +### --players-can-evaluate -There are three global configuration options, _playersCanModify_, _playersCanEvaluate_, and _useWorkers_, which can be toggled either on this page or by entering **!setattr-config** in chat. The former two will give players the possibility of modifying characters they don't control or using the **--evaluate** option. You should only activate either of these if you can trust your players not to vandalize your characters or your campaign. The last option will determine if the script triggers sheet workers on use, and should normally be toggled on. +Allows players to use the `--evaluate` option. -## Registering observers +``` +!setattr-config --players-can-evaluate +``` -**Note:** this section is only intended to be read by script authors. If you are not writing API scripts, you can safely ignore this. +### --use-workers + +Toggles whether the script triggers sheet workers when setting attributes. + +``` +!setattr-config --use-workers +``` + +## Complete Examples + +### Basic Combat Example + +Reduce a character's HP and status after taking damage: + +``` +!modattr --sel --evaluate --hp|-15 --fb-header "Combat Result" --fb-content "_CHARNAME_ took 15 damage and has _CUR0_ HP remaining!" +``` + +### Leveling Up a Character + +Update multiple stats when a character gains a level: + +``` +!setattr --sel --level|8 --hp|75|75 --attack_bonus|7 --fb-from "Level Up" --fb-header "Character Advanced" --fb-public +``` + +### Create New Item in Inventory + +Add a new item to a character's inventory: + +``` +!setattr --sel --repeating_inventory_-CREATE_itemname|"Healing Potion" --repeating_inventory_-CREATE_itemcount|3 --repeating_inventory_-CREATE_itemweight|0.5 --repeating_inventory_-CREATE_itemcontent|"Restores 2d8+2 hit points when consumed" +``` + +### Apply Status Effects During Combat + +Apply a debuff to selected enemies in the middle of combat: + +``` +&{template:default} {{name=Web Spell}} {{effect=Slows movement}} !setattr --name @{target|character_name} --silent --speed|-15 --status|"Restrained"!!! {{duration=1d4 rounds}} +``` + +## For Developers + +### Registering Observers + +If you're developing your own scripts, you can register observer functions to react to attribute changes made by ChatSetAttr: + +```javascript +ChatSetAttr.registerObserver(event, observer); +``` -Changes made by API scripts do not trigger the default Roll20 event handlers, by default. While perhaps a sensible choice in order to prevent infinite loops, it is unfortunate if you do want your script to ChatSetAttr-induced attribute changes. To this end, ChatSetAttr offers an observer pattern. You can register your script with ChatSetAttr like you would register Roll20 event handlers, and your handler functions will be called by ChatSetAttr. The syntax is +Where `event` is one of: +- `"add"` - Called when attributes are created +- `"change"` - Called when attributes are modified +- `"destroy"` - Called when attributes are deleted -`ChatSetAttr.registerObserver(event, observer);` +And `observer` is an event handler function similar to Roll20's built-in event handlers. -where `event` is one of `"add"`, `"change"`, or `"destroy"`, and `observer` is the event handler function (with identical structure like the one you would pass to e.g. a `"change:attribute"` event). \ No newline at end of file +This allows your scripts to react to changes made by ChatSetAttr the same way they would react to changes made directly by Roll20's interface. \ No newline at end of file diff --git a/ChatSetAttr/build/.gitignore b/ChatSetAttr/build/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/ChatSetAttr/build/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ChatSetAttr/build/package-lock.json b/ChatSetAttr/build/package-lock.json new file mode 100644 index 0000000000..927e565fd9 --- /dev/null +++ b/ChatSetAttr/build/package-lock.json @@ -0,0 +1,1527 @@ +{ + "name": "build", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "build", + "version": "0.0.0", + "dependencies": { + "@types/node": "^22.15.15" + }, + "devDependencies": { + "@types/underscore": "^1.13.0", + "terser": "^5.39.2", + "typescript": "~5.8.3", + "underscore": "^1.13.7", + "vite": "^6.3.5", + "vitest": "^3.1.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", + "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", + "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", + "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", + "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", + "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", + "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", + "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", + "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", + "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", + "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", + "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", + "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", + "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", + "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", + "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", + "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", + "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", + "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", + "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", + "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.15.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.15.tgz", + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/underscore": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", + "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.3.tgz", + "integrity": "sha512-7FTQQuuLKmN1Ig/h+h/GO+44Q1IlglPlR2es4ab7Yvfx+Uk5xsv+Ykk+MEt/M2Yn/xGmzaLKxGw2lgy2bwuYqg==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.1.3", + "@vitest/utils": "3.1.3", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.3.tgz", + "integrity": "sha512-PJbLjonJK82uCWHjzgBJZuR7zmAOrSvKk1QBxrennDIgtH4uK0TB1PvYmc0XBCigxxtiAVPfWtAdy4lpz8SQGQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.3.tgz", + "integrity": "sha512-i6FDiBeJUGLDKADw2Gb01UtUNb12yyXAqC/mmRWuYl+m/U9GS7s8us5ONmGkGpUUo7/iAYzI2ePVfOZTYvUifA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.3.tgz", + "integrity": "sha512-Tae+ogtlNfFei5DggOsSUvkIaSuVywujMj6HzR97AHK6XK8i3BuVyIifWAm/sE3a15lF5RH9yQIrbXYuo0IFyA==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.3.tgz", + "integrity": "sha512-XVa5OPNTYUsyqG9skuUkFzAeFnEzDp8hQu7kZ0N25B1+6KjGm4hWLtURyBbsIAOekfWQ7Wuz/N/XXzgYO3deWQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.1.3", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.3.tgz", + "integrity": "sha512-x6w+ctOEmEXdWaa6TO4ilb7l9DxPR5bwEb6hILKuxfU1NqWT2mpJD9NJN7t3OTfxmVlOMrvtoFJGdgyzZ605lQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-2Ltrpht4OmHO9+c/nmHtF09HWiyWdworqnHIwjfvDyWjuwKbdkcS9AnhsDn+8E2RM4x++foD1/tNuLPVvWG1Rg==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.1.3", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", + "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.2", + "@rollup/rollup-android-arm64": "4.40.2", + "@rollup/rollup-darwin-arm64": "4.40.2", + "@rollup/rollup-darwin-x64": "4.40.2", + "@rollup/rollup-freebsd-arm64": "4.40.2", + "@rollup/rollup-freebsd-x64": "4.40.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", + "@rollup/rollup-linux-arm-musleabihf": "4.40.2", + "@rollup/rollup-linux-arm64-gnu": "4.40.2", + "@rollup/rollup-linux-arm64-musl": "4.40.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-musl": "4.40.2", + "@rollup/rollup-linux-s390x-gnu": "4.40.2", + "@rollup/rollup-linux-x64-gnu": "4.40.2", + "@rollup/rollup-linux-x64-musl": "4.40.2", + "@rollup/rollup-win32-arm64-msvc": "4.40.2", + "@rollup/rollup-win32-ia32-msvc": "4.40.2", + "@rollup/rollup-win32-x64-msvc": "4.40.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true + }, + "node_modules/terser": { + "version": "5.39.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", + "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "dev": true + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.3.tgz", + "integrity": "sha512-uHV4plJ2IxCl4u1up1FQRrqclylKAogbtBfOTwcuJ28xFi+89PZ57BRh+naIRvH70HPwxy5QHYzg1OrEaC7AbA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.3.tgz", + "integrity": "sha512-188iM4hAHQ0km23TN/adso1q5hhwKqUpv+Sd6p5sOuh6FhQnRNW3IsiIpvxqahtBabsJ2SLZgmGSpcYK4wQYJw==", + "dev": true, + "dependencies": { + "@vitest/expect": "3.1.3", + "@vitest/mocker": "3.1.3", + "@vitest/pretty-format": "^3.1.3", + "@vitest/runner": "3.1.3", + "@vitest/snapshot": "3.1.3", + "@vitest/spy": "3.1.3", + "@vitest/utils": "3.1.3", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.1.3", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.1.3", + "@vitest/ui": "3.1.3", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/ChatSetAttr/build/package.json b/ChatSetAttr/build/package.json new file mode 100644 index 0000000000..0e6917c6f2 --- /dev/null +++ b/ChatSetAttr/build/package.json @@ -0,0 +1,21 @@ +{ + "name": "build", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "vite build --emptyOutDir", + "test": "vitest" + }, + "devDependencies": { + "@types/underscore": "^1.13.0", + "terser": "^5.39.2", + "typescript": "~5.8.3", + "underscore": "^1.13.7", + "vite": "^6.3.5", + "vitest": "^3.1.3" + }, + "dependencies": { + "@types/node": "^22.15.15" + } +} diff --git a/ChatSetAttr/build/src/classes/APIWrapper.ts b/ChatSetAttr/build/src/classes/APIWrapper.ts new file mode 100644 index 0000000000..c7a85c8dbb --- /dev/null +++ b/ChatSetAttr/build/src/classes/APIWrapper.ts @@ -0,0 +1,333 @@ +import _ from "underscore"; +import { globalSubscribeManager } from "./SubscriptionManager"; + +export type AttributeDelta = { + value?: string; + max?: string; +}; + +export type DeltasObject = { + [key: string]: AttributeDelta; +}; + +export type ErrorResponse = { + messages: string[]; + errors: string[]; +}; + +export class APIWrapper { + // #region Attributes + public static async getAllAttrs( + character: Roll20Character + ): Promise { + const attrs = findObjs<"attribute">({ + _type: "attribute", + _characterid: character?.id, + }); + return attrs; + }; + + public static async getAttr( + character: Roll20Character, + attributeName: string + ): Promise { + const attr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character?.id, + name: attributeName, + })[0]; + if (!attr) { + return null; + } + return attr; + }; + + public static async getAttributes( + character: Roll20Character, + attributeList: string[] + ): Promise { + const attributes: DeltasObject = {}; + for (const attribute of attributeList) { + const value = await APIWrapper.getAttribute(character, attribute); + if (!value) { + continue; + } + attributes[attribute] = value; + } + return attributes; + }; + + public static async getAttribute( + character: Roll20Character, + attributeName: string + ): Promise { + if (!character) { + return null; + } + const value = await getSheetItem(character.id, attributeName, "current"); + const max = await getSheetItem(character.id, attributeName, "max"); + const attributeValue: AttributeDelta = {}; + if (value) { + attributeValue.value = value; + } + if (max) { + attributeValue.max = max; + } + if (!value && !max) { + return null; + } + return attributeValue; + }; + + private static async createAttribute( + character: Roll20Character, + name: string, + value?: string, + max?: string, + ): Promise<[ErrorResponse]> { + const errors: string[] = []; + const messages: string[] = []; + const newObjProps: Partial = { + name, + _characterid: character?.id, + }; + const newAttr = createObj("attribute", newObjProps); + await APIWrapper.setAttribute(character, name, value, max); + globalSubscribeManager.publish("add", newAttr); + // This is how the previous script was doing it + globalSubscribeManager.publish("change", newAttr, newAttr); + messages.push(`Created attribute ${name} with value ${value} for character ${character?.get("name")}.`); + return [{ messages, errors }]; + }; + + private static async updateAttribute( + character: Roll20Character, + name: string, + value?: string, + max?: string + ): Promise<[ErrorResponse]> { + const errors: string[] = []; + const messages: string[] = []; + 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, value, max); + if (value && max) { + messages.push(`Setting ${name} to ${value} and ${name}_max to ${max} for character ${character?.get("name")}.`); + } else if (value) { + messages.push(`Setting ${name} to ${value} for character ${character?.get("name")}, max remains unchanged.`); + } else if (max) { + messages.push(`Setting ${name}_max to ${max} for character ${character?.get("name")}, current remains unchanged.`); + } + globalSubscribeManager.publish("change", { name, current: value, max }, { name, current: oldAttr.value, max: oldAttr.max }); + return [{ messages, errors }]; + }; + + public static async setAttributeOld( + attr: Roll20Attribute, + value?: string, + max?: string + ): Promise { + if (state.ChatSetAttr.useWorkers) { + attr.setWithWorker({ current: value }); + } else { + attr.set({ current: value }); + } + if (max) { + if (state.ChatSetAttr.useWorkers) { + attr.setWithWorker({ max }); + } + else { + attr.set({ max }); + } + } + }; + + private static async setWithWorker( + characterID: string, + attr: string, + value?: string, + max?: string + ): Promise { + const attrObj = await APIWrapper.getAttr( + { id: characterID } as Roll20Character, + attr + ); + if (!attrObj) { + return; + } + attrObj.setWithWorker({ + current: value, + }); + if (max) { + attrObj.setWithWorker({ + max, + }); + } + }; + + private static async setAttribute( + character: Roll20Character, + attr: string, + value?: string, + max?: string + ): Promise { + if (state.ChatSetAttr.useWorkers) { + await APIWrapper.setWithWorker(character.id, attr, value, max); + return; + } + if (value) { + await setSheetItem(character.id, attr, value, "current"); + log(`Setting ${attr} to ${value} 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")}.`); + } + }; + + public static async setAttributesOnly( + character: Roll20Character, + attributes: DeltasObject + ): Promise<[ErrorResponse]> { + const errors: string[] = []; + const messages: string[] = []; + const entries = Object.entries(attributes); + for (const [key, value] of entries) { + const attribute = await APIWrapper.getAttribute(character, key); + if (!attribute?.value) { + errors.push(`Attribute ${key} does not exist for character ${character.get("name")}.`); + return [{ messages, errors }]; + } + const stringValue = value.value ? value.value.toString() : undefined; + const stringMax = value.max ? value.max.toString() : undefined; + const [response] = await APIWrapper.updateAttribute(character, key, stringValue, stringMax); + messages.push(...response.messages); + errors.push(...response.errors); + } + return [{ messages, errors }]; + }; + + public static async setAttributes( + character: Roll20Character, + attributes: DeltasObject + ): Promise<[ErrorResponse]> { + const errors: string[] = []; + const messages: string[] = []; + const entries = Object.entries(attributes); + for (const [key, value] of entries) { + const stringValue = value.value ? value.value.toString() : ""; + const stringMax = value.max ? value.max.toString() : undefined; + const attribute = await APIWrapper.getAttribute(character, key); + if (!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 }]; + }; + + public static async deleteAttributes( + character: Roll20Character, + attributes: string[] + ): Promise<[ErrorResponse]> { + const errors: string[] = []; + const messages: string[] = []; + 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 }]; + }; + + public static async deleteRepeatingRow( + character: Roll20Character, + section: string, + repeatingID: string + ): Promise<[ErrorResponse]> { + const errors: string[] = []; + const messages: string[] = []; + const repeatingAttrs = findObjs<"attribute">({ + _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 }]; + }; + + public static async resetAttributes( + character: Roll20Character, + attributes: string[] + ): Promise<[ErrorResponse]> { + const errors: string[] = []; + const messages: string[] = []; + for (const attribute of attributes) { + const attr = await APIWrapper.getAttribute(character, attribute); + const value = attr?.value; + if (!value) { + 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 + public static extractRepeatingDetails(attributeName: string) { + const [, section, repeatingID, ...attributeParts] = attributeName.split("_"); + const attribute = attributeParts.join("_"); + return { + section: section || undefined, + repeatingID: repeatingID || undefined, + attribute: attribute || undefined, + }; + }; + + public static hasRepeatingAttributes( + attributes: DeltasObject + ): boolean { + return Object.keys(attributes).some((key) => key.startsWith("repeating_")); + }; +}; \ No newline at end of file diff --git a/ChatSetAttr/build/src/classes/AttrProcessor.ts b/ChatSetAttr/build/src/classes/AttrProcessor.ts new file mode 100644 index 0000000000..d84b1b3d1f --- /dev/null +++ b/ChatSetAttr/build/src/classes/AttrProcessor.ts @@ -0,0 +1,385 @@ +import { APIWrapper, type AttributeDelta, type DeltasObject } from "./APIWrapper"; +import { UUID } from "./UUIDs"; + +export type RepeatingData = { + [section: string]: { + [repeatingID: string]: DeltasObject; + }; +}; + +export type RepeatingOrders = { + [section: string]: string[]; +}; + +export type AllRepeating = { + repeatingData: RepeatingData; + repeatingOrders: RepeatingOrders; +}; + +export type AttributesManagerOptions = { + useEval?: boolean; + useParse?: boolean; + useModify?: boolean; + useConstrain?: boolean; + useReplace?: boolean; +}; + +const REPLACEMENTS = { + "<": "[", + ">": "]", + "\lbrak": "[", + "\rbrak": "]", + ";": "?", + "\ques": "?", + "`": "@", + "\at": "@", + "~": "-", + "\dash": "-", +}; + +export class AttrProcessor { + public character: Roll20Character; + public delta: DeltasObject; + public final: DeltasObject; + public attributes: Roll20Attribute[]; + public repeating: AllRepeating; + public eval: boolean; + public parse: boolean; + public modify: boolean; + public constrain: boolean; + public replace: boolean; + public errors: string[] = []; + private newRowID: string | null = null; + private static readonly referenceRegex = /%([^%]+)%/g; + private static readonly rowNumberRegex = /\$\d+/g; + + constructor( + character: Roll20Character, + delta: DeltasObject, + { + useEval = false, + useParse = true, + useModify = false, + useConstrain = false, + useReplace = false, + }: AttributesManagerOptions = {}, + ) { + 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; + } + }; + + private initializeRepeating( + ): AllRepeating { + return { + repeatingData: {}, + repeatingOrders: {}, + }; + }; + + public async init(): Promise { + 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; + }; + + private async getAllRepeatingData(): Promise { + const repeatingData: 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: AttributeDelta = { + value: attributeValue || "", + } + if (attributeMax) { + attribute.max = attributeMax; + } + repeatingData[section][repeatingID][attributeName] = attribute; + } + return repeatingData; + }; + + private getRepeatingOrders(): RepeatingOrders { + const repeatingOrders: 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 name = attr.get("name"); + const isSection = name.includes(name); + return isSection; + }); + const idArray: string[] = []; + if (attribute) { + const value = attribute.get("current"); + const list = value.split(",").map(item => item.trim()).filter(item => !!item); + idArray.push(...list); + } + const unordered = this.getUnorderedAttributes(name, idArray) + const allIDs = new Set([...idArray, ...unordered]); + const ids = Array.from(allIDs); + repeatingOrders[name] ??= []; + repeatingOrders[name].push(...ids); + } + return repeatingOrders; + }; + + private getUnorderedAttributes( + section: string, + ids: string[], + ): string[] { + 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; + }; + + private async parseAttributes(): Promise { + const delta = { ...this.delta }; + const final: DeltasObject = {}; + 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; + }; + + private replaceMarks(value: string): string { + const replacements = Object.entries(REPLACEMENTS); + for (const [oldChar, newChar] of replacements) { + const regex = new RegExp(oldChar, "g"); + value = value.replace(regex, newChar); + } + return value; + }; + + private parseDelta(value: string): string { + return value.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; // Return original match if attribute not found + }); + }; + + private evalDelta(value: string): string { + try { + const evaled = eval(value); + if (evaled) { + return evaled.toString(); + } + else { + return value; + } + } catch (error) { + return value; + } + }; + + private modifyDelta(delta: string, name: string, isMax = false): string { + const attribute = this.attributes.find(attr => attr.get("name") === name); + if (!attribute) { + this.errors.push(`Attribute ${name} not found for modification.`); + return delta; // Return delta value if attribute not found + } + const current = isMax ? attribute.get("max") : attribute.get("current"); + if (delta === undefined || delta === null || delta === "") { + if (!isMax) { + this.errors.push(`Attribute ${name} has no value to modify.`); + } + return ""; // Return original value if no value is found + } + 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; // Return original value if not a number + } + let modified = deltaAsNumber + currentAsNumber; + return modified.toString(); + }; + + private constrainDelta(value: string, maxDelta: string, name: string): string { + const attribute = this.attributes.find(attr => attr.get("name") === name); + const max = maxDelta ? maxDelta : attribute?.get("max"); + const valueAsNumber = Number(value); + const maxAsNumber = max === "" ? Infinity : Number(max); + if (isNaN(valueAsNumber) || isNaN(maxAsNumber)) { + this.errors.push(`Invalid value for constraining: ${value} or max: ${max}`); + return value; // Return original value if not a number + } + let valueUpper = Math.min(valueAsNumber, maxAsNumber); + let valueLower = Math.max(0, valueUpper); + return valueLower.toString(); + }; + + private useNewRepeatingID(): string { + if (!this.newRowID) { + this.newRowID = UUID.generateRowID(); + } + return this.newRowID; + }; + + private parseRepeating(name: string): string { + const { section, repeatingID, attribute } = APIWrapper.extractRepeatingDetails(name) ?? {}; + if (!section) { + this.errors.push(`Invalid repeating attribute name: ${name}`); + return name; // Return original name if invalid + } + 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 repeatingID = this.repeating.repeatingOrders[section]?.[index]; + if (repeatingID && !attribute) { + return `repeating_${section}_${repeatingID}`; + } else if (repeatingID) { + return `repeating_${section}_${repeatingID}_${attribute}`; + } + } + this.errors.push(`Repeating ID for ${name} not found.`); + return name; + }; + + public replacePlaceholders( + message: string, + ): string { + 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; // Return original match if index is invalid + } + 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; // Return original match if index is invalid + } + 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; // Return original match if index is invalid + } + 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; // Return original match if index is invalid + } + 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; // Return original match if index is invalid + } + const actualIndex = parseInt(index, 10); + const attr = finalEntries[actualIndex]; + return attr[1].max ?? ""; + }) + .replace(/_CHARNAME_/g, this.character.get("name") ?? ""); + return newMessage; + } +}; \ No newline at end of file diff --git a/ChatSetAttr/build/src/classes/ChatOutput.ts b/ChatSetAttr/build/src/classes/ChatOutput.ts new file mode 100644 index 0000000000..deb15aac5b --- /dev/null +++ b/ChatSetAttr/build/src/classes/ChatOutput.ts @@ -0,0 +1,119 @@ +import { createStyle } from "../utils/createStyle"; + +type MessageType = "error" | "info" | "feedback"; + +export type ChatProps = { + header?: string; + content?: string; + from?: string; + playerID?: string; + type?: MessageType; + whisper?: boolean; +}; + +export class ChatOutput { + public header: string; + public content: string; + public from: string; + public playerID: string; + public type: MessageType; + public whisper: boolean; + + private chatStyle = createStyle({ + border: `1px solid #ccc`, + borderRadius: `5px`, + padding: `5px`, + backgroundColor: `#f9f9f9`, + }); + + private headerStyle = createStyle({ + fontSize: `1.2em`, + fontWeight: `bold`, + marginBottom: `5px`, + }); + + private errorStyle = createStyle({ + border: `1px solid #f00`, + borderRadius: `5px`, + padding: `5px`, + backgroundColor: `#f9f9f9`, + }); + + private errorHeaderStyle = createStyle({ + color: `#f00`, + fontWeight: `bold`, + fontSize: `1.2em`, + }); + + constructor({ + playerID = "GM", + header = "", + content = "", + from = "ChatOutput", + type = "info", + whisper = false, + }: ChatProps = {}) { + this.playerID = playerID; + this.header = header; + this.content = content; + this.from = from; + this.type = type; + this.whisper = whisper; + }; + + public send() { + const noarchive = this.type === "feedback" ? false : true; + let output = ``; + output += this.createWhisper(); + output += this.createWrapper(); + output += this.createHeader(); + output += this.createContent(); + output += this.closeWrapper(); + sendChat(this.from, output, undefined, { noarchive }); + }; + + private createWhisper() { + if (this.whisper === false) { + return ``; + } + if (this.playerID === "GM") { + return `/w GM `; + } + const player = getObj("player", this.playerID); + if (!player) { + return `/w GM `; + } + const playerName = player.get("_displayname"); + if (!playerName) { + return `/w GM `; + } + return `/w "${playerName}" `; + }; + + private createWrapper() { + const style = this.type === "error" ? this.errorStyle : this.chatStyle; + return `
`; + }; + + private createHeader() { + if (!this.header) { + return ``; + } + const style = this.type === "error" ? this.errorHeaderStyle : this.headerStyle; + return `

${this.header}

`; + }; + + private createContent() { + if (!this.content) { + return ``; + } + if (this.content.startsWith("<")) { + return this.content; // Already HTML content + } + return `

${this.content}

`; + }; + + private closeWrapper() { + return `
`; + }; +}; \ No newline at end of file diff --git a/ChatSetAttr/build/src/classes/ChatSetAttr.ts b/ChatSetAttr/build/src/classes/ChatSetAttr.ts new file mode 100644 index 0000000000..ce236a6b41 --- /dev/null +++ b/ChatSetAttr/build/src/classes/ChatSetAttr.ts @@ -0,0 +1,324 @@ +import { ConfigCommand, DelAttrCommand, ModAttrCommand, ModBAttrCommand, ResetAttrCommand, SetAttrCommand, type CommandStrategy } from "./Commands"; +import { CommandType, InputParser, Flags, Commands } from "./InputParser"; +import type { Option, Command } from "./InputParser"; +import { TargetAllCharacters, TargetAllGMCharacters, TargetByName, TargetByID, TargetBySelection, TargetAllPlayerCharacters } from "./Targets"; +import type { TargetIdentifier, TargetStrategies } from "./Targets"; +import { globalSubscribeManager } from "./SubscriptionManager"; +import { ChatOutput } from "./ChatOutput"; +import { TimerManager } from "./TimerManager"; + +export type Feedback = { + header?: string; + content?: string; + sender?: string; + whisper?: boolean; +}; + +const VERSION = "1.11"; +const SCHEMA_VERSION = 4; + +export const SCRIPT_STRINGS = { + CAN_MODIFY: "Players can modify all characters", + CAN_EVAL: "Players can use --evaluate", + USE_WORKERS: "Trigger sheet workers when setting attributes", +}; + +export class ChatSetAttr { + private InputParser: InputParser; + private errors: string[] = []; + private messages: string[] = []; + + constructor() { + this.InputParser = new InputParser(); + this.registerEventHandlers(); + }; + + private async handleChatMessage(msg: Roll20ChatMessage) { + const { + commandType, + command, + flags, + attributes + } = this.InputParser.parse(msg); + + // #region Command + if (commandType === CommandType.NONE || !command) { + return; + } + + const actualCommand = this.overrideCommandFromOptions(flags, command); + const commandHandler = this.getCommandHandler(actualCommand); + + if (!commandHandler) { + this.errors.push(`Command ${actualCommand} not found.`); + this.sendMessages(); + return; + } + + // #region Targets + const targets = this.getTargets(msg, flags); + if (targets.length === 0 && actualCommand !== Commands.SET_ATTR_CONFIG) { + this.errors.push(`No valid targets found for command ${actualCommand}.`); + this.sendMessages(); + return; + } + + // #region Act + TimerManager.start("chatsetattr", 8000, this.sendDelayMessage); + const response = await commandHandler.execute(flags, targets, attributes, msg); + TimerManager.stop("chatsetattr"); + + // #region Messages + const feedback = this.extractFeedback(flags); + this.messages.push(...response.messages); + this.errors.push(...response.errors); + const isSilent = flags.some(flag => flag.name === Flags.SILENT); + if (isSilent) { + this.messages = []; + } + const isMuted = flags.some(flag => flag.name === Flags.MUTE); + if (isMuted) { + this.messages = []; + this.errors = []; + } + if (response.errors.length > 0 || response.messages.length > 0) { + this.sendMessages(feedback); + return; + } + }; + + private overrideCommandFromOptions(flags: Option[], command: Command): Command { + const commandFlags = [Flags.MOD, Flags.MOD, Flags.MOD_B, Flags.RESET, Flags.DEL]; + type CommandFlags = typeof commandFlags[number]; + const commandOptions = flags + .filter(flag => commandFlags.includes(flag.name as CommandFlags)) + .map(flag => flag.name as CommandFlags); + const commandOverride = commandOptions[0]; + switch (commandOverride) { + case Flags.MOD: + return Commands.MOD_ATTR; + case Flags.MOD_B: + return Commands.MOD_B_ATTR; + case Flags.RESET: + return Commands.RESET_ATTR; + case Flags.DEL: + return Commands.DEL_ATTR; + default: + return command; + }; + }; + + private getCommandHandler(command: Command): CommandStrategy { + switch (command) { + case Commands.SET_ATTR: + return new SetAttrCommand(); + case Commands.MOD_ATTR: + return new ModAttrCommand(); + case Commands.MOD_B_ATTR: + return new ModBAttrCommand(); + case Commands.RESET_ATTR: + return new ResetAttrCommand(); + case Commands.DEL_ATTR: + return new DelAttrCommand(); + case Commands.SET_ATTR_CONFIG: + return new ConfigCommand(); + default: + throw new Error(`Command ${command} not found.`); + }; + }; + + private getTargets(msg: Roll20ChatMessage, flags: Option[]): Roll20Character[] { + const target = this.targetFromOptions(flags); + if (!target) { + return []; + } + const targetStrategy = this.getTargetStrategy(target); + const targets = this.getTargetsFromOptions(target, flags, msg.selected); + const [validTargets, { messages, errors }] = targetStrategy.parse(targets, msg.playerid); + this.messages.push(...messages); + this.errors.push(...errors); + return validTargets; + }; + + private getTargetsFromOptions(target: TargetStrategies, flags: Option[], selected: Roll20ChatMessage["selected"]): string[] { + switch (target) { + case "all": + return []; + case "allgm": + return []; + case "allplayers": + return []; + case "name": + const nameFlag = flags.find(flag => flag.name === Flags.CHAR_NAME); + if (!nameFlag?.value) { + this.errors.push(`Target 'name' requires a name flag.`); + return []; + } + const names = nameFlag.value.split(",").map(name => name.trim()); + return names; + case "charid": + const idFlag = flags.find(flag => flag.name === Flags.CHAR_ID); + if (!idFlag?.value) { + this.errors.push(`Target 'charid' requires an ID flag.`); + return []; + } + const ids = idFlag.value.split(",").map(id => id.trim()); + return ids; + case "sel": + if (!selected || selected.length === 0) { + this.errors.push(`Target 'sel' requires selected tokens.`); + return []; + } + const selectedIDs = this.convertObjectsToIDs(selected); + return selectedIDs; + default: + this.errors.push(`Target strategy ${target} not found.`); + return []; + } + }; + + private convertObjectsToIDs(objects: { _id: string }[] | undefined): string[] { + if (!objects) return []; + const ids = objects.map(object => object._id); + return ids; + }; + + private getTargetStrategy(target: TargetStrategies): TargetIdentifier { + switch (target) { + case "all": + return new TargetAllCharacters(); + case "allgm": + return new TargetAllGMCharacters(); + case "allplayers": + return new TargetAllPlayerCharacters(); + case "name": + return new TargetByName(); + case "charid": + return new TargetByID(); + case "sel": + return new TargetBySelection(); + default: + throw new Error(`Target strategy ${target} not found.`); + } + }; + + private targetFromOptions(flags: Option[]): TargetStrategies | false { + const targetFlags = [Flags.ALL, Flags.ALL_GM, Flags.ALL_PLAYERS, Flags.CHAR_ID, Flags.CHAR_NAME, Flags.SELECTED]; + const targetOptions = flags + .filter(flag => targetFlags.includes(flag.name as TargetStrategies)) + .map(flag => flag.name as TargetStrategies); + const targetOverride = targetOptions[0]; + return targetOverride || false; + }; + + private sendMessages(feedback?: Feedback | null) { + const sendErrors = this.errors.length > 0; + const from = feedback?.sender || "ChatSetAttr"; + const whisper = feedback?.whisper ?? true; + if (sendErrors) { + const header = "ChatSetAttr Error"; + const content = this.errors.map(error => error.startsWith("<") ? error : `

${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, + ) => Promise; + + help: () => string; +}; + +export class SetAttrCommand implements CommandStrategy { + public name = "setattr"; + public description = "Set attributes for a character."; + public options = ["nocreate", "evaluate"]; + + public async execute( + options: Option[], + targets: Roll20Character[], + values: DeltasObject, + ) { + const messages: string[] = []; + const errors: string[] = []; + + 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 = options.find((opt) => opt.name === Flags.FB_CONTENT)?.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 ?? []); + // messages.push(createResponse.messages[0] ?? ""); + + 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, + }; + }; + + public help() { + return `!setattr ${this.options.join(" ")} - Set attributes for a character.`; + }; +}; + +export class ModAttrCommand implements CommandStrategy { + public name = "modattr"; + public description = "Modify attributes for a character."; + public options = ["evaluate"]; + + public async execute( + options: Option[], + targets: Roll20Character[], + values: DeltasObject, + ) { + const messages: string[] = []; + const errors: string[] = []; + + 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, + }; + }; + + public help() { + return `!modattr ${this.options.join(" ")} - Modify attributes for a character.`; + }; +}; + +export class ModBAttrCommand implements CommandStrategy { + public name = "modbattr"; + public description = "Modify attributes for a character bound to upper values of the related max."; + public options = ["evaluate"]; + + public async execute( + options: Option[], + targets: Roll20Character[], + values: DeltasObject, + ) { + const messages: string[] = []; + const errors: string[] = []; + + 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, + }; + }; + + public help() { + return `!modbattr ${this.options.join(" ")} - Modify attributes for a character bound to upper values of the related max.`; + }; +}; + +export class DelAttrCommand implements CommandStrategy { + public name = "delattr"; + public description = "Delete attributes for a character."; + public options = []; + + public async execute( + _: Option[], + targets: Roll20Character[], + values: DeltasObject, + ) { + const messages: string[] = []; + const errors: string[] = []; + + 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, + }; + }; + + public help() { + return `!delattr - Delete attributes for a character.`; + }; +}; + +export class ResetAttrCommand implements CommandStrategy { + public name = "resetattr"; + public description = "Reset attributes for a character."; + public options = []; + + public async execute( + _: Option[], + targets: Roll20Character[], + values: DeltasObject, + ) { + const messages: string[] = []; + const errors: string[] = []; + + 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, + }; + }; + + public help() { + return `!resetattr - Reset attributes for a character.`; + }; +}; + +export class ConfigCommand implements CommandStrategy { + public name = "setattr-config"; + public description = "Configure the SetAttr command."; + public options = ["players-can-modify", "players-can-evaluate", "use-workers"]; + + public async execute( + options: Option[], + _: Roll20Character[], + __: DeltasObject, + message: Roll20ChatMessage, + ) { + const messages: string[] = []; + const errors: string[] = []; + + 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; + default: + break; + } + } + + const messageContent = this.createMessage(); + messages.push(messageContent); + + return { + messages, + errors, + }; + }; + + public help() { + return `!setattr-config ${this.options.join(" ")} - Configure the SetAttr command.`; + }; + + private createMessage() { + const localState = state.ChatSetAttr; + let message = ``; + message += `

ChatSetAttr Configuration

`; + message += `

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

`; + message += `

!setattr-config --option

`; + message += `

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 `
` + + `${value ? "ON" : "OFF"}` + + `${property}: ${description}` + + `
`; + }; +} \ No newline at end of file diff --git a/ChatSetAttr/build/src/classes/InputParser.ts b/ChatSetAttr/build/src/classes/InputParser.ts new file mode 100644 index 0000000000..15a1a743d5 --- /dev/null +++ b/ChatSetAttr/build/src/classes/InputParser.ts @@ -0,0 +1,207 @@ +import type { AttributeDelta, DeltasObject } from "./APIWrapper"; + +export const CommandType = { + NONE: -1, + API: 0, + INLINE: 1, +} as const; + +type CommandType = typeof CommandType[keyof typeof CommandType]; + +export const Commands = { + SET_ATTR_CONFIG: "setattr-config", + RESET_ATTR: "resetattr", + SET_ATTR: "setattr", + DEL_ATTR: "delattr", + MOD_ATTR: "modattr", + MOD_B_ATTR: "modbattr", +} as const; + +export type Command = typeof Commands[keyof typeof Commands]; + +export 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", +} as const; + +export type Flag = typeof Flags[keyof typeof Flags]; + +export type Option = { + name: Flag | string; + value?: string; +}; + +export type ParseResult = { + commandType: CommandType; + command: Command | null; + flags: Option[]; + attributes: DeltasObject; +}; + +export class InputParser { + private commands: Command[] = Object.values(Commands); + private flags: Flag[] = Object.values(Flags); + private commandPrefix: string = "!"; + private commandSuffix: string = "!!!"; + private optionPrefix: string = "--"; + + constructor() {}; + + public parse(message: Roll20ChatMessage): ParseResult { + 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); + if (match) { + return this.parseAPICommand(command, match[1], CommandType.INLINE); + } + } + return { + commandType: CommandType.NONE, + command: null, + flags: [], + attributes: {}, + }; + }; + + private parseAPICommand(command: Command, input: string, type: CommandType): ParseResult { + const { flags, attributes } = this.extractOptions(input, command); + return { + commandType: type, + command: command as Command, + flags, + attributes, + }; + }; + + private extractOptions(input: string, command: string): { flags: Option[]; attributes: DeltasObject } { + const attributes: DeltasObject = {}; + const flags: Option[] = []; + 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 }; + }; + + private parseFlag(option: string): Option | null { + const [name, ...values] = option.split(" ").map(opt => opt.trim()); + const value = values.join(" "); + return { + name: this.stripChars(name), + value: this.stripChars(value), + }; + }; + + private parseAttribute(option: string): { attribute: AttributeDelta, name: string } | null { + const split = option.split(/(? opt.trim()); + const rationalized = split.map(p => { + p = this.stripChars(p); + if (!p || p === "") { + return null; + } + return p; + }); + const [name, value, max] = rationalized; + if (!name) { + return null; + } + const attribute: AttributeDelta = { + }; + if (value) { + attribute.value = value; + } + if (max) { + attribute.max = max; + } + return { + attribute, + name, + } + }; + + private stripQuotes(str: string): string { + return str.replace(/["'](.*)["']/g, "$1"); + } + + private stripBackslashes(str: string): string { + return str.replace(/\\/g, ""); + } + + private stripChars(str: string): string { + const noSlashes = this.stripBackslashes(str); + const noQuotes = this.stripQuotes(noSlashes); + return noQuotes; + } + + private processInlineRolls(message: Roll20ChatMessage): string { + 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 !== undefined) { + const rollValue = roll.results.total; + content = content.replace(`$[[${key}]]`, rollValue.toString()); + } else { + content = content.replace(`$[[${key}]]`, ""); + } + } + return content; + } + + private removeRollTemplates(input: string): string { + return input.replace(/{{[^}[\]]+\$\[\[(\d+)\]\].*?}}/g, (_, number) => { + return `$[[${number}]]`; + }); + }; +}; diff --git a/ChatSetAttr/build/src/classes/SubscriptionManager.ts b/ChatSetAttr/build/src/classes/SubscriptionManager.ts new file mode 100644 index 0000000000..f0c8e2f40f --- /dev/null +++ b/ChatSetAttr/build/src/classes/SubscriptionManager.ts @@ -0,0 +1,52 @@ +export const ObserverTypes = { + ADD: "add", + CHANGE: "change", + DESTROY: "destroy", +} as const; + +export type ObserverType = typeof ObserverTypes[keyof typeof ObserverTypes]; +const ObserverTypeValues = Object.values(ObserverTypes); + +class SubscriptionManager { + private subscriptions = new Map(); + + constructor() {}; + + public subscribe(event: string, callback: Function) { + if (typeof callback !== "function") { + log(`event registration unsuccessful: ${event} - callback is not a function`); + } + if (!ObserverTypeValues.includes(event as ObserverType)) { + log(`event registration unsuccessful: ${event} - event is not a valid observer type`); + } + if (!this.subscriptions.has(event)) { + this.subscriptions.set(event, []); + } + this.subscriptions.get(event)?.push(callback); + log(`event registration successful: ${event}`); + }; + + public unsubscribe(event: string, callback: Function) { + if (!this.subscriptions.has(event)) { + return; + } + const callbacks = this.subscriptions.get(event); + const index = callbacks?.indexOf(callback); + if (index !== undefined && index !== -1) { + callbacks?.splice(index, 1); + } + }; + + public publish(event: string, ...args: any[]) { + if (!this.subscriptions.has(event)) { + return; + } + const callbacks = this.subscriptions.get(event); + callbacks?.forEach(callback => callback(...args)); + }; + +}; + +const globalSubscribeManager = new SubscriptionManager(); + +export { globalSubscribeManager }; \ No newline at end of file diff --git a/ChatSetAttr/build/src/classes/Targets.ts b/ChatSetAttr/build/src/classes/Targets.ts new file mode 100644 index 0000000000..22a6979f9c --- /dev/null +++ b/ChatSetAttr/build/src/classes/Targets.ts @@ -0,0 +1,225 @@ +import { filterByPermission } from "../utils/filterByPermission"; +import type { ErrorResponse } from "./APIWrapper"; + +export type TargetStrategies = "all" | "allgm" | "allplayers" | "name" | "charid" | "sel"; + +export interface TargetIdentifier { + name: string; + description: string; + + parse: ( + targets: string[], + playerID: string, + ) => [Roll20Character[], ErrorResponse]; +}; + +export class TargetAllCharacters implements TargetIdentifier { + public name = "all"; + public description = "All characters in the game."; + + public parse( + targets: string[], + playerID: string, + ): [Roll20Character[], ErrorResponse] { + const errors: string[] = []; + const messages: string[] = []; + + const canUseAll = playerIsGM(playerID); + if (!canUseAll) { + errors.push("You do not have permission to use the 'all' target."); + return [[], { messages, errors }]; + } + + if (targets.length > 0) { + errors.push("The 'all' target does not accept any targets."); + return [[], { messages, errors }]; + } + + const allCharacters = findObjs({ + _type: "character", + }); + + return [allCharacters, { messages, errors }]; + }; +}; + +export class TargetAllPlayerCharacters implements TargetIdentifier { + public name = "allplayers"; + public description = "All characters controlled by players."; + public parse( + targets: string[], + playerID: string, + ): [Roll20Character[], ErrorResponse] { + const errors: string[] = []; + const messages: string[] = []; + + const canUseAll = playerIsGM(playerID) || state.ChatSetAttr.playersCanModify; + if (!canUseAll) { + errors.push("You do not have permission to use the 'allplayers' target."); + return [[], { messages, errors }]; + } + + if (targets.length > 0) { + errors.push("The 'allplayers' target does not accept any targets."); + return [[], { messages, errors }]; + } + + const allPlayerCharacters = findObjs<"character">({ + _type: "character", + }) + .filter(character => { + const controlledBy = character.get("controlledby"); + return controlledBy && controlledBy !== "" && controlledBy !== "all"; + }); + + return [allPlayerCharacters, { messages, errors }]; + }; +}; + +export class TargetAllGMCharacters implements TargetIdentifier { + public name = "allgm"; + public description = "All characters not controlled by any player."; + + public parse( + targets: string[], + playerID: string, + ): [Roll20Character[], ErrorResponse] { + const errors: string[] = []; + const messages: string[] = []; + + const canUseAll = playerIsGM(playerID) || state.ChatSetAttr.playersCanModify; + if (!canUseAll) { + errors.push("You do not have permission to use the 'allgm' target."); + return [[], { messages, errors }]; + } + + if (targets.length > 0) { + errors.push("The 'allgm' target does not accept any targets."); + return [[], { messages, errors }]; + } + + const allGmCharacters = findObjs<"character">({ + _type: "character", + }) + .filter(character => { + const controlledBy = character.get("controlledby"); + return controlledBy === "" || controlledBy === "all"; + }); + + return [allGmCharacters, { messages, errors }]; + }; +}; + +export class TargetByName implements TargetIdentifier { + public name = "name"; + public description = "Target specific character names."; + public parse( + targets: string[], + playerID: string, + ): [Roll20Character[], ErrorResponse] { + const errors: string[] = []; + const messages: string[] = []; + + if (targets.length === 0) { + errors.push("The 'name' target requires at least one target."); + return [[], { messages, errors }]; + } + + const targetsByName: Roll20Character[] = targets.map(target => { + const character = findObjs({ + _type: "character", + name: target, + })[0]; + if (!character) { + errors.push(`Character with name ${target} does not exist.`); + return null; + } + return character; + }).filter(target => target !== null); + + const [validTargets, response] = filterByPermission(playerID, targetsByName); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + + return [validTargets, { messages, errors }]; + }; +}; + +export class TargetByID implements TargetIdentifier { + public name = "id"; + public description = "Target specific character IDs."; + public parse( + targets: string[], + playerID: string, + ): [Roll20Character[], ErrorResponse] { + const errors: string[] = []; + const messages: string[] = []; + + if (targets.length === 0) { + errors.push("The 'id' target requires at least one target."); + return [[], { messages, errors }]; + } + + const targetsByID: Roll20Character[] = targets.map(target => { + const character = getObj("character", target); + if (!character) { + errors.push(`Character with ID ${target} does not exist.`); + return null; + } + return character; + }).filter(target => target !== null); + + const [validTargets, response] = filterByPermission(playerID, targetsByID); + messages.push(...response.messages ?? []); + errors.push(...response.errors ?? []); + + if (validTargets.length === 0 && targets.length > 0) { + errors.push("No valid targets found with the provided IDs."); + } + + return [validTargets, { messages, errors }]; + } +}; + +export class TargetBySelection implements TargetIdentifier { + public name = "target"; + public description = "Target characters by selected tokens."; + + public parse( + targets: string[], + playerID: string, + ): [Roll20Character[], ErrorResponse] { + const errors: string[] = []; + const messages: string[] = []; + + if (targets.length === 0) { + errors.push("The 'target' target requires at least one target."); + return [[], { messages, errors }]; + } + + const targetsFromSelection: Roll20Character[] = targets.map(target => { + const graphic = getObj("graphic", target); + if (!graphic) { + errors.push(`Token with ID ${target} does not exist.`); + return null; + } + const represents = graphic.get("represents"); + if (!represents) { + errors.push(`Token with ID ${target} does not represent a character.`); + return null; + } + const character = getObj("character", represents); + if (!character) { + errors.push(`Character with ID ${represents} does not exist.`); + return null; + } + return character; + }).filter(target => target !== null); + + const [validTargets, permissionResponse] = filterByPermission(playerID, targetsFromSelection); + messages.push(...permissionResponse.messages ?? []); + errors.push(...permissionResponse.errors ?? []); + + return [validTargets, { messages, errors }]; + }; +}; diff --git a/ChatSetAttr/build/src/classes/TimerManager.ts b/ChatSetAttr/build/src/classes/TimerManager.ts new file mode 100644 index 0000000000..ec3e7b6598 --- /dev/null +++ b/ChatSetAttr/build/src/classes/TimerManager.ts @@ -0,0 +1,26 @@ +export class TimerManager { + private static timers: Map = new Map(); + + public static start(id: string, duration: number, callback: () => void): void { + if (this.timers.has(id)) { + this.stop(id); + } + const timer = setTimeout(() => { + callback(); + this.timers.delete(id); + }, duration); + this.timers.set(id, timer); + }; + + public static stop(id: string): void { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + }; + + public static isRunning(id: string): boolean { + return this.timers.has(id); + }; +}; \ No newline at end of file diff --git a/ChatSetAttr/build/src/classes/UUIDs.ts b/ChatSetAttr/build/src/classes/UUIDs.ts new file mode 100644 index 0000000000..7c6a2f03b9 --- /dev/null +++ b/ChatSetAttr/build/src/classes/UUIDs.ts @@ -0,0 +1,53 @@ +export class UUID { + private static base64Chars = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; + private static base = 64; + private static previousTime = 0; + private static counter = new Array(12).fill(0); + + private static toBase64(num: number, length: number) { + let result = ""; + for (let i = 0; i < length; i++) { + result = this.base64Chars[num % this.base] + result; + num = Math.floor(num / this.base); + } + return result; + }; + + private static generateRandomBase64(length: number) { + let result = ""; + for (let i = 0; i < length; i++) { + result += this.base64Chars[Math.floor(Math.random() * this.base)]; + } + return result; + }; + + public static generateUUID(): string { + const currentTime = Date.now(); + const timeBase64 = this.toBase64(currentTime, 8); + let randomOrCounterBase64 = ''; + + if (currentTime === this.previousTime) { + // Increment the counter + 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 { + // Generate new random values + randomOrCounterBase64 = this.generateRandomBase64(12); + this.counter.fill(0); // Reset the counter + this.previousTime = currentTime; + } + + return timeBase64 + randomOrCounterBase64; + }; + + public static generateRowID(): string { + return this.generateUUID().replace(/_/g, "Z"); + }; +}; diff --git a/ChatSetAttr/build/src/main.ts b/ChatSetAttr/build/src/main.ts new file mode 100644 index 0000000000..f886018b5d --- /dev/null +++ b/ChatSetAttr/build/src/main.ts @@ -0,0 +1,12 @@ +import { ChatSetAttr } from "./classes/ChatSetAttr"; + +on("ready", () => { + new ChatSetAttr(); + // ChatSetAttr.checkInstall(); +}); + +export default { + registerObserver: ChatSetAttr.registerObserver, + unregisterObserver: ChatSetAttr.unregisterObserver, +}; + diff --git a/ChatSetAttr/build/src/utils/asyncTimeout.ts b/ChatSetAttr/build/src/utils/asyncTimeout.ts new file mode 100644 index 0000000000..3451978a94 --- /dev/null +++ b/ChatSetAttr/build/src/utils/asyncTimeout.ts @@ -0,0 +1,7 @@ +export function asyncTimeout(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +}; diff --git a/ChatSetAttr/build/src/utils/checkOpt.ts b/ChatSetAttr/build/src/utils/checkOpt.ts new file mode 100644 index 0000000000..19b0afd592 --- /dev/null +++ b/ChatSetAttr/build/src/utils/checkOpt.ts @@ -0,0 +1,6 @@ +import type { Flag, Option } from "../classes/InputParser"; + +export function checkOpt(options: Option[], type: Flag | string): boolean { + return options.some(opt => opt.name === type); +}; + diff --git a/ChatSetAttr/build/src/utils/createStyle.ts b/ChatSetAttr/build/src/utils/createStyle.ts new file mode 100644 index 0000000000..ffc20f7236 --- /dev/null +++ b/ChatSetAttr/build/src/utils/createStyle.ts @@ -0,0 +1,12 @@ +export function convertCamelToKebab(camel: string): string { + return camel.replace(/([a-z])([A-Z])/g, `$1-$2`).toLowerCase(); +}; + +export function createStyle(styleObject: Record) { + let style = ``; + for (const [key, value] of Object.entries(styleObject)) { + const kebabKey = convertCamelToKebab(key); + style += `${kebabKey}: ${value};`; + } + return style; +}; \ No newline at end of file diff --git a/ChatSetAttr/build/src/utils/evalAttributes.ts b/ChatSetAttr/build/src/utils/evalAttributes.ts new file mode 100644 index 0000000000..6d4747ed81 --- /dev/null +++ b/ChatSetAttr/build/src/utils/evalAttributes.ts @@ -0,0 +1,34 @@ +import type { DeltasObject } from "../classes/APIWrapper"; +import type { ErrorResponse } from "../classes/ErrorManager"; + +export function evalAttributes(values: DeltasObject): [DeltasObject, ErrorResponse] { + const evaluatedValues: DeltasObject = {}; + const messages: string[] = []; + const errors: string[] = []; + for (const [key, value] of Object.entries(values)) { + let evaledValue: any; + let evaledMax: any; + + try { + evaledValue = eval(value.value ?? ""); + } catch (error) { + errors.push(`Error evaluating expression for ${key}: ${value.value}`); + evaledValue = value.value; + } + + if (value.max) { + try { + evaledMax = eval(value.max); + } catch (error) { + errors.push(`Error evaluating expression for ${key}: ${value.max}`); + evaledMax = value.max; + } + } + + evaluatedValues[key] = { + value: evaledValue, + max: evaledMax, + }; + } + return [evaluatedValues, { messages, errors }]; +}; \ No newline at end of file diff --git a/ChatSetAttr/build/src/utils/filterByPermission.ts b/ChatSetAttr/build/src/utils/filterByPermission.ts new file mode 100644 index 0000000000..aec21eb1a5 --- /dev/null +++ b/ChatSetAttr/build/src/utils/filterByPermission.ts @@ -0,0 +1,24 @@ +import type { ErrorResponse } from "src/classes/APIWrapper"; + +export function filterByPermission( + playerID: string, + characters: Roll20Character[], +): [Roll20Character[], ErrorResponse] { + const errors: string[] = []; + const messages: string[] = []; + const validTargets: Roll20Character[] = []; + + for (const character of characters) { + const isGM = playerIsGM(playerID); + const ownedBy = character.get("controlledby"); + const ownedByArray = ownedBy.split(",").map((id) => id.trim()); + const isOwner = ownedByArray.includes(playerID); + const hasPermission = isOwner || isGM; + if (!hasPermission) { + continue; + } + validTargets.push(character); + } + + return [validTargets, { messages, errors }]; +}; \ No newline at end of file diff --git a/ChatSetAttr/build/tests/integration/config.test.ts b/ChatSetAttr/build/tests/integration/config.test.ts new file mode 100644 index 0000000000..fda6aea8a5 --- /dev/null +++ b/ChatSetAttr/build/tests/integration/config.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { ChatSetAttr } from "../../src/classes/ChatSetAttr"; + +describe("Modern ChatSetAttr Configuration Tests", () => { + // Set up the test environment before each test + beforeEach(() => { + setupTestEnvironment(); + new ChatSetAttr(); + }); + + // Reset configuration after each test + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should toggle player modification permissions", () => { + // Arrange + const initialValue = state.ChatSetAttr.playersCanModify; + + // Act + executeCommand("!setattr-config --players-can-modify", [], { playerId: "gm123" }); + + // Assert + expect(state.ChatSetAttr.playersCanModify).toBe(!initialValue); + }); + + it("should toggle player evaluation permissions", () => { + // Arrange + const initialValue = state.ChatSetAttr.playersCanEvaluate; + + // Act + executeCommand("!setattr-config --players-can-evaluate", [], { playerId: "gm123" }); + + // Assert + expect(state.ChatSetAttr.playersCanEvaluate).toBe(!initialValue); + }); + + it("should toggle worker usage", () => { + // Arrange + const initialValue = state.ChatSetAttr.useWorkers; + + // Act + executeCommand("!setattr-config --use-workers", [], { playerId: "gm123" }); + + // Assert + expect(state.ChatSetAttr.useWorkers).toBe(!initialValue); + }); + + it("should deny configuration access to non-GM players", async () => { + // Arrange + const { sendChat } = global; + const initialValues = { ...state.ChatSetAttr }; + + // Temporarily mock playerIsGM to return false + vi.mocked(playerIsGM).mockReturnValueOnce(false); + + // Act + executeCommand("!setattr-config --players-can-modify", [], { playerId: "player123" }); + + // Assert - config should not change + expect(state.ChatSetAttr.playersCanModify).toBe(initialValues.playersCanModify); + + // In previous versions, this would not have sent a message + // Now it should send a message indicating lack of permissions + await vi.waitFor(() => { + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringContaining("Only the GM can configure"), + undefined, + expect.objectContaining({ noarchive: true }) + ); + }); + }); + + it("should display current configuration settings", async () => { + // Arrange + const { sendChat } = global; + + // Set known state + state.ChatSetAttr.playersCanModify = true; + state.ChatSetAttr.playersCanEvaluate = false; + state.ChatSetAttr.useWorkers = true; + + // Act + executeCommand("!setattr-config", [], { playerId: "gm123" }); + + // Assert - check that configuration was displayed with correct values + await vi.waitFor(() => { + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringContaining("Current configuration:") + && expect.stringMatching(/ON.*playersCanModify/) + && expect.stringMatching(/OFF.*playersCanEvaluate/) + && expect.stringMatching(/ON.*useWorkers/), + undefined, + expect.objectContaining({ noarchive: true }) + ); + }); + }); + + it("should update from global config", () => { + // Arrange + const now = Math.floor(Date.now() / 1000); + + // Setup global config with newer timestamp + global.globalconfig = { + chatsetattr: { + lastsaved: now + 1000, // Future timestamp to ensure it's newer + "Players can modify all characters": "playersCanModify", + "Players can use --evaluate": "playersCanEvaluate", + "Trigger sheet workers when setting attributes": "useWorkers" + } + }; + + // Set known state (opposite of what we'll set in global config) + state.ChatSetAttr.playersCanModify = false; + state.ChatSetAttr.playersCanEvaluate = false; + state.ChatSetAttr.useWorkers = false; + state.ChatSetAttr.globalconfigCache.lastsaved = now; + + // Act + ChatSetAttr.checkInstall(); + + // Assert - settings should be updated from global config + expect(state.ChatSetAttr.playersCanModify).toBe(true); + expect(state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(state.ChatSetAttr.useWorkers).toBe(true); + expect(state.ChatSetAttr.globalconfigCache).toEqual(globalconfig.chatsetattr); + }); + + it("should respect useWorkers setting when updating attributes", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + const attr = createObj("attribute", { _characterid: "char1", name: "TestWorker", current: "10" }); + + // Create spy on setWithWorker and regular set + const setWithWorkerSpy = vi.spyOn(attr, "setWithWorker"); + + // Test with useWorkers = true + state.ChatSetAttr.useWorkers = true; + + // Act + executeCommand("!setattr --charid char1 --TestWorker|20"); + + // Assert - should use setWithWorker + await vi.waitFor(() => { + expect(setWithWorkerSpy).toHaveBeenCalled(); + }); + + // Reset and test with useWorkers = false + setWithWorkerSpy.mockClear(); + state.ChatSetAttr.useWorkers = false; + + // Act again + executeCommand("!setattr --charid char1 --TestWorker|30"); + + // Assert - should use regular set + await vi.waitFor(() => { + expect(setWithWorkerSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ChatSetAttr/build/tests/integration/integration.test.ts b/ChatSetAttr/build/tests/integration/integration.test.ts new file mode 100644 index 0000000000..213dfcd9c0 --- /dev/null +++ b/ChatSetAttr/build/tests/integration/integration.test.ts @@ -0,0 +1,1429 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { ChatSetAttr } from "../../src/classes/ChatSetAttr"; +import "../../vitest.globals"; +import type { MockAttribute } from "../../vitest.globals"; + +describe("Modern ChatSetAttr Integration Tests", () => { + // Set up the test environment before each test + beforeEach(() => { + global.setupTestEnvironment(); + new ChatSetAttr(); + }); + + // Cleanup after each test if needed + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Attribute Setting Commands", () => { + it("should set Strength to 15 for selected characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("graphic", { id: "token1", represents: "char1" }); + createObj("graphic", { id: "token2", represents: "char2" }); + + executeCommand("!setattr --sel --Strength|15", ["token1", "token2"]); + + await vi.waitFor(() => { + const char1Strength = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + const char2Strength = attributes.find(a => a._characterid === "char2" && a.name === "Strength"); + expect(char1Strength).toBeDefined(); + expect(char1Strength!.current).toBe("15"); + expect(char2Strength).toBeDefined(); + expect(char2Strength!.current).toBe("15"); + }); + }); + + it("should set HP and Dex for character named John", async () => { + createObj("character", { id: "john1", name: "John" }); + createObj("character", { id: "john2", name: "john" }); + createObj("character", { id: "char3", name: "NotJohn" }); + + executeCommand("!setattr --name John --HP|17|27 --Dex|10"); + + await vi.waitFor(() => { + const johnHP = attributes.find(a => a._characterid === "john1" && a.name === "HP"); + const johnDex = attributes.find(a => a._characterid === "john1" && a.name === "Dex"); + + expect(johnHP).toBeDefined(); + expect(johnHP!.current).toBe("17"); + expect(johnHP!.max).toBe("27"); + expect(johnDex).toBeDefined(); + expect(johnDex!.current).toBe("10"); + + const anotherJohnHP = attributes.find(a => a._characterid === "john2" && a.name === "HP"); + const notJohnHP = attributes.find(a => a._characterid === "char3" && a.name === "HP"); + expect(anotherJohnHP).toBeUndefined(); + expect(notJohnHP).toBeUndefined(); + }); + }); + + it("should set td attribute to d8 for all characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("character", { id: "char3", name: "Character 3" }); + + executeCommand("!setattr --all --td|d8"); + + await vi.waitFor(() => { + const char1TensionDie = attributes.find(a => a._characterid === "char1" && a.name === "td"); + const char2TensionDie = attributes.find(a => a._characterid === "char2" && a.name === "td"); + const char3TensionDie = attributes.find(a => a._characterid === "char3" && a.name === "td"); + + expect(char1TensionDie).toBeDefined(); + expect(char1TensionDie!.current).toBe("d8"); + expect(char2TensionDie).toBeDefined(); + expect(char2TensionDie!.current).toBe("d8"); + expect(char3TensionDie).toBeDefined(); + expect(char3TensionDie!.current).toBe("d8"); + }); + }); + + it("should add a new item to a repeating inventory section", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --fb-public --fb-header Aquiring Magic Item --fb-content The Cloak of Excellence from the chest by a character. --repeating_inventory_-CREATE_itemname|Cloak of Excellence --repeating_inventory_-CREATE_itemcount|1 --repeating_inventory_-CREATE_itemweight|3 --repeating_inventory_-CREATE_equipped|1 --repeating_inventory_-CREATE_itemmodifiers|Item Type: Wondrous item, AC +2, Saving Throws +1 --repeating_inventory_-CREATE_itemcontent|(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar.", ["token1"]); + + await vi.waitFor(() => { + expect(sendChat).toHaveBeenCalled(); + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Aquiring Magic Item") + ); + expect(feedbackCall).toBeDefined(); + + const nameAttr = attributes.find(a => a._characterid === "char1" && a.name.includes("itemname")); + expect(nameAttr).toBeDefined(); + + const repeatingRowId = nameAttr!.name.match(/repeating_inventory_([^_]+)_itemname/)?.[1]; + expect(repeatingRowId).toBeDefined(); + + type ItemAttrs = { + name?: MockAttribute; + count?: MockAttribute; + weight?: MockAttribute; + equipped?: MockAttribute; + modifiers?: MockAttribute; + content?: MockAttribute; + }; + + const itemAttrs: ItemAttrs = { + name: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemname`), + count: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemcount`), + weight: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemweight`), + equipped: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_equipped`), + modifiers: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemmodifiers`), + content: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemcontent`) + }; + + expect(itemAttrs.name).toBeDefined(); + expect(itemAttrs.name!.current).toBe("Cloak of Excellence"); + expect(itemAttrs.count!.current).toBe("1"); + expect(itemAttrs.weight!.current).toBe("3"); + expect(itemAttrs.equipped!.current).toBe("1"); + expect(itemAttrs.modifiers!.current).toBe("Item Type: Wondrous item, AC +2, Saving Throws +1"); + expect(itemAttrs.content!.current).toBe("(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar."); + }); + }); + + it("should process inline roll queries", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + inputQueue.push("15"); + inputQueue.push("20"); + + executeCommand("!setattr --charid char1 --Strength|[[?{Strength Value|10}]] --Dexterity|[[?{Dexterity Value|10}]]"); + + await vi.waitFor(() => { + const strAttr = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + const dexAttr = attributes.find(a => a._characterid === "char1" && a.name === "Dexterity"); + + expect(strAttr).toBeDefined(); + expect(strAttr!.current).toBe("15"); + expect(dexAttr).toBeDefined(); + expect(dexAttr!.current).toBe("20"); + }); + }); + + it("should process an inline command within a chat message", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("I cast a spell and !setattr --charid char1 --Mana|10!!!"); + + await vi.waitFor(() => { + const manaAttr = attributes.find(a => a._characterid === "char1" && a.name === "Mana"); + + expect(manaAttr).toBeDefined(); + expect(manaAttr!.current).toBe("10"); + }); + }); + + it("should use character IDs directly to set attributes", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + + executeCommand("!setattr --charid char1,char2 --Level|5"); + + await vi.waitFor(() => { + const char1Level = attributes.find(a => a._characterid === "char1" && a.name === "Level"); + const char2Level = attributes.find(a => a._characterid === "char2" && a.name === "Level"); + + expect(char1Level).toBeDefined(); + expect(char1Level!.current).toBe("5"); + expect(char2Level).toBeDefined(); + expect(char2Level!.current).toBe("5"); + }); + }); + + it("should set multiple attributes on multiple characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + + executeCommand("!setattr --charid char1,char2 --Class|Fighter --Level|5 --HP|30|30"); + + await vi.waitFor(() => { + const char1Class = attributes.find(a => a._characterid === "char1" && a.name === "Class"); + const char1Level = attributes.find(a => a._characterid === "char1" && a.name === "Level"); + const char1HP = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + + expect(char1Class).toBeDefined(); + expect(char1Class!.current).toBe("Fighter"); + expect(char1Level).toBeDefined(); + expect(char1Level!.current).toBe("5"); + expect(char1HP).toBeDefined(); + expect(char1HP!.current).toBe("30"); + expect(char1HP!.max).toBe("30"); + + const char2Class = attributes.find(a => a._characterid === "char2" && a.name === "Class"); + const char2Level = attributes.find(a => a._characterid === "char2" && a.name === "Level"); + const char2HP = attributes.find(a => a._characterid === "char2" && a.name === "HP"); + + expect(char2Class).toBeDefined(); + expect(char2Class!.current).toBe("Fighter"); + expect(char2Level).toBeDefined(); + expect(char2Level!.current).toBe("5"); + expect(char2HP).toBeDefined(); + expect(char2HP!.current).toBe("30"); + expect(char2HP!.max).toBe("30"); + }); + }); + }); + + describe("Attribute Modification Commands", () => { + it("should increase Strength by 5 for selected characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("character", { id: "char3", name: "Character 3" }); + createObj("attribute", { _characterid: "char1", name: "Strength", current: "10" }); + createObj("attribute", { _characterid: "char2", name: "Strength", current: "15" }); + createObj("attribute", { _characterid: "char3", name: "Strength", current: "Very big" }); + createObj("graphic", { id: "token1", represents: "char1" }); + createObj("graphic", { id: "token2", represents: "char2" }); + createObj("graphic", { id: "token3", represents: "char3" }); + + executeCommand("!setattr --sel --mod --Strength|5", ["token1", "token2", "token3"]); + + await vi.waitFor(() => { + const char1Strength = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + const char2Strength = attributes.find(a => a._characterid === "char2" && a.name === "Strength"); + const char3Strength = attributes.find(a => a._characterid === "char3" && a.name === "Strength"); + + expect(char1Strength).toBeDefined(); + expect(char1Strength!.current).toBe("15"); + expect(char2Strength).toBeDefined(); + expect(char2Strength!.current).toBe("20"); + expect(char3Strength).toBeDefined(); + expect(char3Strength!.current).toBe("Very big"); + + expect(sendChat).toHaveBeenCalled(); + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("is not number-valued") + ); + expect(errorCall).toBeDefined(); + }); + }); + + it("should handle --mod option for modifying attributes", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "Counter", current: "5" }); + createObj("attribute", { _characterid: "char1", name: "CounterMax", current: "3", max: "10" }); + + executeCommand("!modattr --charid char1 --Counter|2 --CounterMax|1|2"); + + await vi.waitFor(() => { + const counter = attributes.find(a => a._characterid === "char1" && a.name === "Counter"); + const counterMax = attributes.find(a => a._characterid === "char1" && a.name === "CounterMax"); + + expect(counter).toBeDefined(); + expect(counter!.current).toBe("7"); + expect(counterMax).toBeDefined(); + expect(counterMax!.current).toBe("4"); + expect(counterMax!.max).toBe("12"); + }); + }); + + it("should modify attributes using the !mod command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "30" }); + + executeCommand("!modattr --charid char1 --HP|5 --MP|-3"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("15"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("12"); + }); + }); + + it("should modify attributes with bounds using modbattr", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "Stamina", current: "1", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|5 --Stamina|-5"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + const stamina = attributes.find(a => a._characterid === "char1" && a.name === "Stamina"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("15"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("15"); + expect(stamina).toBeDefined(); + expect(stamina!.current).toBe("0"); + }); + }); + + it("should modify attributes with bounds using the !modb command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "10" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "8", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|-10"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("10"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("0"); + }); + }); + }); + + describe("Attribute Deletion and Reset Commands", () => { + it("should delete the gold attribute from all characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("attribute", { _characterid: "char1", name: "gold", current: "100" }); + createObj("attribute", { _characterid: "char2", name: "gold", current: "200" }); + createObj("attribute", { _characterid: "char1", name: "silver", current: "50" }); + + executeCommand("!delattr --all --gold"); + + await vi.waitFor(() => { + expect(attributes.find(a => a._characterid === "char1" && a.name === "gold")).toBeUndefined(); + expect(attributes.find(a => a._characterid === "char2" && a.name === "gold")).toBeUndefined(); + expect(attributes.find(a => a._characterid === "char1" && a.name === "silver")).toBeDefined(); + }); + }); + + it("should reset Ammo to its maximum value", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "Ammo", current: "3", max: "20" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --Ammo|%Ammo_max%", ["token1"]); + + await vi.waitFor(() => { + const ammo = attributes.find(a => a._characterid === "char1" && a.name === "Ammo"); + expect(ammo).toBeDefined(); + expect(ammo!.current).toBe("20"); + }); + }); + + it("should reset attributes to their maximum values with resetattr", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "5", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100", max: "" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + const xp = attributes.find(a => a._characterid === "char1" && a.name === "XP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("20"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("15"); + expect(xp).toBeDefined(); + expect(xp!.current).toBe("100"); + }); + }); + + it("should reset attributes using the !reset command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "10", max: "30" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + const xp = attributes.find(a => a._characterid === "char1" && a.name === "XP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("20"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("30"); + expect(xp).toBeDefined(); + expect(xp!.current).toBe("100"); + }); + }); + + it("should delete attributes using the !del command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ToDelete1", current: "10" }); + createObj("attribute", { _characterid: "char1", name: "ToDelete2", current: "20" }); + createObj("attribute", { _characterid: "char1", name: "ToKeep", current: "30" }); + + executeCommand("!delattr --charid char1 --ToDelete1 --ToDelete2"); + + await vi.waitFor(() => { + const toDelete1 = attributes.find(a => a._characterid === "char1" && a.name === "ToDelete1"); + const toDelete2 = attributes.find(a => a._characterid === "char1" && a.name === "ToDelete2"); + const toKeep = attributes.find(a => a._characterid === "char1" && a.name === "ToKeep"); + + expect(toDelete1).toBeUndefined(); + expect(toDelete2).toBeUndefined(); + expect(toKeep).toBeDefined(); + expect(toKeep!.current).toBe("30"); + }); + }); + }); + + describe("Targeting Options", () => { + it("should set attributes for GM-only characters with allgm targeting mode", async () => { + createObj("character", { id: "gmchar1", name: "GM Character 1" }); + createObj("character", { id: "gmchar2", name: "GM Character 2" }); + createObj("character", { id: "playerchar", name: "Player Character", controlledby: "player123" }); + + executeCommand("!setattr --allgm --Status|NPC"); + + await vi.waitFor(() => { + const gmChar1Status = attributes.find(a => a._characterid === "gmchar1" && a.name === "Status"); + const gmChar2Status = attributes.find(a => a._characterid === "gmchar2" && a.name === "Status"); + const playerCharStatus = attributes.find(a => a._characterid === "playerchar" && a.name === "Status"); + + expect(gmChar1Status).toBeDefined(); + expect(gmChar1Status!.current).toBe("NPC"); + + expect(gmChar2Status).toBeDefined(); + expect(gmChar2Status!.current).toBe("NPC"); + + expect(playerCharStatus).toBeUndefined(); + }); + }); + + it("should set attributes for player-controlled characters with allplayers targeting mode", async () => { + createObj("character", { id: "playerchar1", name: "Player Character 1", controlledby: "player123" }); + createObj("character", { id: "playerchar2", name: "Player Character 2", controlledby: "player456" }); + createObj("character", { id: "gmchar", name: "GM Character" }); + + executeCommand("!setattr --allplayers --CharType|PC"); + + await vi.waitFor(() => { + const playerChar1Type = attributes.find(a => a._characterid === "playerchar1" && a.name === "CharType"); + const playerChar2Type = attributes.find(a => a._characterid === "playerchar2" && a.name === "CharType"); + const gmCharType = attributes.find(a => a._characterid === "gmchar" && a.name === "CharType"); + + expect(playerChar1Type).toBeDefined(); + expect(playerChar1Type!.current).toBe("PC"); + + expect(playerChar2Type).toBeDefined(); + expect(playerChar2Type!.current).toBe("PC"); + + expect(gmCharType).toBeUndefined(); + }); + }); + }); + + describe("Attribute Value Processing", () => { + it("should evaluate expressions using attribute references", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "attr1", current: "3" }); + createObj("attribute", { _characterid: "char1", name: "attr2", current: "2" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%", ["token1"]); + + await vi.waitFor(() => { + const attr3 = attributes.find(a => a._characterid === "char1" && a.name === "attr3"); + expect(attr3).toBeDefined(); + expect(attr3!.current).toBe("11"); + }); + }); + + it("should handle --replace option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("!setattr --replace --charid char1 --Description|This text has characters; and should be `replaced`"); + + await vi.waitFor(() => { + const desc = attributes.find(a => a._characterid === "char1" && a.name === "Description"); + expect(desc).toBeDefined(); + expect(desc!.current).toBe("This text has [special] characters? and should be @replaced@"); + }); + }); + + it("should honor multiple modifier flags used together", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ExistingAttr", current: "10" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --evaluate --ExistingAttr|20*2"); + + await vi.waitFor(() => { + const existingAttr = attributes.find(a => a._characterid === "char1" && a.name === "ExistingAttr"); + expect(existingAttr).toBeDefined(); + expect(existingAttr!.current).toBe("40"); + + expect(sendChat).not.toHaveBeenCalled(); + }); + }); + }); + + describe("Configuration Options", () => { + const originalConfig = { + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: false + }; + + afterEach(() => { + state.ChatSetAttr.playersCanModify = originalConfig.playersCanModify; + state.ChatSetAttr.playersCanEvaluate = originalConfig.playersCanEvaluate; + state.ChatSetAttr.useWorkers = originalConfig.useWorkers; + }); + + it("should handle configuration commands", async () => { + executeCommand("!setattr-config --players-can-modify", [], { playerId: "gm123" }); + + await vi.waitFor(() => { + expect(state.ChatSetAttr.playersCanModify).toBe(!originalConfig.playersCanModify); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(new RegExp(/ON.*playersCanModify/, "g")), + undefined, + expect.anything() + ); + }); + + executeCommand("!setattr-config --players-can-evaluate", [], { playerId: "gm123" }); + + await vi.waitFor(() => { + expect(state.ChatSetAttr.playersCanEvaluate).toBe(!originalConfig.playersCanEvaluate); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(new RegExp(/ON.*playersCanEvaluate/, "g")), + undefined, + expect.anything() + ); + }); + }); + + it("should update configuration and display current settings", async () => { + const originalUseWorkers = state.ChatSetAttr.useWorkers; + + executeCommand("!setattr-config --use-workers", [], { playerId: "gm123" }); + + await vi.waitFor(() => { + expect(state.ChatSetAttr.useWorkers).toBe(!originalUseWorkers); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(new RegExp(/ON.*useWorkers/, "g")), + undefined, + expect.anything() + ); + }); + }); + + it("should toggle the use-workers configuration setting", async () => { + const originalUseWorkers = state.ChatSetAttr.useWorkers; + + executeCommand("!setattr-config --use-workers", [], { playerId: "gm123" }); + + await vi.waitFor(() => { + expect(state.ChatSetAttr.useWorkers).toBe(!originalUseWorkers); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(new RegExp(/ON.*useWorkers/, "g")), + undefined, + expect.anything() + ); + }); + }); + + it("should respect player permissions", async () => { + createObj("character", { id: "char1", name: "Player Character", controlledby: "player123" }); + state.ChatSetAttr.playersCanModify = false; + + vi.mocked(playerIsGM).mockReturnValueOnce(false); + executeCommand("!setattr --charid char1 --Strength|18", [], { playerId: "differentPlayer456" }); + + await vi.waitFor(() => { + const strength = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + expect(strength).toBeUndefined(); + + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/No valid targets found/), + undefined, + expect.anything() + ) + }); + }); + }); + + describe("Feedback Options", () => { + it("should send public feedback with --fb-public option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-public --Attribute|42"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Attribute"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("42"); + + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/Created attribute Attribute<\/strong>/), + undefined, + expect.anything() + ); + }); + }); + + it("should use custom sender with --fb-from option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-from Wizard --Spell|Fireball"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Spell"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("Fireball"); + + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "Wizard", + expect.stringMatching(/Created attribute Spell<\/strong>/), + undefined, + expect.anything() + ); + }); + }); + + it("should use custom header with --fb-header option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-header Magic Item Acquired --Item|Staff of Power"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Item"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("Staff of Power"); + + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/Magic Item Acquired/), + undefined, + expect.anything() + ); + }); + }); + + it("should use custom content with --fb-content option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-header 'Level Up' --fb-content '_CHARNAME_ is now level _CUR0_!' --Level|5"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Level"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("5"); + + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/Level Up.*Character 1 is now level 5!/), + undefined, + expect.anything() + ); + }); + }); + + it("should combine all feedback options together", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-public --fb-from Dungeon_Master --fb-header \"Combat Stats Updated\" --fb-content \"_CHARNAME_'s health increased to _CUR0_!\" --HP|25"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("25"); + + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "Dungeon_Master", + expect.stringMatching(/Combat Stats Updated.*Character 1's health increased to 25!/g), + undefined, + expect.anything() + ); + }); + }); + }); + + describe("Message Suppression Options", () => { + it("should suppress feedback messages when using the --silent option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --TestAttr|42"); + + await vi.waitFor(() => { + const testAttr = attributes.find(a => a._characterid === "char1" && a.name === "TestAttr"); + expect(testAttr).toBeDefined(); + expect(testAttr!.current).toBe("42"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Setting TestAttr") + ); + expect(feedbackCall).toBeUndefined(); + }); + }); + + it("should suppress error messages when using the --mute option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --mute --mod --NonNumeric|abc --Value|5"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Error") + ); + expect(errorCall).toBeUndefined(); + }); + }); + + it("should not create attributes when using the --nocreate option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("!setattr --charid char1 --nocreate --NewAttribute|50"); + + await vi.waitFor(() => { + const newAttr = attributes.find(a => a._characterid === "char1" && a.name === "NewAttribute"); + expect(newAttr).toBeUndefined(); + + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/ChatSetAttr Error/) && expect.stringMatching(/NewAttribute<\/strong> does not exist for character/), + undefined, + expect.anything() + ); + }); + }); + }); + + describe("Observer Events", () => { + it("should observe attribute additions with registered observers", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("add", mockObserver); + + executeCommand("!setattr --charid char1 --NewAttribute|42"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + + const calls = mockObserver.mock.calls; + const hasNewAttributeCall = calls.some(call => { + const attr = call[0]; + return attr && attr.name === "NewAttribute" && attr.current === "42"; + }); + + expect(hasNewAttributeCall).toBe(true); + }); + }); + + it("should observe attribute changes with registered observers", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ExistingAttr", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("change", mockObserver); + + executeCommand("!setattr --charid char1 --ExistingAttr|20"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + + const calls = mockObserver.mock.calls; + const hasChangeCall = calls.some(call => { + const attr = call[0]; + const prev = call[1]; + return attr && attr.name === "ExistingAttr" && attr.current === "20" && prev && prev.current === "10"; + }); + + expect(hasChangeCall).toBe(true); + }); + }); + + it("should observe attribute deletions with registered observers", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "DeleteMe", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("destroy", mockObserver); + + executeCommand("!delattr --charid char1 --DeleteMe"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + + const calls = mockObserver.mock.calls; + const hasDeleteCall = calls.some(call => { + const attr = call[0]; + return attr && attr.name === "DeleteMe" && attr.current === "10"; + }); + + expect(hasDeleteCall).toBe(true); + }); + }); + }); + + describe("Repeating Sections", () => { + it("should create repeating section attributes", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("!setattr --charid char1 --repeating_weapons_-CREATE_weaponname|Longsword --repeating_weapons_-CREATE_damage|1d8"); + + await vi.waitFor(() => { + const nameAttr = attributes.find(a => a._characterid === "char1" && a.name.includes("weaponname")); + expect(nameAttr).toBeDefined(); + + const rowId = nameAttr!.name.match(/repeating_weapons_([^_]+)_weaponname/)?.[1]; + expect(rowId).toBeDefined(); + + const damageAttr = attributes.find(a => a.name === `repeating_weapons_${rowId}_damage`); + expect(damageAttr).toBeDefined(); + + expect(nameAttr!.current).toBe("Longsword"); + expect(damageAttr!.current).toBe("1d8"); + }); + }); + + it("should adjust number of uses remaining for an ability", async () => { + inputQueue.push("2"); + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "repeating_ability_-mm2dso76ircbi5dtea3_used", current: "3" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --repeating_ability_-mm2dso76ircbi5dtea3_used|[[?{How many are left?|0}]]", ["token1"]); + + await vi.waitFor(() => { + const usedAttr = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_ability_-mm2dso76ircbi5dtea3_used" + ); + + expect(usedAttr).toBeDefined(); + expect(usedAttr!.current).toBe("2"); + }); + }); + + it("should toggle a buff on or off", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle", current: "0" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle|[[1-@{selected|repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle}]]", ["token1"]); + + await vi.waitFor(() => { + const buffAttr = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle" + ); + + expect(buffAttr).toBeDefined(); + expect(buffAttr!.current).toBe("1"); + + executeCommand("!setattr --sel --repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle|[[1-@{selected|repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle}]]", ["token1"]); + + return new Promise(resolve => setTimeout(resolve, 100)); + }); + + const buffAttr = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle" + ); + + expect(buffAttr).toBeDefined(); + expect(buffAttr!.current).toBe("0"); + }); + + const createRepeatingObjects = () => { + createObj("character", { id: "char1", name: "Character 1" }); + + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-abc123_weaponname", + current: "Longsword" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-abc123_damage", + current: "1d8" + }); + + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-def456_weaponname", + current: "Dagger" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-def456_damage", + current: "1d4" + }); + + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-ghi789_weaponname", + current: "Bow" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-ghi789_damage", + current: "1d6" + }); + + createObj("attribute", { + _characterid: "char1", + name: "_reporder_" + "repeating_weapons", + current: "-abc123,-def456,-ghi789" + }); + + createObj("graphic", { id: "token1", represents: "char1" }); + }; + + + it("should handle deleting repeating section attributes referenced by index", async () => { + // Arrange + createRepeatingObjects(); + + const secondWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Dagger" + ); + expect(secondWeapon).toBeDefined(); + + // Act - Delete the second weapon ($1 index) by name + executeCommand("!delattr --sel --repeating_weapons_$1_weaponname", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + // Assert - First weapon should still exist + const firstWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Longsword" + ); + expect(firstWeapon).toBeDefined(); + + // Second weapon (Dagger) should be deleted + const secondWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Dagger" + ); + expect(secondWeapon).toBeUndefined(); + + // Third weapon should still exist + const thirdWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Bow" + ); + expect(thirdWeapon).toBeDefined(); + }); + }); + + it("should handle modifying repeating section attributes referenced by index", async () => { + // Arrange + createRepeatingObjects(); + + // Act - Modify the damage of the first weapon ($0 index) + executeCommand("!setattr --sel --nocreate --repeating_weapons_$0_damage|2d8", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + // Assert - First weapon damage should be updated + const firstWeaponDamage = attributes.find(a => + a._characterid === "char1" && + a.name.includes("damage") && + a.name.includes("weapons") && + a.current === "2d8" + ); + expect(firstWeaponDamage).toBeDefined(); + }); + }); + + it("should handle creating new repeating section attributes after deletion", async () => { + // Arrange - Create initial repeating section attributes + createRepeatingObjects(); + + // Act - Create a new attribute in the last weapon ($1 index after deletion) + executeCommand("!setattr --sel --repeating_weapons_$1_newlycreated|5", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + const repOrder = attributes.find(a => + a._characterid === "char1" && + a.name === "_reporder_repeating_weapons" + ); + const order = repOrder!.get("current")!.split(","); + expect(order.length).toBe(3); + + const attackBonus = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_weapons_-def456_newlycreated" + ); + + expect(attackBonus).toBeDefined(); + expect(attackBonus!.current).toBe("5"); + }); + }); + + it("should handle deleting all repeating attributes on a rowid by index", async () => { + // Arrange - Create repeating inventory items + createObj("character", { id: "char1", name: "Character 1" }); + + // Create first inventory item with multiple attributes + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-abc123_itemname", + current: "Potion of Healing" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-abc123_itemcount", + current: "3" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-abc123_itemweight", + current: "0.5" + }); + + // Create second inventory item + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-def456_itemname", + current: "Longsword" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-def456_itemweight", + current: "3" + }); + + // Set up the reporder + createObj("attribute", { + _characterid: "char1", + name: "_reporder_repeating_inventory", + current: "-abc123,-def456" + }); + + // Create token representing character + createObj("graphic", { id: "token1", represents: "char1" }); + + // Verify initial setup + const firstItemName = attributes.find(a => + a._characterid === "char1" && + a.name.includes("itemname") && + a.current === "Potion of Healing" + ); + expect(firstItemName).toBeDefined(); + + // Act - Delete entire first row ($0 index) including all its attributes + executeCommand("!delattr --sel --repeating_inventory_-abc123", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + // Assert - First item should be completely deleted (all attributes) + const firstItemName = attributes.find(a => + a._characterid === "char1" && + a.name.includes("repeating_inventory_-abc123") + ); + expect(firstItemName).toBeUndefined(); + + const firstItemCount = attributes.find(a => + a._characterid === "char1" && + a.name.includes("repeating_inventory_-abc123_itemcount") + ); + expect(firstItemCount).toBeUndefined(); + + const firstItemWeight = attributes.find(a => + a._characterid === "char1" && + a.name.includes("repeating_inventory_-abc123_itemweight") + ); + expect(firstItemWeight).toBeUndefined(); + + // Second item should still exist + const secondItemName = attributes.find(a => + a._characterid === "char1" && + a.name.includes("itemname") && + a.current === "Longsword" + ); + expect(secondItemName).toBeDefined(); + }); + }); + + it("should handle deleting all repeating attributes on a rowid by index", async () => { + // Arrange - Create repeating inventory items + createObj("character", { id: "char1", name: "Character 1" }); + + // Create first inventory item with multiple attributes + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-abc123_itemname", + current: "Potion of Healing" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-abc123_itemcount", + current: "3" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-abc123_itemweight", + current: "0.5" + }); + + // Create second inventory item + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-def456_itemname", + current: "Longsword" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_inventory_-def456_itemweight", + current: "3" + }); + + // Set up the reporder + createObj("attribute", { + _characterid: "char1", + name: "_reporder_repeating_inventory", + current: "-abc123,-def456" + }); + + // Create token representing character + createObj("graphic", { id: "token1", represents: "char1" }); + + // Verify initial setup + const firstItemName = attributes.find(a => + a._characterid === "char1" && + a.name.includes("itemname") && + a.current === "Potion of Healing" + ); + expect(firstItemName).toBeDefined(); + + // Act - Delete entire first row ($0 index) including all its attributes + executeCommand("!delattr --sel --repeating_inventory_$0", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + // Assert - First item should be completely deleted (all attributes) + const firstItemName = attributes.find(a => + a._characterid === "char1" && + a.name.includes("repeating_inventory_-abc123") + ); + expect(firstItemName).toBeUndefined(); + + const firstItemCount = attributes.find(a => + a._characterid === "char1" && + a.name.includes("repeating_inventory_-abc123_itemcount") + ); + expect(firstItemCount).toBeUndefined(); + + const firstItemWeight = attributes.find(a => + a._characterid === "char1" && + a.name.includes("repeating_inventory_-abc123_itemweight") + ); + expect(firstItemWeight).toBeUndefined(); + + // Second item should still exist + const secondItemName = attributes.find(a => + a._characterid === "char1" && + a.name.includes("itemname") && + a.current === "Longsword" + ); + expect(secondItemName).toBeDefined(); + }); + }); + }); + + describe("Delayed Processing", () => { + it("should process characters sequentially with delays", async () => { + vi.useFakeTimers(); + + // Create multiple characters + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("character", { id: "char3", name: "Character 3" }); + + // Set up spy on setTimeout to track when it's called + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + // Execute a command that sets attributes on all three characters + executeCommand("!setattr --charid char1,char2,char3 --TestAttr|42"); + vi.runAllTimers(); + + // All three characters should eventually get their attributes + await vi.waitFor(() => { + const char1Attr = attributes.find(a => a._characterid === "char1" && a.name === "TestAttr"); + const char2Attr = attributes.find(a => a._characterid === "char2" && a.name === "TestAttr"); + const char3Attr = attributes.find(a => a._characterid === "char3" && a.name === "TestAttr"); + + expect(char1Attr).toBeDefined(); + expect(char2Attr).toBeDefined(); + expect(char3Attr).toBeDefined(); + + expect(char1Attr!.current).toBe("42"); + expect(char2Attr!.current).toBe("42"); + expect(char3Attr!.current).toBe("42"); + }); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(4); + }); + + it("should notify about delays when processing characters", async () => { + vi.useFakeTimers(); + const actualCommand = setTimeout; + vi.spyOn(global, "setTimeout").mockImplementation((callback, delay, ...args) => { + if (delay === 8000) { + // Simulate the delay notification + callback(); + } + return actualCommand(callback, delay, ...args); + }); + for (let i = 1; i <= 50; i++) { + createObj("character", { id: `char${i}`, name: `Character ${i}` }); + } + // Execute a command that sets attributes on multiple characters + executeCommand("!setattr --all --TestAttr|42"); + + // Wait for the notification to be called + vi.runAllTimers(); + await vi.waitFor(() => { + expect(sendChat).toBeCalledTimes(2); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/long time to execute/g), + undefined, + expect.objectContaining({ + noarchive: true, + }) + ); + }); + }); + }); + + describe("Inline Roll Processing", () => { + it("should process simple inline rolls in attribute values", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + // Act - use executeCommand directly with just the content string + executeCommand("!setattr --sel --food|[[1d4]]", ["token1"]); + + // Assert + await vi.waitFor(() => { + const foodAttr = attributes.find(a => a._characterid === "char1" && a.name === "food"); + expect(foodAttr).toBeDefined(); + expect(foodAttr!.current).toBe("2"); + }); + }); + + it("should process inline rolls in both value and max", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + + // Act + executeCommand("!setattr --charid char1 --strength|[[1d6+2]]|[[2d8]]"); + + // Assert + await vi.waitFor(() => { + const strengthAttr = attributes.find(a => a._characterid === "char1" && a.name === "strength"); + expect(strengthAttr).toBeDefined(); + expect(strengthAttr!.current).toBe("5"); + expect(strengthAttr!.max).toBe("8"); + }); + }); + + it("should process multiple inline rolls within a single attribute value", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + + // Act + executeCommand("!setattr --charid char1 --damage|[[1d6]]+[[1d8]]+3"); + + // Assert + await vi.waitFor(() => { + const damageAttr = attributes.find(a => a._characterid === "char1" && a.name === "damage"); + expect(damageAttr).toBeDefined(); + expect(damageAttr!.current).toBe("3+4+3"); + }); + }); + + it("should evaluate expressions with inline rolls", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + + // Act + executeCommand("!setattr --charid char1 --evaluate --damage|[[1d6]]+[[1d8]]+3"); + + // Assert + await vi.waitFor(() => { + const damageAttr = attributes.find(a => a._characterid === "char1" && a.name === "damage"); + expect(damageAttr).toBeDefined(); + expect(damageAttr!.current).toBe("10"); // 3+4+3 = 10 (evaluated) + }); + }); + + it("should process complex templates with embedded inline commands", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "sanity", current: "50" }); + createObj("attribute", { _characterid: "char1", name: "corruption", current: "5" }); + + // Act + executeCommand("&{template:default} {{name=Cthulhu}} !modattr --silent --charid char1 --sanity|-{{Sanity damage=[[2d10+2]]}} --corruption|{{Corruption=Corruption increases by [[1]]}}!!! {{description=Text}}"); + + // Assert + await vi.waitFor(() => { + const sanityAttr = attributes.find(a => a._characterid === "char1" && a.name === "sanity"); + const corruptionAttr = attributes.find(a => a._characterid === "char1" && a.name === "corruption"); + + expect(sanityAttr).toBeDefined(); + expect(sanityAttr!.current).toBe("38"); // 50-12=38 + + expect(corruptionAttr).toBeDefined(); + expect(corruptionAttr!.current).toBe("6"); // 5+1=6 + }); + }); + + it("should handle nested inline rolls with mixed content", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + + // Act + executeCommand("!setattr --charid char1 --total|[[2d10]] --bonus|[[3d6]]"); + + // Assert + await vi.waitFor(() => { + const totalAttr = attributes.find(a => a._characterid === "char1" && a.name === "total"); + expect(totalAttr).toBeDefined(); + expect(totalAttr!.current).toBe("10"); + const bonusAttr = attributes.find(a => a._characterid === "char1" && a.name === "bonus"); + expect(bonusAttr).toBeDefined(); + expect(bonusAttr!.current).toBe("9"); + }); + }); + + it("should handle inline rolls in repeating attributes", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + + // Act + executeCommand("!setattr --charid char1 --repeating_attacks_-CREATE_damage|[[2d6]]+3"); + + // Assert + await vi.waitFor(() => { + const damageAttr = attributes.find(a => a._characterid === "char1" && a.name.includes("repeating_attacks") && a.name.includes("damage")); + expect(damageAttr).toBeDefined(); + expect(damageAttr!.current).toBe("6+3"); + }); + }); + + it("should handle inline rolls combined with attribute references", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "level", current: "5" }); + + // Act + executeCommand("!setattr --charid char1 --evaluate --formula|@{level}+[[1d6]]"); + + // Assert + await vi.waitFor(() => { + const formulaAttr = attributes.find(a => a._characterid === "char1" && a.name === "formula"); + expect(formulaAttr).toBeDefined(); + expect(formulaAttr!.current).toBe("8"); + }); + }); + + it("should process inline rolls in feedback parameter values", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + // Act + executeCommand("!setattr --charid char1 --fb-header Action Result: [[1d20]] --strength|15"); + + // Assert + await vi.waitFor(() => { + const strengthAttr = attributes.find(a => a._characterid === "char1" && a.name === "strength"); + expect(strengthAttr).toBeDefined(); + expect(strengthAttr!.current).toBe("15"); + + expect(sendChat).toHaveBeenCalled(); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/Action Result: 10/), + undefined, + expect.anything() + ); + }); + }); + }); +}); diff --git a/ChatSetAttr/build/tests/integration/observer.test.ts b/ChatSetAttr/build/tests/integration/observer.test.ts new file mode 100644 index 0000000000..ecc7062b5b --- /dev/null +++ b/ChatSetAttr/build/tests/integration/observer.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ChatSetAttr } from '../../src/classes/ChatSetAttr'; + +describe('Modern ChatSetAttr Observer Tests', () => { + // Set up the test environment before each test + beforeEach(() => { + global.setupTestEnvironment(); + new ChatSetAttr(); + }); + + // Cleanup after each test + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should register and call observers for attribute creation", async () => { + // Arrange + const addObserver = vi.fn(); + const changeObserver = vi.fn(); + const destroyObserver = vi.fn(); + + // Create a character to test with + createObj("character", {id: "char1", name: "Character 1"}); + + // Register observers for all event types + ChatSetAttr.registerObserver("add", addObserver); + ChatSetAttr.registerObserver("change", changeObserver); + ChatSetAttr.registerObserver("destroy", destroyObserver); + + // Act - create a new attribute + global.executeCommand("!setattr --charid char1 --NewAttr|42"); + + // Assert + await vi.waitFor(() => { + // Should call add observer but not change or destroy + expect(addObserver).toHaveBeenCalled(); + // it also, perhaps incorrectly, calls the change observer + expect(changeObserver).toHaveBeenCalled(); + expect(destroyObserver).not.toHaveBeenCalled(); + + // Check observer was called with correct attribute + const call = addObserver.mock.calls[0]; + expect(call[0].name).toBe("NewAttr"); + expect(call[0].current).toBe("42"); + }); + }); + + it("should register and call observers for attribute changes", async () => { + // Arrange + const addObserver = vi.fn(() => {}); + const changeObserver = vi.fn(); + + // Create a character and existing attribute + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ExistingAttr", current: "10" }); + + // Register observers + ChatSetAttr.registerObserver("add", addObserver); + ChatSetAttr.registerObserver("change", changeObserver); + + // Act - modify the existing attribute + global.executeCommand("!setattr --charid char1 --ExistingAttr|20"); + + // Assert + await vi.waitFor(() => { + // Should call change observer but not add observer + expect(addObserver).not.toHaveBeenCalled(); + expect(changeObserver).toHaveBeenCalled(); + + // Check observer was called with correct attribute and previous value + const call = changeObserver.mock.calls[0]; + expect(call[0].name).toBe("ExistingAttr"); + expect(call[0].current).toBe("20"); + expect(call[1].current).toBe("10"); // Previous value + }); + }); + + it("should register and call observers for attribute deletion", async () => { + // Arrange + const destroyObserver = vi.fn(); + + // Create a character and attribute to be deleted + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ToBeDeleted", current: "value" }); + + // Register observer + ChatSetAttr.registerObserver("destroy", destroyObserver); + + // Act - delete the attribute + global.executeCommand("!delattr --charid char1 --ToBeDeleted"); + + // Assert + await vi.waitFor(() => { + // Should call destroy observer + expect(destroyObserver).toHaveBeenCalled(); + + // Check observer was called with correct attribute + const call = destroyObserver.mock.calls[0]; + expect(call[0].name).toBe("ToBeDeleted"); + expect(call[0].current).toBe("value"); + }); + }); + + it("should allow multiple observers for the same event", async () => { + // Arrange + const observer1 = vi.fn(); + const observer2 = vi.fn(); + + // Create a character + createObj("character", { id: "char1", name: "Character 1" }); + + // Register multiple observers for the same event + ChatSetAttr.registerObserver("add", observer1); + ChatSetAttr.registerObserver("add", observer2); + + // Act - create a new attribute + global.executeCommand("!setattr --charid char1 --MultiObserverTest|success"); + + // Assert + await vi.waitFor(() => { + // Both observers should have been called + expect(observer1).toHaveBeenCalled(); + expect(observer2).toHaveBeenCalled(); + }); + }); + + it("should handle invalid observer registrations", () => { + // Arrange - various invalid observer scenarios + const validFn = () => {}; + const consoleSpy = vi.spyOn(global, 'log'); + + // Act & Assert - test with invalid event + ChatSetAttr.registerObserver("invalid-event", validFn); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("event registration unsuccessful")); + + // Act & Assert - test with invalid function + consoleSpy.mockClear(); + ChatSetAttr.registerObserver("add", "not a function" as any); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("event registration unsuccessful")); + + // Cleanup + consoleSpy.mockRestore(); + }); +}); diff --git a/ChatSetAttr/build/tests/legacy/ChatSetAttr.d.ts b/ChatSetAttr/build/tests/legacy/ChatSetAttr.d.ts new file mode 100644 index 0000000000..4c6694da4a --- /dev/null +++ b/ChatSetAttr/build/tests/legacy/ChatSetAttr.d.ts @@ -0,0 +1,125 @@ +declare interface StateConfig { + version: number; + globalconfigCache: { + lastsaved: number; + }; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; +} + +declare interface MockAttribute { + id: string; + _type: string; + _characterid: string; + name: string; + current: string; + max?: string; + get(property: string): string; + set(values: Record): void; + setWithWorker(values: Record): void; + remove(): void; +} + +declare interface ObserverFunction { + (attribute: MockAttribute, previousValues?: Record): void; +} + +declare interface RepeatingData { + regExp: RegExp[]; + toCreate: string[]; + sections: string[]; +} + +declare interface AttributeValue { + current?: string; + max?: string; + fillin?: boolean; + repeating: any | false; +} + +declare interface CommandOptions { + all?: boolean; + allgm?: boolean; + charid?: string; + name?: string; + allplayers?: boolean; + sel?: boolean; + deletemode?: boolean; + replace?: boolean; + nocreate?: boolean; + mod?: boolean; + modb?: boolean; + evaluate?: boolean; + silent?: boolean; + reset?: boolean; + mute?: boolean; + "fb-header"?: string; + "fb-content"?: string; + "fb-from"?: string; + "fb-public"?: boolean; + [key: string]: any; +} + +declare const ChatSetAttr: { + /** + * Checks if the script is properly installed and updates state if needed + */ + checkInstall(): void; + + /** + * Registers an observer function for attribute events + * @param event Event type: "add", "change", or "destroy" + * @param observer Function to call when event occurs + */ + registerObserver(event: "add" | "change" | "destroy", observer: ObserverFunction): void; + + /** + * Registers event handlers for the module + */ + registerEventHandlers(): void; + + /** + * Testing methods - only available for internal use + */ + testing: { + isDef(value: any): boolean; + getWhisperPrefix(playerid: string): string; + sendChatMessage(msg: string, from?: string): void; + setAttribute(attr: MockAttribute, value: Record): void; + handleErrors(whisper: string, errors: string[]): void; + showConfig(whisper: string): void; + getConfigOptionText(o: { name: string; command: string; desc: string }): string; + getCharNameById(id: string): string; + escapeRegExp(str: string): string; + htmlReplace(str: string): string; + processInlinerolls(msg: { content: string; inlinerolls?: any[] }): string; + notifyAboutDelay(whisper: string): number; + getCIKey(obj: Record, name: string): string | false; + generateUUID(): string; + generateRowID(): string; + delayedGetAndSetAttributes(whisper: string, list: string[], setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): void; + setCharAttributes(charid: string, setting: Record, errors: string[], feedback: string[], attrs: Record, opts: CommandOptions): void; + fillInAttrValues(charid: string, expression: string): string; + getCharAttributes(charid: string, setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): Record; + getCharStandardAttributes(charid: string, attrNames: string[], errors: string[], opts: CommandOptions): Record; + getCharRepeatingAttributes(charid: string, setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): Record; + delayedDeleteAttributes(whisper: string, list: string[], setting: Record, errors: string[], rData: RepeatingData, opts: CommandOptions): void; + deleteCharAttributes(charid: string, attrs: Record, feedback: Record): void; + parseOpts(content: string, hasValue: string[]): CommandOptions; + parseAttributes(args: string[], opts: CommandOptions, errors: string[]): [Record, RepeatingData]; + getRepeatingData(name: string, globalData: any, opts: CommandOptions, errors: string[]): any | null; + checkPermissions(list: string[], errors: string[], playerid: string, isGM: boolean): string[]; + getIDsFromTokens(selected: any[] | undefined): string[]; + getIDsFromNames(charNames: string, errors: string[]): string[]; + sendFeedback(whisper: string, feedback: string[], opts: CommandOptions): void; + sendDeleteFeedback(whisper: string, feedback: Record, opts: CommandOptions): void; + handleCommand(content: string, playerid: string, selected: any[] | undefined, pre: string): void; + handleInlineCommand(msg: { content: string; playerid: string; selected?: any[]; inlinerolls?: any[] }): void; + handleInput(msg: { type: string; content: string; playerid: string; selected?: any[]; inlinerolls?: any[] }): void; + notifyObservers(event: "add" | "change" | "destroy", obj: MockAttribute, prev?: Record): void; + checkGlobalConfig(): void; + }; +}; + +export default ChatSetAttr; diff --git a/ChatSetAttr/build/tests/legacy/ChatSetAttr.js b/ChatSetAttr/build/tests/legacy/ChatSetAttr.js new file mode 100644 index 0000000000..705f529ed0 --- /dev/null +++ b/ChatSetAttr/build/tests/legacy/ChatSetAttr.js @@ -0,0 +1,824 @@ +// ChatSetAttr version 1.10 +// Last Updated: 2020-09-03 +// A script to create, modify, or delete character attributes from the chat area or macros. +// If you don't like my choices for --replace, you can edit the replacers variable at your own peril to change them. + +/* global log, state, globalconfig, getObj, sendChat, _, getAttrByName, findObjs, createObj, playerIsGM, on */ +var ChatSetAttr = (function () { + "use strict"; + const version = "1.10", + observers = { + "add": [], + "change": [], + "destroy": [] + }, + schemaVersion = 3, + replacers = [ + [//g, "]"], + [/\\rbrak/g, "]"], + [/;/g, "?"], + [/\\ques/g, "?"], + [/`/g, "@"], + [/\\at/g, "@"], + [/~/g, "-"], + [/\\n/g, "\n"], + ], + // Basic Setup + checkInstall = function () { + log(`-=> ChatSetAttr v${version} <=-`); + if (!state.ChatSetAttr || state.ChatSetAttr.version !== schemaVersion) { + log(` > Updating ChatSetAttr Schema to v${schemaVersion} <`); + state.ChatSetAttr = { + version: schemaVersion, + globalconfigCache: { + lastsaved: 0 + }, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true + }; + } + checkGlobalConfig(); + }, + checkGlobalConfig = function () { + const s = state.ChatSetAttr, + g = globalconfig && globalconfig.chatsetattr; + if (g && g.lastsaved && g.lastsaved > s.globalconfigCache.lastsaved) { + log(" > Updating ChatSetAttr from Global Config < [" + + (new Date(g.lastsaved * 1000)) + "]"); + s.playersCanModify = "playersCanModify" === g["Players can modify all characters"]; + s.playersCanEvaluate = "playersCanEvaluate" === g["Players can use --evaluate"]; + s.useWorkers = "useWorkers" === g["Trigger sheet workers when setting attributes"]; + s.globalconfigCache = globalconfig.chatsetattr; + } + }, + // Utility functions + isDef = function (value) { + return value !== undefined; + }, + getWhisperPrefix = function (playerid) { + const player = getObj("player", playerid); + if (player && player.get("_displayname")) { + return "/w \"" + player.get("_displayname") + "\" "; + } else { + return "/w GM "; + } + }, + sendChatMessage = function (msg, from) { + if (from === undefined) from = "ChatSetAttr"; + sendChat(from, msg, null, { + noarchive: true + }); + }, + setAttribute = function (attr, value) { + if (state.ChatSetAttr.useWorkers) attr.setWithWorker(value); + else attr.set(value); + }, + handleErrors = function (whisper, errors) { + if (errors.length) { + const output = whisper + + "
" + + "

Errors

" + + `

${errors.join("
")}

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

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

!setattr-config --option
" + + "

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

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

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

" + + "

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

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

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

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

"; + sendChatMessage(output, opts["fb-from"]); + }, + handleCommand = (content, playerid, selected, pre) => { + // Parsing input + let charIDList = [], + errors = []; + const hasValue = ["charid", "name", "fb-header", "fb-content", "fb-from"], + optsArray = ["all", "allgm", "charid", "name", "allplayers", "sel", "deletemode", + "replace", "nocreate", "mod", "modb", "evaluate", "silent", "reset", "mute", + "fb-header", "fb-content", "fb-from", "fb-public" + ], + opts = parseOpts(content, hasValue), + isGM = playerid === "API" || playerIsGM(playerid), + whisper = getWhisperPrefix(playerid); + opts.mod = opts.mod || (pre === "mod"); + opts.modb = opts.modb || (pre === "modb"); + opts.reset = opts.reset || (pre === "reset"); + opts.silent = opts.silent || opts.mute; + opts.deletemode = (pre === "del"); + // Sanitise feedback + if ("fb-from" in opts) opts["fb-from"] = String(opts["fb-from"]); + // Parse desired attribute values + const [setting, rData] = parseAttributes(Object.keys(_.omit(opts, optsArray)), opts, errors); + // Fill in header info + if ("fb-header" in opts) { + opts["fb-header"] = Object.entries(setting).reduce((m, [n, v], k) => { + return m.replace(`_NAME${k}_`, n) + .replace(`_TCUR${k}_`, htmlReplace(v.current || "")) + .replace(`_TMAX${k}_`, htmlReplace(v.max || "")); + }, String(opts["fb-header"])).replace(/_(?:TCUR|TMAX|NAME)\d*_/g, ""); + } + if (opts.evaluate && !isGM && !state.ChatSetAttr.playersCanEvaluate) { + if (!opts.mute) handleErrors(whisper, ["The --evaluate option is only available to the GM."]); + return; + } + // Get list of character IDs + if (opts.all && isGM) { + charIDList = findObjs({ + _type: "character" + }).map(c => c.id); + } else if (opts.allgm && isGM) { + charIDList = findObjs({ + _type: "character" + }).filter(c => c.get("controlledby") === "") + .map(c => c.id); + } else if (opts.allplayers && isGM) { + charIDList = findObjs({ + _type: "character" + }).filter(c => c.get("controlledby") !== "") + .map(c => c.id); + } else { + if (opts.charid) charIDList.push(...opts.charid.split(/\s*,\s*/)); + if (opts.name) charIDList.push(...getIDsFromNames(opts.name, errors)); + if (opts.sel) charIDList.push(...getIDsFromTokens(selected)); + charIDList = checkPermissions([...new Set(charIDList)], errors, playerid, isGM); + } + if (charIDList.length === 0) { + errors.push("No target characters. You need to supply one of --all, --allgm, --sel," + + " --allplayers, --charid, or --name."); + } + if (Object.keys(setting).length === 0) { + errors.push("No attributes supplied."); + } + // Get attributes + if (!opts.mute) handleErrors(whisper, errors); + // Set or delete attributes + if (charIDList.length > 0 && Object.keys(setting).length > 0) { + if (opts.deletemode) { + delayedDeleteAttributes(whisper, charIDList, setting, errors, rData, opts); + } else { + delayedGetAndSetAttributes(whisper, charIDList, setting, errors, rData, opts); + } + } + }, + handleInlineCommand = (msg) => { + const command = msg.content.match(/!(set|mod|modb)attr .*?!!!/); + if (command) { + const mode = command[1], + newMsgContent = command[0].slice(0, -3).replace(/{{[^}[\]]+\$\[\[(\d+)\]\].*?}}/g, (_, number) => { + return `$[[${number}]]`; + }); + const newMsg = { + content: newMsgContent, + inlinerolls: msg.inlinerolls, + }; + handleCommand( + processInlinerolls(newMsg), + msg.playerid, + msg.selected, + mode + ); + } + }, + // Main function, called after chat message input + handleInput = function (msg) { + if (msg.type !== "api") handleInlineCommand(msg); + else { + const mode = msg.content.match(/^!(reset|set|del|mod|modb)attr\b(?:-|\s|$)(config)?/); + + if (mode && mode[2]) { + if (playerIsGM(msg.playerid)) { + const whisper = getWhisperPrefix(msg.playerid), + opts = parseOpts(msg.content, []); + if (opts["players-can-modify"]) { + state.ChatSetAttr.playersCanModify = !state.ChatSetAttr.playersCanModify; + } + if (opts["players-can-evaluate"]) { + state.ChatSetAttr.playersCanEvaluate = !state.ChatSetAttr.playersCanEvaluate; + } + if (opts["use-workers"]) { + state.ChatSetAttr.useWorkers = !state.ChatSetAttr.useWorkers; + } + showConfig(whisper); + } + } else if (mode) { + handleCommand( + processInlinerolls(msg), + msg.playerid, + msg.selected, + mode[1] + ); + } + } + return; + }, + notifyObservers = function(event, obj, prev) { + observers[event].forEach(observer => observer(obj, prev)); + }, + registerObserver = function (event, observer) { + if(observer && _.isFunction(observer) && observers.hasOwnProperty(event)) { + observers[event].push(observer); + } else { + log("ChatSetAttr event registration unsuccessful. Please check the documentation."); + } + }, + registerEventHandlers = function () { + on("chat:message", handleInput); + }; + return { + checkInstall, + registerObserver, + registerEventHandlers, + testing: { + isDef, + getWhisperPrefix, + sendChatMessage, + setAttribute, + handleErrors, + showConfig, + getConfigOptionText, + getCharNameById, + escapeRegExp, + htmlReplace, + processInlinerolls, + notifyAboutDelay, + getCIKey, + generateUUID, + generateRowID, + delayedGetAndSetAttributes, + setCharAttributes, + fillInAttrValues, + getCharAttributes, + getCharStandardAttributes, + getCharRepeatingAttributes, + delayedDeleteAttributes, + deleteCharAttributes, + parseOpts, + parseAttributes, + getRepeatingData, + checkPermissions, + getIDsFromTokens, + getIDsFromNames, + sendFeedback, + sendDeleteFeedback, + handleCommand, + handleInlineCommand, + handleInput, + notifyObservers, + checkGlobalConfig, + } + }; +}()); + +on("ready", function () { + "use strict"; + ChatSetAttr.checkInstall(); + ChatSetAttr.registerEventHandlers(); +}); + +export default ChatSetAttr; \ No newline at end of file diff --git a/ChatSetAttr/build/tests/legacy/legacyConfig.test.ts b/ChatSetAttr/build/tests/legacy/legacyConfig.test.ts new file mode 100644 index 0000000000..919c5b9a98 --- /dev/null +++ b/ChatSetAttr/build/tests/legacy/legacyConfig.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import './ChatSetAttr.js'; +import ChatSetAttr from "./ChatSetAttr.js"; + +// Define interfaces for better typing +interface StateConfig { + version: number; + globalconfigCache: { + lastsaved: number; + }; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; +} + +describe('ChatSetAttr Configuration Tests', () => { + const state = global.state as { ChatSetAttr: StateConfig }; + const originalConfig: StateConfig = { + version: 3, + globalconfigCache: { + lastsaved: Date.now() / 1000, // Current timestamp in seconds + }, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: true, + }; + + // Set up the test environment before each test + beforeEach(() => { + global.setupTestEnvironment(); + ChatSetAttr.registerEventHandlers(); + }); + + // Reset configuration after each test + afterEach(() => { + vi.clearAllMocks(); + state.ChatSetAttr.playersCanModify = originalConfig.playersCanModify; + state.ChatSetAttr.playersCanEvaluate = originalConfig.playersCanEvaluate; + state.ChatSetAttr.useWorkers = originalConfig.useWorkers; + }); + + it("should toggle player modification permissions", () => { + // Arrange + const initialValue = state.ChatSetAttr.playersCanModify; + const msg = { + type: "api", + content: "!setattr-config --players-can-modify", + playerid: "gm123" + }; + + // Act + ChatSetAttr.testing.handleInput(msg); + + // Assert + expect(state.ChatSetAttr.playersCanModify).toBe(!initialValue); + }); + + it("should toggle player evaluation permissions", () => { + // Arrange + const initialValue = state.ChatSetAttr.playersCanEvaluate; + const msg = { + type: "api", + content: "!setattr-config --players-can-evaluate", + playerid: "gm123" + }; + + // Act + ChatSetAttr.testing.handleInput(msg); + + // Assert + expect(state.ChatSetAttr.playersCanEvaluate).toBe(!initialValue); + }); + + it("should toggle worker usage", () => { + // Arrange + const initialValue = state.ChatSetAttr.useWorkers; + const msg = { + type: "api", + content: "!setattr-config --use-workers", + playerid: "gm123" + }; + + // Act + ChatSetAttr.testing.handleInput(msg); + + // Assert + expect(state.ChatSetAttr.useWorkers).toBe(!initialValue); + }); + + it("should deny configuration access to non-GM players", () => { + // Arrange + const { sendChat } = global; + const initialValues = { ...state.ChatSetAttr }; + + // Temporarily mock playerIsGM to return false + const originalPlayerIsGM = global.playerIsGM; + global.playerIsGM = vi.fn(() => false); + + // Create a message from a non-GM player + const msg = { + type: "api", + content: "!setattr-config --players-can-modify", + playerid: "player123" + }; + + // Act + ChatSetAttr.testing.handleInput(msg); + + // Assert - config should not change + expect(state.ChatSetAttr.playersCanModify).toBe(initialValues.playersCanModify); + + // Verify that access was denied (no feedback sent) + expect(sendChat).not.toHaveBeenCalled(); + + // Restore mock + global.playerIsGM = originalPlayerIsGM; + }); + + it("should display current configuration settings", () => { + // Arrange + const { sendChat } = global; + + // Set known state + state.ChatSetAttr.playersCanModify = true; + state.ChatSetAttr.playersCanEvaluate = false; + state.ChatSetAttr.useWorkers = true; + + const msg = { + type: "api", + content: "!setattr-config", + playerid: "gm123" + }; + + // Act + ChatSetAttr.testing.handleInput(msg); + + // Assert - check that configuration was displayed with correct values + expect(sendChat).toHaveBeenCalled(); + + const chatCall = vi.mocked(sendChat).mock.calls[0][1]; + + // Should show playersCanModify as ON + expect(chatCall).toMatch(/playersCanModify.*ON/); + + // Should show playersCanEvaluate as OFF + expect(chatCall).toMatch(/playersCanEvaluate.*OFF/); + + // Should show useWorkers as ON + expect(chatCall).toMatch(/useWorkers.*ON/); + }); + + it("should update from global config", () => { + // Arrange + const now = Date.now() / 1000; + + // Setup global config with newer timestamp + global.globalconfig = { + chatsetattr: { + lastsaved: now + 1000, // Future timestamp to ensure it's newer + "Players can modify all characters": "playersCanModify", + "Players can use --evaluate": "playersCanEvaluate", + "Trigger sheet workers when setting attributes": "useWorkers" + } + }; + + // Set known state (opposite of what we'll set in global config) + state.ChatSetAttr.playersCanModify = false; + state.ChatSetAttr.playersCanEvaluate = false; + state.ChatSetAttr.useWorkers = false; + state.ChatSetAttr.globalconfigCache.lastsaved = now; + + // Act - run checkGlobalConfig + ChatSetAttr.testing.checkGlobalConfig(); + + // Assert - settings should be updated from global config + expect(state.ChatSetAttr.playersCanModify).toBe(true); + expect(state.ChatSetAttr.playersCanEvaluate).toBe(true); + expect(state.ChatSetAttr.useWorkers).toBe(true); + expect(state.ChatSetAttr.globalconfigCache).toEqual(global.globalconfig.chatsetattr); + }); + + it("should respect useWorkers setting when updating attributes", async () => { + // Arrange + createObj("character", { id: "char1", name: "Character 1" }); + const attr = createObj("attribute", { + _characterid: "char1", + name: "TestWorker", + current: "10" + }); + + // Create spy on setWithWorker and regular set + const setWithWorkerSpy = vi.spyOn(attr, 'setWithWorker'); + const setSpy = vi.spyOn(attr, 'set'); + + // Test with useWorkers = true + state.ChatSetAttr.useWorkers = true; + + // Act + global.executeCommand("!setattr --charid char1 --TestWorker|20"); + + // Assert - should use setWithWorker + await vi.waitFor(() => { + expect(setWithWorkerSpy).toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + }); + + // Reset and test with useWorkers = false + setWithWorkerSpy.mockClear(); + setSpy.mockClear(); + state.ChatSetAttr.useWorkers = false; + + // Act again + global.executeCommand("!setattr --charid char1 --TestWorker|30"); + + // Assert - should use regular set + await vi.waitFor(() => { + expect(setWithWorkerSpy).not.toHaveBeenCalled(); + expect(setSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/ChatSetAttr/build/tests/legacy/legacyIntegration.test.ts b/ChatSetAttr/build/tests/legacy/legacyIntegration.test.ts new file mode 100644 index 0000000000..50623a1959 --- /dev/null +++ b/ChatSetAttr/build/tests/legacy/legacyIntegration.test.ts @@ -0,0 +1,1119 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { MockAttribute } from "../../vitest.globals"; +import ChatSetAttr from "./ChatSetAttr.js"; +import { create } from "underscore"; + +// Define interfaces for better typing +interface StateConfig { + version: number; + globalconfigCache: { + lastsaved: number; + }; + playersCanModify: boolean; + playersCanEvaluate: boolean; + useWorkers: boolean; +} + +describe("ChatSetAttr Integration Tests", () => { + // Set up the test environment before each test + beforeEach(() => { + setupTestEnvironment(); + ChatSetAttr.registerEventHandlers(); + }); + + // Cleanup after each test if needed + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Attribute Setting Commands", () => { + it("should set Strength to 15 for selected characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("graphic", { id: "token1", represents: "char1" }); + createObj("graphic", { id: "token2", represents: "char2" }); + + executeCommand("!setattr --sel --Strength|15", ["token1", "token2"]); + + await vi.waitFor(() => { + const char1Strength = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + const char2Strength = attributes.find(a => a._characterid === "char2" && a.name === "Strength"); + expect(char1Strength).toBeDefined(); + expect(char1Strength!.current).toBe("15"); + expect(char2Strength).toBeDefined(); + expect(char2Strength!.current).toBe("15"); + }); + }); + + it("should set HP and Dex for character named John", async () => { + createObj("character", { id: "john1", name: "John" }); + createObj("character", { id: "john2", name: "john" }); + createObj("character", { id: "char3", name: "NotJohn" }); + + executeCommand("!setattr --name John --HP|17|27 --Dex|10"); + + await vi.waitFor(() => { + const johnHP = attributes.find(a => a._characterid === "john1" && a.name === "HP"); + const johnDex = attributes.find(a => a._characterid === "john1" && a.name === "Dex"); + + expect(johnHP).toBeDefined(); + expect(johnHP!.current).toBe("17"); + expect(johnHP!.max).toBe("27"); + expect(johnDex).toBeDefined(); + expect(johnDex!.current).toBe("10"); + + const anotherJohnHP = attributes.find(a => a._characterid === "john2" && a.name === "HP"); + const notJohnHP = attributes.find(a => a._characterid === "char3" && a.name === "HP"); + expect(anotherJohnHP).toBeUndefined(); + expect(notJohnHP).toBeUndefined(); + }); + }); + + it("should set td attribute to d8 for all characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("character", { id: "char3", name: "Character 3" }); + + executeCommand("!setattr --all --td|d8"); + + await vi.waitFor(() => { + const char1TensionDie = attributes.find(a => a._characterid === "char1" && a.name === "td"); + const char2TensionDie = attributes.find(a => a._characterid === "char2" && a.name === "td"); + const char3TensionDie = attributes.find(a => a._characterid === "char3" && a.name === "td"); + + expect(char1TensionDie).toBeDefined(); + expect(char1TensionDie!.current).toBe("d8"); + expect(char2TensionDie).toBeDefined(); + expect(char2TensionDie!.current).toBe("d8"); + expect(char3TensionDie).toBeDefined(); + expect(char3TensionDie!.current).toBe("d8"); + }); + }); + + it("should add a new item to a repeating inventory section", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --fb-public --fb-header Aquiring Magic Item --fb-content The Cloak of Excellence from the chest by a character. --repeating_inventory_-CREATE_itemname|Cloak of Excellence --repeating_inventory_-CREATE_itemcount|1 --repeating_inventory_-CREATE_itemweight|3 --repeating_inventory_-CREATE_equipped|1 --repeating_inventory_-CREATE_itemmodifiers|Item Type: Wondrous item, AC +2, Saving Throws +1 --repeating_inventory_-CREATE_itemcontent|(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar.", ["token1"]); + + await vi.waitFor(() => { + expect(sendChat).toHaveBeenCalled(); + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Aquiring Magic Item") + ); + expect(feedbackCall).toBeDefined(); + + const nameAttr = attributes.find(a => a._characterid === "char1" && a.name.includes("itemname")); + expect(nameAttr).toBeDefined(); + + const repeatingRowId = nameAttr!.name.match(/repeating_inventory_([^_]+)_itemname/)?.[1]; + expect(repeatingRowId).toBeDefined(); + + type ItemAttrs = { + name?: MockAttribute; + count?: MockAttribute; + weight?: MockAttribute; + equipped?: MockAttribute; + modifiers?: MockAttribute; + content?: MockAttribute; + }; + + const itemAttrs: ItemAttrs = { + name: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemname`), + count: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemcount`), + weight: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemweight`), + equipped: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_equipped`), + modifiers: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemmodifiers`), + content: attributes.find(a => a.name === `repeating_inventory_${repeatingRowId}_itemcontent`) + }; + + expect(itemAttrs.name).toBeDefined(); + expect(itemAttrs.name!.current).toBe("Cloak of Excellence"); + expect(itemAttrs.count!.current).toBe("1"); + expect(itemAttrs.weight!.current).toBe("3"); + expect(itemAttrs.equipped!.current).toBe("1"); + expect(itemAttrs.modifiers!.current).toBe("Item Type: Wondrous item, AC +2, Saving Throws +1"); + expect(itemAttrs.content!.current).toBe("(Requires Attunment)A purple cape, that feels heavy to the touch, but light to carry. It has gnomish text embroiled near the collar."); + }); + }); + + it("should process inline roll queries", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + inputQueue.push("15"); + inputQueue.push("20"); + + executeCommand("!setattr --charid char1 --Strength|[[?{Strength Value|10}]] --Dexterity|[[?{Dexterity Value|10}]]"); + + await vi.waitFor(() => { + const strAttr = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + const dexAttr = attributes.find(a => a._characterid === "char1" && a.name === "Dexterity"); + + expect(strAttr).toBeDefined(); + expect(strAttr!.current).toBe("15"); + expect(dexAttr).toBeDefined(); + expect(dexAttr!.current).toBe("20"); + }); + }); + + it("should process an inline command within a chat message", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("I cast a spell and !setattr --charid char1 --Mana|10!!!"); + + await vi.waitFor(() => { + const manaAttr = attributes.find(a => a._characterid === "char1" && a.name === "Mana"); + + expect(manaAttr).toBeDefined(); + expect(manaAttr!.current).toBe("10"); + }); + }); + + it("should use character IDs directly to set attributes", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + + executeCommand("!setattr --charid char1,char2 --Level|5"); + + await vi.waitFor(() => { + const char1Level = attributes.find(a => a._characterid === "char1" && a.name === "Level"); + const char2Level = attributes.find(a => a._characterid === "char2" && a.name === "Level"); + + expect(char1Level).toBeDefined(); + expect(char1Level!.current).toBe("5"); + expect(char2Level).toBeDefined(); + expect(char2Level!.current).toBe("5"); + }); + }); + + it("should set multiple attributes on multiple characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + + executeCommand("!setattr --charid char1,char2 --Class|Fighter --Level|5 --HP|30|30"); + + await vi.waitFor(() => { + const char1Class = attributes.find(a => a._characterid === "char1" && a.name === "Class"); + const char1Level = attributes.find(a => a._characterid === "char1" && a.name === "Level"); + const char1HP = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + + expect(char1Class).toBeDefined(); + expect(char1Class!.current).toBe("Fighter"); + expect(char1Level).toBeDefined(); + expect(char1Level!.current).toBe("5"); + expect(char1HP).toBeDefined(); + expect(char1HP!.current).toBe("30"); + expect(char1HP!.max).toBe("30"); + + const char2Class = attributes.find(a => a._characterid === "char2" && a.name === "Class"); + const char2Level = attributes.find(a => a._characterid === "char2" && a.name === "Level"); + const char2HP = attributes.find(a => a._characterid === "char2" && a.name === "HP"); + + expect(char2Class).toBeDefined(); + expect(char2Class!.current).toBe("Fighter"); + expect(char2Level).toBeDefined(); + expect(char2Level!.current).toBe("5"); + expect(char2HP).toBeDefined(); + expect(char2HP!.current).toBe("30"); + expect(char2HP!.max).toBe("30"); + }); + }); + }); + + describe("Attribute Modification Commands", () => { + it("should increase Strength by 5 for selected characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("character", { id: "char3", name: "Character 3" }); + createObj("attribute", { _characterid: "char1", name: "Strength", current: "10" }); + createObj("attribute", { _characterid: "char2", name: "Strength", current: "15" }); + createObj("attribute", { _characterid: "char3", name: "Strength", current: "Very big" }); + createObj("graphic", { id: "token1", represents: "char1" }); + createObj("graphic", { id: "token2", represents: "char2" }); + createObj("graphic", { id: "token3", represents: "char3" }); + + executeCommand("!setattr --sel --mod --Strength|5", ["token1", "token2", "token3"]); + + await vi.waitFor(() => { + const char1Strength = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + const char2Strength = attributes.find(a => a._characterid === "char2" && a.name === "Strength"); + const char3Strength = attributes.find(a => a._characterid === "char3" && a.name === "Strength"); + + expect(char1Strength).toBeDefined(); + expect(char1Strength!.current).toBe("15"); + expect(char2Strength).toBeDefined(); + expect(char2Strength!.current).toBe("20"); + expect(char3Strength).toBeDefined(); + expect(char3Strength!.current).toBe("Very big"); + + expect(sendChat).toHaveBeenCalled(); + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("is not number-valued") + ); + expect(errorCall).toBeDefined(); + }); + }); + + it("should handle --mod option for modifying attributes", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "Counter", current: "5" }); + createObj("attribute", { _characterid: "char1", name: "CounterMax", current: "3", max: "10" }); + + executeCommand("!modattr --charid char1 --Counter|2 --CounterMax|1|2"); + + await vi.waitFor(() => { + const counter = attributes.find(a => a._characterid === "char1" && a.name === "Counter"); + const counterMax = attributes.find(a => a._characterid === "char1" && a.name === "CounterMax"); + + expect(counter).toBeDefined(); + expect(counter!.current).toBe("7"); + expect(counterMax).toBeDefined(); + expect(counterMax!.current).toBe("4"); + expect(counterMax!.max).toBe("12"); + }); + }); + + it("should modify attributes using the !mod command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "30" }); + + executeCommand("!modattr --charid char1 --HP|5 --MP|-3"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("15"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("12"); + }); + }); + + it("should modify attributes with bounds using modbattr", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "15", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "Stamina", current: "1", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|5 --Stamina|-5"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + const stamina = attributes.find(a => a._characterid === "char1" && a.name === "Stamina"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("15"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("15"); + expect(stamina).toBeDefined(); + expect(stamina!.current).toBe("0"); + }); + }); + + it("should modify attributes with bounds using the !modb command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "10" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "8", max: "10" }); + + executeCommand("!modbattr --charid char1 --HP|10 --MP|-10"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("10"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("0"); + }); + }); + }); + + describe("Attribute Deletion and Reset Commands", () => { + it("should delete the gold attribute from all characters", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("attribute", { _characterid: "char1", name: "gold", current: "100" }); + createObj("attribute", { _characterid: "char2", name: "gold", current: "200" }); + createObj("attribute", { _characterid: "char1", name: "silver", current: "50" }); + + executeCommand("!delattr --all --gold"); + + await vi.waitFor(() => { + expect(attributes.find(a => a._characterid === "char1" && a.name === "gold")).toBeUndefined(); + expect(attributes.find(a => a._characterid === "char2" && a.name === "gold")).toBeUndefined(); + expect(attributes.find(a => a._characterid === "char1" && a.name === "silver")).toBeDefined(); + }); + }); + + it("should reset Ammo to its maximum value", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "Ammo", current: "3", max: "20" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --Ammo|%Ammo_max%", ["token1"]); + + await vi.waitFor(() => { + const ammo = attributes.find(a => a._characterid === "char1" && a.name === "Ammo"); + expect(ammo).toBeDefined(); + expect(ammo!.current).toBe("20"); + }); + }); + + it("should reset attributes to their maximum values with resetattr", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "10", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "5", max: "15" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100", max: "" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + const xp = attributes.find(a => a._characterid === "char1" && a.name === "XP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("20"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("15"); + expect(xp).toBeDefined(); + expect(xp!.current).toBe("100"); + }); + }); + + it("should reset attributes using the !reset command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "HP", current: "5", max: "20" }); + createObj("attribute", { _characterid: "char1", name: "MP", current: "10", max: "30" }); + createObj("attribute", { _characterid: "char1", name: "XP", current: "100" }); + + executeCommand("!resetattr --charid char1 --HP --MP"); + + await vi.waitFor(() => { + const hp = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + const mp = attributes.find(a => a._characterid === "char1" && a.name === "MP"); + const xp = attributes.find(a => a._characterid === "char1" && a.name === "XP"); + + expect(hp).toBeDefined(); + expect(hp!.current).toBe("20"); + expect(mp).toBeDefined(); + expect(mp!.current).toBe("30"); + expect(xp).toBeDefined(); + expect(xp!.current).toBe("100"); + }); + }); + + it("should delete attributes using the !del command syntax", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ToDelete1", current: "10" }); + createObj("attribute", { _characterid: "char1", name: "ToDelete2", current: "20" }); + createObj("attribute", { _characterid: "char1", name: "ToKeep", current: "30" }); + + executeCommand("!delattr --charid char1 --ToDelete1 --ToDelete2"); + + await vi.waitFor(() => { + const toDelete1 = attributes.find(a => a._characterid === "char1" && a.name === "ToDelete1"); + const toDelete2 = attributes.find(a => a._characterid === "char1" && a.name === "ToDelete2"); + const toKeep = attributes.find(a => a._characterid === "char1" && a.name === "ToKeep"); + + expect(toDelete1).toBeUndefined(); + expect(toDelete2).toBeUndefined(); + expect(toKeep).toBeDefined(); + expect(toKeep!.current).toBe("30"); + }); + }); + }); + + describe("Targeting Options", () => { + it("should set attributes for GM-only characters with allgm targeting mode", async () => { + createObj("character", { id: "gmchar1", name: "GM Character 1" }); + createObj("character", { id: "gmchar2", name: "GM Character 2" }); + createObj("character", { id: "playerchar", name: "Player Character", controlledby: "player123" }); + + executeCommand("!setattr --allgm --Status|NPC"); + + await vi.waitFor(() => { + const gmChar1Status = attributes.find(a => a._characterid === "gmchar1" && a.name === "Status"); + const gmChar2Status = attributes.find(a => a._characterid === "gmchar2" && a.name === "Status"); + const playerCharStatus = attributes.find(a => a._characterid === "playerchar" && a.name === "Status"); + + expect(gmChar1Status).toBeDefined(); + expect(gmChar1Status!.current).toBe("NPC"); + + expect(gmChar2Status).toBeDefined(); + expect(gmChar2Status!.current).toBe("NPC"); + + expect(playerCharStatus).toBeUndefined(); + }); + }); + + it("should set attributes for player-controlled characters with allplayers targeting mode", async () => { + createObj("character", { id: "playerchar1", name: "Player Character 1", controlledby: "player123" }); + createObj("character", { id: "playerchar2", name: "Player Character 2", controlledby: "player456" }); + createObj("character", { id: "gmchar", name: "GM Character" }); + + executeCommand("!setattr --allplayers --CharType|PC"); + + await vi.waitFor(() => { + const playerChar1Type = attributes.find(a => a._characterid === "playerchar1" && a.name === "CharType"); + const playerChar2Type = attributes.find(a => a._characterid === "playerchar2" && a.name === "CharType"); + const gmCharType = attributes.find(a => a._characterid === "gmchar" && a.name === "CharType"); + + expect(playerChar1Type).toBeDefined(); + expect(playerChar1Type!.current).toBe("PC"); + + expect(playerChar2Type).toBeDefined(); + expect(playerChar2Type!.current).toBe("PC"); + + expect(gmCharType).toBeUndefined(); + }); + }); + }); + + describe("Attribute Value Processing", () => { + it("should evaluate expressions using attribute references", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "attr1", current: "3" }); + createObj("attribute", { _characterid: "char1", name: "attr2", current: "2" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%", ["token1"]); + + await vi.waitFor(() => { + const attr3 = attributes.find(a => a._characterid === "char1" && a.name === "attr3"); + expect(attr3).toBeDefined(); + expect(attr3!.current).toBe("11"); + }); + }); + + it("should handle --replace option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("!setattr --replace --charid char1 --Description|This text has characters; and should be `replaced`"); + + await vi.waitFor(() => { + const desc = attributes.find(a => a._characterid === "char1" && a.name === "Description"); + expect(desc).toBeDefined(); + expect(desc!.current).toBe("This text has [special] characters? and should be @replaced@"); + }); + }); + + it("should honor multiple modifier flags used together", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ExistingAttr", current: "10" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --evaluate --ExistingAttr|20*2"); + + await vi.waitFor(() => { + const existingAttr = attributes.find(a => a._characterid === "char1" && a.name === "ExistingAttr"); + expect(existingAttr).toBeDefined(); + expect(existingAttr!.current).toBe("40"); + + expect(sendChat).not.toHaveBeenCalled(); + }); + }); + }); + + describe("Configuration Options", () => { + it("should handle configuration commands", async () => { + const state = global.state as { ChatSetAttr: StateConfig }; + + const originalConfig: StateConfig = { + version: state.ChatSetAttr.version, + globalconfigCache: { ...state.ChatSetAttr.globalconfigCache }, + playersCanModify: state.ChatSetAttr.playersCanModify, + playersCanEvaluate: state.ChatSetAttr.playersCanEvaluate, + useWorkers: state.ChatSetAttr.useWorkers + }; + + afterEach(() => { + state.ChatSetAttr.playersCanModify = originalConfig.playersCanModify; + state.ChatSetAttr.playersCanEvaluate = originalConfig.playersCanEvaluate; + state.ChatSetAttr.useWorkers = originalConfig.useWorkers; + }); + + executeCommand("!setattr-config --players-can-modify", [], { playerId: "gm123" }); + + expect(state.ChatSetAttr.playersCanModify).toBe(!originalConfig.playersCanModify); + expect(sendChat).toHaveBeenCalled(); + + vi.mocked(sendChat).mockClear(); + executeCommand("!setattr-config --players-can-evaluate", [], { playerId: "gm123" }); + + expect(state.ChatSetAttr.playersCanEvaluate).toBe(!originalConfig.playersCanEvaluate); + expect(sendChat).toHaveBeenCalled(); + }); + + it("should update configuration and display current settings", async () => { + const state = global.state as { ChatSetAttr: StateConfig }; + + const originalUseWorkers = state.ChatSetAttr.useWorkers; + + executeCommand("!setattr-config --use-workers", [], { playerId: "gm123" }); + + expect(state.ChatSetAttr.useWorkers).toBe(!originalUseWorkers); + + expect(sendChat).toHaveBeenCalled(); + const configMessage = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Configuration") + ); + expect(configMessage).toBeDefined(); + + state.ChatSetAttr.useWorkers = originalUseWorkers; + }); + + it("should toggle the use-workers configuration setting", async () => { + const state = global.state as { ChatSetAttr: StateConfig }; + const originalUseWorkers = state.ChatSetAttr.useWorkers; + + executeCommand("!setattr-config --use-workers", [], { playerId: "gm123" }); + + expect(state.ChatSetAttr.useWorkers).toBe(!originalUseWorkers); + expect(sendChat).toHaveBeenCalled(); + + const configCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("use-workers") && + call[1].includes("Configuration") + ); + expect(configCall).toBeDefined(); + + state.ChatSetAttr.useWorkers = originalUseWorkers; + }); + + it("should respect player permissions", async () => { + createObj("character", { id: "char1", name: "Player Character", controlledby: "player123" }); + + const state = global.state as { ChatSetAttr: StateConfig }; + const originalConfig = state.ChatSetAttr.playersCanModify; + state.ChatSetAttr.playersCanModify = false; + + const originalPlayerIsGM = global.playerIsGM; + global.playerIsGM = vi.fn(() => false); + + executeCommand("!setattr --charid char1 --Strength|18", [], { playerId: "differentPlayer456" }); + + await vi.waitFor(() => { + const strength = attributes.find(a => a._characterid === "char1" && a.name === "Strength"); + expect(strength).toBeUndefined(); + + expect(sendChat).toHaveBeenCalled(); + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Permission error") + ); + expect(errorCall).toBeDefined(); + }); + + state.ChatSetAttr.playersCanModify = originalConfig; + global.playerIsGM = originalPlayerIsGM; + }); + }); + + describe("Feedback Options", () => { + it("should send public feedback with --fb-public option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-public --Attribute|42"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Attribute"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("42"); + + const feedbackCalls = vi.mocked(sendChat).mock.calls.filter(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Setting Attribute") && + !call[1].startsWith("/w ") + ); + + expect(feedbackCalls.length).toBeGreaterThan(0); + }); + }); + + it("should use custom sender with --fb-from option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-from Wizard --Spell|Fireball"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Spell"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("Fireball"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[0] === "Wizard" && + call[1] && typeof call[1] === "string" && + call[1].includes("Setting Spell") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should use custom header with --fb-header option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-header Magic Item Acquired --Item|Staff of Power"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Item"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("Staff of Power"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Magic Item Acquired") && + !call[1].includes("Setting attributes") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should use custom content with --fb-content option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-header \"Level Up\" --fb-content \"_CHARNAME_ is now level _CUR0_!\" --Level|5"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "Level"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("5"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Character 1 is now level 5!") + ); + + expect(feedbackCall).toBeDefined(); + }); + }); + + it("should combine all feedback options together", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --fb-public --fb-from Dungeon_Master --fb-header \"Combat Stats Updated\" --fb-content \"_CHARNAME_'s health increased to _CUR0_!\" --HP|25"); + + await vi.waitFor(() => { + const attr = attributes.find(a => a._characterid === "char1" && a.name === "HP"); + expect(attr).toBeDefined(); + expect(attr!.current).toBe("25"); + + const feedbackCalls = vi.mocked(sendChat).mock.calls.filter(call => + call[0] === "Dungeon_Master" && + !call[1].startsWith("/w ") && + call[1].includes("Combat Stats Updated") && + call[1].includes("Character 1's health increased to 25!") + ); + + expect(feedbackCalls.length).toBe(1); + }); + }); + }); + + describe("Message Suppression Options", () => { + it("should suppress feedback messages when using the --silent option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --silent --TestAttr|42"); + + await vi.waitFor(() => { + const testAttr = attributes.find(a => a._characterid === "char1" && a.name === "TestAttr"); + expect(testAttr).toBeDefined(); + expect(testAttr!.current).toBe("42"); + + const feedbackCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Setting TestAttr") + ); + expect(feedbackCall).toBeUndefined(); + }); + }); + + it("should suppress error messages when using the --mute option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + vi.mocked(sendChat).mockClear(); + + executeCommand("!setattr --charid char1 --mute --mod --NonNumeric|abc --Value|5"); + + await vi.waitFor(() => { + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && call[1].includes("Error") + ); + expect(errorCall).toBeUndefined(); + }); + }); + + it("should not create attributes when using the --nocreate option", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("!setattr --charid char1 --nocreate --NewAttribute|50"); + + await vi.waitFor(() => { + const newAttr = attributes.find(a => a._characterid === "char1" && a.name === "NewAttribute"); + expect(newAttr).toBeUndefined(); + + const errorCall = vi.mocked(sendChat).mock.calls.find(call => + call[1] && typeof call[1] === "string" && + call[1].includes("Missing attribute") && + call[1].includes("not created") + ); + expect(errorCall).toBeDefined(); + }); + }); + }); + + describe("Observer Events", () => { + it("should observe attribute additions with registered observers", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("add", mockObserver); + + executeCommand("!setattr --charid char1 --NewAttribute|42"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + + const calls = mockObserver.mock.calls; + const hasNewAttributeCall = calls.some(call => { + const attr = call[0]; + return attr && attr.name === "NewAttribute" && attr.current === "42"; + }); + + expect(hasNewAttributeCall).toBe(true); + }); + }); + + it("should observe attribute changes with registered observers", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "ExistingAttr", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("change", mockObserver); + + executeCommand("!setattr --charid char1 --ExistingAttr|20"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + + const calls = mockObserver.mock.calls; + const hasChangeCall = calls.some(call => { + const attr = call[0]; + const prev = call[1]; + return attr && attr.name === "ExistingAttr" && attr.current === "20" && prev && prev.current === "10"; + }); + + expect(hasChangeCall).toBe(true); + }); + }); + + it("should observe attribute deletions with registered observers", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "DeleteMe", current: "10" }); + const mockObserver = vi.fn(); + + ChatSetAttr.registerObserver("destroy", mockObserver); + + executeCommand("!delattr --charid char1 --DeleteMe"); + + await vi.waitFor(() => { + expect(mockObserver).toHaveBeenCalled(); + + const calls = mockObserver.mock.calls; + const hasDeleteCall = calls.some(call => { + const attr = call[0]; + return attr && attr.name === "DeleteMe" && attr.current === "10"; + }); + + expect(hasDeleteCall).toBe(true); + }); + }); + }); + + describe("Repeating Sections", () => { + it("should create repeating section attributes", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + + executeCommand("!setattr --charid char1 --repeating_weapons_-CREATE_weaponname|Longsword --repeating_weapons_-CREATE_damage|1d8"); + + await vi.waitFor(() => { + const nameAttr = attributes.find(a => a._characterid === "char1" && a.name.includes("weaponname")); + expect(nameAttr).toBeDefined(); + + const rowId = nameAttr!.name.match(/repeating_weapons_([^_]+)_weaponname/)?.[1]; + expect(rowId).toBeDefined(); + + const damageAttr = attributes.find(a => a.name === `repeating_weapons_${rowId}_damage`); + expect(damageAttr).toBeDefined(); + + expect(nameAttr!.current).toBe("Longsword"); + expect(damageAttr!.current).toBe("1d8"); + }); + }); + + it("should adjust number of uses remaining for an ability", async () => { + inputQueue.push("2"); + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "repeating_ability_-mm2dso76ircbi5dtea3_used", current: "3" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --repeating_ability_-mm2dso76ircbi5dtea3_used|[[?{How many are left?|0}]]", ["token1"]); + + await vi.waitFor(() => { + const usedAttr = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_ability_-mm2dso76ircbi5dtea3_used" + ); + + expect(usedAttr).toBeDefined(); + expect(usedAttr!.current).toBe("2"); + }); + }); + + it("should toggle a buff on or off", async () => { + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { _characterid: "char1", name: "repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle", current: "0" }); + createObj("graphic", { id: "token1", represents: "char1" }); + + executeCommand("!setattr --sel --repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle|[[1-@{selected|repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle}]]", ["token1"]); + + await vi.waitFor(() => { + const buffAttr = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle" + ); + + expect(buffAttr).toBeDefined(); + expect(buffAttr!.current).toBe("1"); + + executeCommand("!setattr --sel --repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle|[[1-@{selected|repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle}]]", ["token1"]); + + return new Promise(resolve => setTimeout(resolve, 100)); + }); + + const buffAttr = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_buff2_-mfyn0yxatk2wbh0km4d_enable_toggle" + ); + + expect(buffAttr).toBeDefined(); + expect(buffAttr!.current).toBe("0"); + }); + + const createRepeatingObjects = () => { + createObj("character", { id: "char1", name: "Character 1" }); + + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-abc123_weaponname", + current: "Longsword" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-abc123_damage", + current: "1d8" + }); + + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-def456_weaponname", + current: "Dagger" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-def456_damage", + current: "1d4" + }); + + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-ghi789_weaponname", + current: "Bow" + }); + createObj("attribute", { + _characterid: "char1", + name: "repeating_weapons_-ghi789_damage", + current: "1d6" + }); + + createObj("attribute", { + _characterid: "char1", + name: "_reporder_" + "repeating_weapons", + current: "abc123,def456,ghi789" + }); + + createObj("graphic", { id: "token1", represents: "char1" }); + }; + + + it("should handle deleting repeating section attributes referenced by index", async () => { + // Arrange + createRepeatingObjects(); + + const secondWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Dagger" + ); + expect(secondWeapon).toBeDefined(); + + // Act - Delete the second weapon ($1 index) by name + executeCommand("!delattr --sel --repeating_weapons_$1_weaponname", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + // Assert - First weapon should still exist + const firstWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Longsword" + ); + expect(firstWeapon).toBeDefined(); + + // Second weapon (Dagger) should be deleted + const secondWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Dagger" + ); + expect(secondWeapon).toBeUndefined(); + + // Third weapon should still exist + const thirdWeapon = attributes.find(a => + a._characterid === "char1" && + a.name.includes("weaponname") && + a.current === "Bow" + ); + expect(thirdWeapon).toBeDefined(); + }); + }); + + it("should handle modifying repeating section attributes referenced by index", async () => { + // Arrange + createRepeatingObjects(); + + // Act - Modify the damage of the first weapon ($0 index) + executeCommand("!setattr --sel --nocreate --repeating_weapons_$0_damage|2d8", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + // Assert - First weapon damage should be updated + const firstWeaponDamage = attributes.find(a => + a._characterid === "char1" && + a.name.includes("damage") && + a.name.includes("weapons") && + a.current === "2d8" + ); + expect(firstWeaponDamage).toBeDefined(); + }); + }); + + it("should handle creating new repeating section attributes after deletion", async () => { + // Arrange - Create initial repeating section attributes + createRepeatingObjects(); + + // Act - Create a new attribute in the last weapon ($1 index after deletion) + executeCommand("!setattr --sel --repeating_weapons_$1_newlycreated|5", ["token1"]); + + // Wait for the operation to complete + await vi.waitFor(() => { + const repOrder = attributes.find(a => + a._characterid === "char1" && + a.name === "_reporder_repeating_weapons" + ); + const order = repOrder!.get("current")!.split(","); + expect(order.length).toBe(3); + + const attackBonus = attributes.find(a => + a._characterid === "char1" && + a.name === "repeating_weapons_-def456_newlycreated" + ); + + expect(attackBonus).toBeDefined(); + expect(attackBonus!.current).toBe("5"); + }); + }); + }); + + describe("Delayed Processing", () => { + it("should process characters sequentially with delays", async () => { + vi.useFakeTimers(); + + // Create multiple characters + createObj("character", { id: "char1", name: "Character 1" }); + createObj("character", { id: "char2", name: "Character 2" }); + createObj("character", { id: "char3", name: "Character 3" }); + + // Set up spy on setTimeout to track when it's called + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + // Execute a command that sets attributes on all three characters + executeCommand("!setattr --charid char1,char2,char3 --TestAttr|42"); + vi.runAllTimers(); + + // All three characters should eventually get their attributes + await vi.waitFor(() => { + const char1Attr = attributes.find(a => a._characterid === "char1" && a.name === "TestAttr"); + const char2Attr = attributes.find(a => a._characterid === "char2" && a.name === "TestAttr"); + const char3Attr = attributes.find(a => a._characterid === "char3" && a.name === "TestAttr"); + + expect(char1Attr).toBeDefined(); + expect(char2Attr).toBeDefined(); + expect(char3Attr).toBeDefined(); + + expect(char1Attr!.current).toBe("42"); + expect(char2Attr!.current).toBe("42"); + expect(char3Attr!.current).toBe("42"); + }); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(3); + + // Verify the specific parameters of setTimeout calls + const timeoutCalls = setTimeoutSpy.mock.calls.filter( + call => typeof call[0] === 'function' && call[1] === 50 + ); + expect(timeoutCalls.length).toBe(2); + }); + + it("should notify about delays when processing characters", async () => { + vi.useFakeTimers(); + const actualCommand = setTimeout; + vi.spyOn(global, "setTimeout").mockImplementation((callback, delay, ...args) => { + if (delay === 8000) { + // Simulate the delay notification + callback(); + } + return actualCommand(callback, delay, ...args); + }); + for (let i = 1; i <= 50; i++) { + createObj("character", { id: `char${i}`, name: `Character ${i}` }); + } + // Execute a command that sets attributes on multiple characters + executeCommand("!setattr --all --TestAttr|42"); + + // Wait for the notification to be called + vi.runAllTimers(); + await vi.waitFor(() => { + expect(sendChat).toBeCalledTimes(2); + expect(sendChat).toHaveBeenCalledWith( + "ChatSetAttr", + expect.stringMatching(/long time to execute/g), + null, + expect.objectContaining({ + noarchive: true, + }) + ); + }); + }); + }); +}); diff --git a/ChatSetAttr/build/tests/legacy/legacyObserver.test.ts b/ChatSetAttr/build/tests/legacy/legacyObserver.test.ts new file mode 100644 index 0000000000..fb3c3b5b8e --- /dev/null +++ b/ChatSetAttr/build/tests/legacy/legacyObserver.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import ChatSetAttr from './ChatSetAttr.js'; + +describe('ChatSetAttr Observer Tests', () => { + // Set up the test environment before each test + beforeEach(() => { + global.setupTestEnvironment(); + ChatSetAttr.registerEventHandlers(); + }); + + // Cleanup after each test + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should register and call observers for attribute creation", async () => { + // Arrange + const addObserver = vi.fn(); + const changeObserver = vi.fn(); + const destroyObserver = vi.fn(); + + // Create a character to test with + createObj("character", { id: "char1", name: "Character 1" }); + + // Register observers for all event types + ChatSetAttr.registerObserver("add", addObserver); + ChatSetAttr.registerObserver("change", changeObserver); + ChatSetAttr.registerObserver("destroy", destroyObserver); + + // Act - create a new attribute + global.executeCommand("!setattr --charid char1 --NewAttr|42"); + + // Assert + await vi.waitFor(() => { + // Should call add observer but not change or destroy + expect(addObserver).toHaveBeenCalled(); + // it also, perhaps incorrectly, calls the change observer + expect(changeObserver).toHaveBeenCalled(); + expect(destroyObserver).not.toHaveBeenCalled(); + + // Check observer was called with correct attribute + const call = addObserver.mock.calls[0]; + expect(call[0].name).toBe("NewAttr"); + expect(call[0].current).toBe("42"); + }); + }); + + it("should register and call observers for attribute changes", async () => { + // Arrange + const addObserver = vi.fn(); + const changeObserver = vi.fn(); + + // Create a character and existing attribute + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { + _characterid: "char1", + name: "ExistingAttr", + current: "10" + }); + + // Register observers + ChatSetAttr.registerObserver("add", addObserver); + ChatSetAttr.registerObserver("change", changeObserver); + + // Act - modify the existing attribute + global.executeCommand("!setattr --charid char1 --ExistingAttr|20"); + + // Assert + await vi.waitFor(() => { + // Should call change observer but not add observer + expect(addObserver).not.toHaveBeenCalled(); + expect(changeObserver).toHaveBeenCalled(); + + // Check observer was called with correct attribute and previous value + const call = changeObserver.mock.calls[0]; + expect(call[0].name).toBe("ExistingAttr"); + expect(call[0].current).toBe("20"); + expect(call[1].current).toBe("10"); // Previous value + }); + }); + + it("should register and call observers for attribute deletion", async () => { + // Arrange + const destroyObserver = vi.fn(); + + // Create a character and attribute to be deleted + createObj("character", { id: "char1", name: "Character 1" }); + createObj("attribute", { + _characterid: "char1", + name: "ToBeDeleted", + current: "value" + }); + + // Register observer + ChatSetAttr.registerObserver("destroy", destroyObserver); + + // Act - delete the attribute + global.executeCommand("!delattr --charid char1 --ToBeDeleted"); + + // Assert + await vi.waitFor(() => { + // Should call destroy observer + expect(destroyObserver).toHaveBeenCalled(); + + // Check observer was called with correct attribute + const call = destroyObserver.mock.calls[0]; + expect(call[0].name).toBe("ToBeDeleted"); + expect(call[0].current).toBe("value"); + }); + }); + + it("should allow multiple observers for the same event", async () => { + // Arrange + const observer1 = vi.fn(); + const observer2 = vi.fn(); + + // Create a character + createObj("character", { id: "char1", name: "Character 1" }); + + // Register multiple observers for the same event + ChatSetAttr.registerObserver("add", observer1); + ChatSetAttr.registerObserver("add", observer2); + + // Act - create a new attribute + global.executeCommand("!setattr --charid char1 --MultiObserverTest|success"); + + // Assert + await vi.waitFor(() => { + // Both observers should have been called + expect(observer1).toHaveBeenCalled(); + expect(observer2).toHaveBeenCalled(); + }); + }); + + it("should handle invalid observer registrations", () => { + // Arrange - various invalid observer scenarios + const validFn = () => {}; + const consoleSpy = vi.spyOn(global, 'log'); + + // Act & Assert - test with invalid event + // @ts-expect-error // Invalid event name + ChatSetAttr.registerObserver("invalid-event", validFn); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("event registration unsuccessful")); + + // Act & Assert - test with invalid function + consoleSpy.mockClear(); + // @ts-expect-error // Invalid observer function + ChatSetAttr.registerObserver("add", "not a function"); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("event registration unsuccessful")); + + // Cleanup + consoleSpy.mockRestore(); + }); +}); diff --git a/ChatSetAttr/build/tests/unit/APIWrapper.test.ts b/ChatSetAttr/build/tests/unit/APIWrapper.test.ts new file mode 100644 index 0000000000..fe44f6ccd4 --- /dev/null +++ b/ChatSetAttr/build/tests/unit/APIWrapper.test.ts @@ -0,0 +1,454 @@ +import { it, expect, describe, beforeEach, afterEach, vi } from "vitest" +import { APIWrapper, type DeltasObject } from "../../src/classes/APIWrapper"; + +describe("APIWrapper", () => { + beforeEach(() => { + global.setupTestEnvironment(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getAttributes", () => { + it("should return an empty object if the character does not exist", async () => { + // Arrange + const nonexistentCharacter = null as unknown as Roll20Character; + const attributeList = ["strength", "dexterity"]; + + // Act + const result = await APIWrapper.getAttributes(nonexistentCharacter, attributeList); + + // Assert + expect(result).toEqual({}); + }); + + it("should return an empty object if the character has no attributes", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + const attributeList = ["strength", "dexterity"]; + + // Act + const result = await APIWrapper.getAttributes(character, attributeList); + + // Assert + expect(result).toEqual({}); + }); + + it("should return the attributes of the character", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + createObj("attribute", { + name: "strength", + current: "10", + max: "20", + _characterid: character.id, + }); + + createObj("attribute", { + name: "dexterity", + current: "15", + max: "25", + _characterid: character.id, + }); + const attributeList = ["strength", "dexterity"]; + + // Act + const result = await APIWrapper.getAttributes(character, attributeList); + + // Assert + expect(result).toEqual({ + strength: { value: "10", max: "20" }, + dexterity: { value: "15", max: "25" }, + }); + }); + + it("should return the attributes of the character without max value", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + createObj("attribute", { + name: "strength", + current: "10", + _characterid: character.id, + }); + + createObj("attribute", { + name: "dexterity", + current: "15", + _characterid: character.id, + }); + const attributeList = ["strength", "dexterity"]; + + // Act + const result = await APIWrapper.getAttributes(character, attributeList); + + // Assert + expect(result).toEqual({ + strength: { value: "10" }, + dexterity: { value: "15" }, + }); + }); + + it("should return the attributes of the character excluding missing attributes", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + createObj("attribute", { + name: "strength", + current: "10", + max: "20", + _characterid: character.id, + }); + const attributeList = ["strength", "dexterity"]; + + // Act + const result = await APIWrapper.getAttributes(character, attributeList); + + // Assert + expect(result).toEqual({ + strength: { value: "10", max: "20" }, + }); + }); + + it("should handle empty attribute list", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + createObj("attribute", { + name: "strength", + current: "10", + max: "20", + _characterid: character.id, + }); + const attributeList: string[] = []; + + // Act + const result = await APIWrapper.getAttributes(character, attributeList); + + // Assert + expect(result).toEqual({}); + }); + + it("should handle empty max values", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + createObj("attribute", { + name: "emptyMax", + current: "10", + max: "", + _characterid: character.id, + }); + + createObj("attribute", { + name: "nullMax", + current: "15", + _characterid: character.id, + }); + + const attributeList = ["emptyMax", "nullMax"]; + + // Act + const result = await APIWrapper.getAttributes(character, attributeList); + + // Assert + expect(result).toEqual({ + emptyMax: { value: "10" }, + nullMax: { value: "15" } + }); + }); + }); + + describe("setAttributes", () => { + it("should set attributes for the character", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + const attributes: DeltasObject = { + strength: { value: "10", max: "20" }, + dexterity: { value: "15", max: "15" }, + }; + + // Act + await APIWrapper.setAttributes(character, attributes); + + // Assert + const strengthAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "strength", + })[0]; + expect(strengthAttr).toBeDefined(); + expect(strengthAttr.get("current")).toBe("10"); + expect(strengthAttr.get("max")).toBe("20"); + + const dexterityAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "dexterity", + })[0]; + expect(dexterityAttr).toBeDefined(); + expect(dexterityAttr.get("current")).toBe("15"); + expect(dexterityAttr.get("max")).toBe("15"); + }); + + it("should create new attributes if they do not exist", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + const attributes: DeltasObject = { + strength: { value: "10", max: "20" }, + dexterity: { value: "15" }, + }; + + // Act + await APIWrapper.setAttributes(character, attributes); + + // Assert + const strengthAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "strength", + })[0]; + expect(strengthAttr).toBeDefined(); + expect(strengthAttr.get("current")).toBe("10"); + expect(strengthAttr.get("max")).toBe("20"); + + const dexterityAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "dexterity", + })[0]; + expect(dexterityAttr).toBeDefined(); + expect(dexterityAttr.get("current")).toBe("15"); + }); + + it("should not include max value if it is not provided", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + const attributes: DeltasObject = { + strength: { value: "10" }, + dexterity: { value: "15" }, + }; + + // Act + await APIWrapper.setAttributes(character, attributes); + + // Assert + const strengthAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "strength", + })[0]; + expect(strengthAttr).toBeDefined(); + expect(strengthAttr.get("current")).toBe("10"); + expect(strengthAttr.get("max")).toBe(undefined); + }); + + it("should normalize the attribute values to strings", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + const attributes: DeltasObject = { + strength: { value: 10, max: 20 } as any, + dexterity: { value: 15 } as any, + }; + + // Act + await APIWrapper.setAttributes(character, attributes); + + // Assert + const strengthAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "strength", + })[0]; + expect(strengthAttr).toBeDefined(); + expect(strengthAttr.get("current")).toBe("10"); + expect(strengthAttr.get("max")).toBe("20"); + + const dexterityAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "dexterity", + })[0]; + expect(dexterityAttr).toBeDefined(); + expect(dexterityAttr.get("current")).toBe("15"); + }); + + it("should handle empty string values", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + const attributes: DeltasObject = { + emptyValue: { value: "" }, + emptyMax: { value: "10", max: "" } + }; + + // Act + await APIWrapper.setAttributes(character, attributes); + + // Assert + const emptyValueAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "emptyValue", + })[0]; + expect(emptyValueAttr).toBeDefined(); + expect(emptyValueAttr.get("current")).toBe(""); + + const emptyMaxAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "emptyMax", + })[0]; + expect(emptyMaxAttr).toBeDefined(); + expect(emptyMaxAttr.get("current")).toBe("10"); + expect(emptyMaxAttr.get("max")).toBe(undefined); + }); + + it("should handle null and undefined values", async () => { + // Arrange + const character = createObj("character", { + id: "character_id", + name: "Test Character", + }); + + const attributes = { + nullValue: { value: null }, + undefinedMax: { value: "5", max: undefined } + } as any as DeltasObject; + + // Act + await APIWrapper.setAttributes(character, attributes); + + // Assert + const nullValueAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "nullValue", + })[0]; + expect(nullValueAttr).toBeDefined(); + expect(nullValueAttr.get("current")).toBe(""); + + const undefinedMaxAttr = findObjs<"attribute">({ + _type: "attribute", + _characterid: character.id, + name: "undefinedMax", + })[0]; + expect(undefinedMaxAttr).toBeDefined(); + expect(undefinedMaxAttr.get("current")).toBe("5"); + expect(undefinedMaxAttr.get("max")).toBeUndefined(); + }); + }); + + describe("extractRepeatingDetails", () => { + it("should extract section, ID and attribute from repeating attribute name", () => { + // Arrange + const attributeName = "repeating_weapons_-LmD9XTw6zZ8dD6iqZ9z_name"; + + // Act + const result = APIWrapper.extractRepeatingDetails(attributeName); + + // Assert + expect(result).toEqual({ + section: "weapons", + repeatingID: "-LmD9XTw6zZ8dD6iqZ9z", + attribute: "name" + }); + }); + + it("should return undefined for non-repeating attribute names", () => { + // Arrange + const attributeName = "strength"; + + // Act + const result = APIWrapper.extractRepeatingDetails(attributeName); + + // Assert + expect(result.attribute).toBeUndefined(); + expect(result.section).toBeUndefined(); + expect(result.repeatingID).toBeUndefined(); + }); + + it("should handle complex section names with hyphens", () => { + // Arrange + const attributeName = "repeating_spell-cantrip_-ABC123_spellname"; + + // Act + const result = APIWrapper.extractRepeatingDetails(attributeName); + + // Assert + expect(result).toEqual({ + section: "spell-cantrip", + repeatingID: "-ABC123", + attribute: "spellname" + }); + }); + + it("should handle underscores in attribute names", () => { + // Arrange + const attributeName = "repeating_equipment_-XYZ789_item_name"; + + // Act + const result = APIWrapper.extractRepeatingDetails(attributeName); + + // Assert + expect(result).toEqual({ + section: "equipment", + repeatingID: "-XYZ789", + attribute: "item_name" + }); + }); + + it("should handle complex alphanumeric IDs", () => { + // Arrange + const attributeName = "repeating_skills_-L1a2B3c4D5e6F7g8_skill_name"; + + // Act + const result = APIWrapper.extractRepeatingDetails(attributeName); + + // Assert + expect(result).toEqual({ + section: "skills", + repeatingID: "-L1a2B3c4D5e6F7g8", + attribute: "skill_name" + }); + }); + }); +}); \ No newline at end of file diff --git a/ChatSetAttr/build/tests/unit/AttrProcessor.test.ts b/ChatSetAttr/build/tests/unit/AttrProcessor.test.ts new file mode 100644 index 0000000000..188e26b6e4 --- /dev/null +++ b/ChatSetAttr/build/tests/unit/AttrProcessor.test.ts @@ -0,0 +1,638 @@ +import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"; +import { AttrProcessor } from "../../src/classes/AttrProcessor"; + +describe("AttrProcessor", () => { + beforeEach(() => { + // Set up test environment + global.setupTestEnvironment(); + }); + + afterEach(() => { + // Clean up test environment + vi.clearAllMocks(); + }); + + describe("Constructor", () => { + it("should initialize with default options", () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const delta = { strength: { value: "15" } }; + + // Act + const processor = new AttrProcessor(character, delta); + + // Assert + expect(processor.character).toBe(character); + expect(processor.delta).toEqual(delta); + expect(processor.eval).toBe(false); + expect(processor.parse).toBe(true); + expect(processor.constrain).toBe(false); + }); + + it("should initialize with custom options", () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const delta = { strength: { value: "15" } }; + const options = { useEval: true, useParse: false, useConstrain: true }; + + // Act + const processor = new AttrProcessor(character, delta, options); + + // Assert + expect(processor.character).toBe(character); + expect(processor.delta).toEqual(delta); + expect(processor.eval).toBe(true); + expect(processor.parse).toBe(false); + expect(processor.constrain).toBe(true); + }); + }); + + describe("init", () => { + it("should initialize attributes and return processed delta", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + createObj("attribute", { + _characterid: character.id, + name: "strength", + current: "10" + }); + const delta = { strength: { value: "15" } }; + const processor = new AttrProcessor(character, delta); + + // Act + const result = await processor.init(); + + // Assert + expect(processor.attributes).toHaveLength(1); + expect(processor.attributes[0].get("name")).toBe("strength"); + expect(result).toEqual(delta); + }); + }); + + describe("parseAttributes", () => { + it("should process simple attribute values", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const delta = { + strength: { value: "15" }, + dexterity: { value: "12", max: "18" } + }; + const processor = new AttrProcessor(character, delta); + + // Act + await processor.init(); + + // Assert + expect(processor.delta).toEqual({ + strength: { value: "15" }, + dexterity: { value: "12", max: "18" } + }); + }); + + it("should parse referenced attributes when useParse is true", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + createObj("attribute", { + _characterid: character.id, + name: "strength", + current: "10" + }); + createObj("attribute", { + _characterid: character.id, + name: "dexterity", + current: "12", + max: "18" + }); + const delta = { + new_str: { value: "%strength%" }, + new_dex: { value: "%dexterity%", max: "%dexterity_max%" } + }; + const processor = new AttrProcessor(character, delta, { useParse: true }); + + // Act + const result = await processor.init(); + + // Assert + expect(result.new_str.value).toBe("10"); + expect(result.new_dex.value).toBe("12"); + expect(result.new_dex.max).toBe("18"); + }); + + it("should evaluate expressions when useEval is true", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const delta = { + strength: { value: "10 + 5" }, + dexterity: { value: "8 * 2", max: "20 - 2" } + }; + const processor = new AttrProcessor(character, delta, { useEval: true }); + + // Act + const result = await processor.init(); + + // Assert + expect(result.strength.value).toBe("15"); + expect(result.dexterity.value).toBe("16"); + expect(result.dexterity.max).toBe("18"); + }); + + it("should modify values when useModify is true", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + createObj("attribute", { + _characterid: character.id, + name: "strength", + current: "10" + }); + createObj("attribute", { + _characterid: character.id, + name: "dexterity", + current: "10" + }); + const delta = { + strength: { value: "5" }, + dexterity: { value: "-3" } + }; + const processor = new AttrProcessor(character, delta, { useModify: true }); + // Act + const result = await processor.init(); + // Assert + expect(result.strength.value).toBe("15"); + expect(result.dexterity.value).toBe("7"); + }); + + it("should constrain values when useConstrain is true", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + createObj("attribute", { + _characterid: character.id, + name: "health", + current: "10", + max: "10" + }); + createObj("attribute", { + _characterid: character.id, + name: "mana", + current: "3", + max: "20" + }); + const delta = { + health: { value: "5" }, + mana: { value: "-5" }, + }; + const processor = new AttrProcessor(character, delta, { useConstrain: true }); + + // Act + const result = await processor.init(); + + // Assert + expect(result.health.value).toBe("10"); // Constrained to max + expect(result.mana.value).toBe("0"); // Constrained to lower bound 0 + }); + + it("should combine parse and eval when both are enabled", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + createObj("attribute", { + _characterid: character.id, + name: "base_str", + current: "10" + }); + const delta = { + strength: { value: "%base_str% + 5" } + }; + const processor = new AttrProcessor(character, delta, { useParse: true, useEval: true }); + + // Act + const result = await processor.init(); + + // Assert + expect(result.strength.value).toBe("15"); + }); + + it("should modify both current and max values when useModify is true", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + createObj("attribute", { + _characterid: character.id, + name: "health", + current: "10", + max: "20" + }); + const delta = { + health: { value: "5", max: "10" } + }; + const processor = new AttrProcessor(character, delta, { useModify: true }); + + // Act + const result = await processor.init(); + + // Assert + expect(result.health.value).toBe("15"); + expect(result.health.max).toBe("30"); + }); + }); + + describe("repeating sections", () => { + it("should handle repeating section attributes", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const rowId = "row1"; + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${rowId}_skillname`, + current: "Stealth" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${rowId}_skillvalue`, + current: "5" + }); + + // Use existing repeating ID + const delta = { + [`repeating_skills_${rowId}_skillbonus`]: { value: "3" } + }; + const processor = new AttrProcessor(character, delta); + + // Act + const result = await processor.init(); + + // Assert + expect(result[`repeating_skills_${rowId}_skillbonus`]).toEqual({ + value: "3", + }); + + // Verify repeatingData was populated correctly + expect(processor.repeating.repeatingData).toHaveProperty("skills"); + expect(processor.repeating.repeatingData.skills).toHaveProperty(rowId); + }); + + it("should create new row when -CREATE is used", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const delta = { + "repeating_skills_-CREATE_skillname": { value: "Acrobatics" }, + "repeating_skills_-CREATE_skillvalue": { value: "4" } + }; + const processor = new AttrProcessor(character, delta); + + // Act + const result = await processor.init(); + + // Assert + // Check that a new row ID was generated + const keys = Object.keys(result); + expect(keys.length).toBe(2); + + // Both should have the same new row ID replacing -CREATE + const rowIdMatch = keys[0].match(/repeating_skills_([^_]+)_skillname/); + expect(rowIdMatch).toBeTruthy(); + + const newRowId = rowIdMatch?.[1]; + expect(newRowId).toBeTruthy(); + expect(newRowId).not.toBe("-CREATE"); + + // Both attributes should use the same row ID + expect(keys[0]).toBe(`repeating_skills_${newRowId}_skillname`); + expect(keys[1]).toBe(`repeating_skills_${newRowId}_skillvalue`); + }); + }); + + describe("handling repeating orders", () => { + it("should process repeating orders correctly", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const row1 = "row1"; + const row2 = "row2"; + + // Create reporder attribute + createObj("attribute", { + _characterid: character.id, + name: `_reporder_skills`, + current: `${row1},${row2}` + }); + + // Create some repeating attributes + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row1}_skillname`, + current: "Stealth" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row2}_skillname`, + current: "Perception" + }); + + const delta = { strength: { value: "15" } }; // Non-repeating delta for simplicity + const processor = new AttrProcessor(character, delta); + + // Act + await processor.init(); + + // Assert + expect(processor.repeating.repeatingOrders).toHaveProperty("skills"); + expect(processor.repeating.repeatingOrders.skills).toContain(row1); + expect(processor.repeating.repeatingOrders.skills).toContain(row2); + expect(processor.repeating.repeatingOrders.skills.indexOf(row1)) + .toBeLessThan(processor.repeating.repeatingOrders.skills.indexOf(row2)); + }); + + it("should handle row number references", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const row1 = "row1"; + const row2 = "row2"; + + // Create reporder attribute + createObj("attribute", { + _characterid: character.id, + name: `_reporder_skills`, + current: `${row1},${row2}` + }); + + // Create some repeating attributes + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row1}_skillname`, + current: "Stealth" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row2}_skillname`, + current: "Perception" + }); + + const delta = { + "repeating_skills_$0_skillbonus": { value: "3" }, + "repeating_skills_$1_skillbonus": { value: "2" } + }; + const processor = new AttrProcessor(character, delta); + + // Act + const result = await processor.init(); + + // Assert + expect(result).toHaveProperty(`repeating_skills_${row1}_skillbonus`); + expect(result).toHaveProperty(`repeating_skills_${row2}_skillbonus`); + expect(result[`repeating_skills_${row1}_skillbonus`].value).toBe("3"); + expect(result[`repeating_skills_${row2}_skillbonus`].value).toBe("2"); + }); + + it("should handle mixed ordered and unordered repeating rows", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const row1 = "row1"; + const row2 = "row2"; + const row3 = "row3"; + const row4 = "row4"; + + // Create reporder attribute with only some of the rows + createObj("attribute", { + _characterid: character.id, + name: `_reporder_skills`, + current: `${row1},${row3}` // Only includes row1 and row3 + }); + + // Create repeating attributes for all rows (including unordered ones) + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row1}_skillname`, + current: "Stealth" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row2}_skillname`, // Not in the order + current: "Acrobatics" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row3}_skillname`, + current: "Perception" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row4}_skillname`, // Not in the order + current: "Athletics" + }); + + const delta = { + // Reference all rows by index + "repeating_skills_$0_skillbonus": { value: "3" }, + "repeating_skills_$1_skillbonus": { value: "4" }, + "repeating_skills_$2_skillbonus": { value: "5" }, + "repeating_skills_$3_skillbonus": { value: "6" } + }; + const processor = new AttrProcessor(character, delta); + + // Act + const result = await processor.init(); + + // Assert + // The processor should have built a complete order with all rows + expect(processor.repeating.repeatingOrders).toHaveProperty("skills"); + expect(processor.repeating.repeatingOrders.skills).toHaveLength(4); + + // The order should maintain the original order from reporder for the first items + expect(processor.repeating.repeatingOrders.skills[0]).toBe(row1); + expect(processor.repeating.repeatingOrders.skills[1]).toBe(row3); + + // The unordered rows should be included (though the exact order isn't specified) + expect(processor.repeating.repeatingOrders.skills).toContain(row2); + expect(processor.repeating.repeatingOrders.skills).toContain(row4); + + // Verify the row references are resolved correctly + expect(result).toHaveProperty(`repeating_skills_${row1}_skillbonus`); + expect(result).toHaveProperty(`repeating_skills_${row3}_skillbonus`); + expect(result[`repeating_skills_${row1}_skillbonus`].value).toBe("3"); + expect(result[`repeating_skills_${row3}_skillbonus`].value).toBe("4"); + + // Check if the other rows also got their skill bonuses + // We don't know the exact order of the unordered rows, but we can confirm they exist + const unorderedRows = [row2, row4]; + const unorderedValues = ["5", "6"]; + + for (const row of unorderedRows) { + expect(result).toHaveProperty(`repeating_skills_${row}_skillbonus`); + expect(unorderedValues).toContain(result[`repeating_skills_${row}_skillbonus`].value); + } + }); + + it("should handle references using both direct IDs and row indices", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const row1 = "row1"; + const row2 = "row2"; + + // Create reporder attribute + createObj("attribute", { + _characterid: character.id, + name: `_reporder_skills`, + current: `${row1},${row2}` + }); + + // Create repeating attributes + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row1}_skillname`, + current: "Stealth" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row1}_skillvalue`, + current: "10" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${row2}_skillname`, + current: "Perception" + }); + + const delta = { + // Mix of direct ID and row index references + [`repeating_skills_${row1}_skillbonus`]: { value: "3" }, + "repeating_skills_$1_skillbonus": { value: "4" }, + }; + const processor = new AttrProcessor(character, delta, { useParse: true }); + + // Act + const result = await processor.init(); + + // Assert + // Direct ID reference works + expect(result[`repeating_skills_${row1}_skillbonus`].value).toBe("3"); + + // Row index reference works + expect(result[`repeating_skills_${row2}_skillbonus`].value).toBe("4"); + }); + }); + + describe("error handling", () => { + it("should handle evaluation errors gracefully", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const delta = { + strength: { value: "10 + 5" }, + invalid: { value: "this is not valid math" } + }; + const processor = new AttrProcessor(character, delta, { useEval: true }); + + // Act + const result = await processor.init(); + + // Assert + expect(result.strength.value).toBe("15"); + expect(result.invalid.value).toBe("this is not valid math"); + }); + + it("should handle missing references gracefully", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const delta = { + new_str: { value: "%nonexistent%" } + }; + const processor = new AttrProcessor(character, delta, { useParse: true }); + + // Act + const result = await processor.init(); + + // Assert + expect(result.new_str.value).toBe("%nonexistent%"); + }); + }); + + describe("complex integration scenarios", () => { + it("should handle complex operations with repeating sections, references, and evaluation", async () => { + // Arrange + const character = createObj("character", { id: "char1", name: "TestChar" }); + const rowId = "row1"; + + // Create base attributes + createObj("attribute", { + _characterid: character.id, + name: "base_bonus", + current: "5" + }); + createObj("attribute", { + _characterid: character.id, + name: "multiplier", + current: "2" + }); + + // Create existing repeating row + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${rowId}_skillname`, + current: "Stealth" + }); + createObj("attribute", { + _characterid: character.id, + name: `repeating_skills_${rowId}_skillbonus`, + current: "3" + }); + + // Create reporder attribute + createObj("attribute", { + _characterid: character.id, + name: `_reporder_skills`, + current: `${rowId}` + }); + + // Complex delta that: + // 1. References existing attributes with %attr% + // 2. Uses evaluation with math expressions + // 3. Uses row references with $0 + // 4. Creates new rows with -CREATE + const delta = { + // Modify existing row with reference and evaluation + [`repeating_skills_$0_total`]: { + value: "%base_bonus% + %repeating_skills_$0_skillbonus% * %multiplier%" + }, + + // Create a new row with evaluated values + "repeating_skills_-CREATE_skillname": { value: "Perception" }, + "repeating_skills_-CREATE_skillbonus": { value: "2 + 2" }, + "repeating_skills_-CREATE_total": { value: "%base_bonus% * 3" } + }; + + const processor = new AttrProcessor(character, delta, { + useParse: true, + useEval: true + }); + + // Act + const result = await processor.init(); + + // Assert + // Check the modification to the existing row - should be 5 + 3 * 2 = 11 + expect(result[`repeating_skills_${rowId}_total`].value).toBe("11"); + + // Check that the new row was created with consistent ID + const newRowKeys = Object.keys(result).filter(key => + key.includes("repeating_skills_") && !key.includes(rowId)); + + expect(newRowKeys.length).toBe(3); // Should have 3 attributes in new row + + // Extract the new row ID + const newRowMatch = newRowKeys[0].match(/repeating_skills_([^_]+)_/); + const newRowId = newRowMatch![1]; + + // Verify all new attributes use the same row ID + expect(newRowKeys.every(key => key.includes(newRowId))).toBe(true); + + // Check the evaluated values in the new row + expect(result[`repeating_skills_${newRowId}_skillname`].value).toBe("Perception"); + expect(result[`repeating_skills_${newRowId}_skillbonus`].value).toBe("4"); // 2+2=4 + expect(result[`repeating_skills_${newRowId}_total`].value).toBe("15"); // 5*3=15 + + // Check that the processor correctly organized repeating data + expect(processor.repeating.repeatingData).toHaveProperty("skills"); + expect(processor.repeating.repeatingData.skills).toHaveProperty(rowId); + + // Check that the processor correctly processed repeating orders + expect(processor.repeating.repeatingOrders).toHaveProperty("skills"); + expect(processor.repeating.repeatingOrders.skills).toContain(rowId); + }); + }); +}); diff --git a/ChatSetAttr/build/tests/unit/ChatOutput.test.ts b/ChatSetAttr/build/tests/unit/ChatOutput.test.ts new file mode 100644 index 0000000000..85e3a78873 --- /dev/null +++ b/ChatSetAttr/build/tests/unit/ChatOutput.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { ChatOutput } from "../../src/classes/ChatOutput"; + +describe("ChatOutput", () => { + let sendChatSpy: any; + + beforeEach(() => { + // Set up test environment + global.setupTestEnvironment(); + + // Spy on sendChat + sendChatSpy = vi.spyOn(global, 'sendChat'); + }); + + afterEach(() => { + // Clean up test environment + vi.clearAllMocks(); + }); + + describe("constructor", () => { + it("should create a ChatOutput instance with default values", () => { + // Arrange & Act + const chatOutput = new ChatOutput({ + header: "Test Header", + content: "Test Content" + }); + + // Assert + expect(chatOutput.header).toBe("Test Header"); + expect(chatOutput.content).toBe("Test Content"); + expect(chatOutput.from).toBe("ChatOutput"); + expect(chatOutput.type).toBe("info"); + }); + + it("should create a ChatOutput instance with custom values", () => { + // Arrange & Act + const chatOutput = new ChatOutput({ + header: "Error Header", + content: "Error Message", + from: "System", + type: "error", + }); + + // Assert + expect(chatOutput.header).toBe("Error Header"); + expect(chatOutput.content).toBe("Error Message"); + expect(chatOutput.from).toBe("System"); + expect(chatOutput.type).toBe("error"); + }); + }); + + describe("send", () => { + it("should send an info message with header and content", () => { + // Arrange + const chatOutput = new ChatOutput({ + header: "Info Header", + content: "Info Content" + }); + + // Act + chatOutput.send(); + + // Assert + expect(sendChatSpy).toHaveBeenCalledWith( + "ChatOutput", + expect.stringContaining(" { + // Arrange + const chatOutput = new ChatOutput({ + header: "Error Header", + content: "Error Content", + from: "System", + type: "error" + }); + + // Act + chatOutput.send(); + + // Assert + expect(sendChatSpy).toHaveBeenCalledWith( + "System", + expect.stringContaining("border: 1px solid #f00;"), + undefined, + { noarchive: true } + ); + expect(sendChatSpy).toHaveBeenCalledWith( + "System", + expect.stringContaining("color: #f00;"), + undefined, + { noarchive: true } + ); + expect(sendChatSpy).toHaveBeenCalledWith( + "System", + expect.stringContaining("Error Header"), + undefined, + { noarchive: true } + ); + expect(sendChatSpy).toHaveBeenCalledWith( + "System", + expect.stringContaining("Error Content"), + undefined, + { noarchive: true } + ); + }); + + it("should handle messages without header", () => { + // Arrange + const chatOutput = new ChatOutput({ + header: "", + content: "Content Only" + }); + + // Act + chatOutput.send(); + + // Assert + expect(sendChatSpy).toHaveBeenCalledWith( + "ChatOutput", + expect.not.stringContaining(" { + // Arrange + const chatOutput = new ChatOutput({ + header: "Header Only", + content: "" + }); + + // Act + chatOutput.send(); + + // Assert + expect(sendChatSpy).toHaveBeenCalledWith( + "ChatOutput", + expect.stringContaining(""), + undefined, + { noarchive: true } + ); + }); + }); + + describe("style handling", () => { + it("should apply info styling correctly", () => { + // Arrange + const chatOutput = new ChatOutput({ + header: "Info Header", + content: "Info Content" + }); + + // Act + chatOutput.send(); + + // Assert + expect(sendChatSpy).toHaveBeenCalledWith( + "ChatOutput", + expect.stringContaining("background-color: #f9f9f9;"), + undefined, + { noarchive: true } + ); + expect(sendChatSpy).toHaveBeenCalledWith( + "ChatOutput", + expect.stringContaining("border: 1px solid #ccc;"), + undefined, + { noarchive: true } + ); + expect(sendChatSpy).toHaveBeenCalledWith( + "ChatOutput", + expect.stringContaining("font-weight: bold;"), + undefined, + { noarchive: true } + ); + }); + + it("should apply error styling correctly", () => { + // Arrange + const chatOutput = new ChatOutput({ + header: "Error Header", + content: "Error Content", + from: "System", + type: "error" + }); + + // Act + chatOutput.send(); + + // Assert + expect(sendChatSpy).toHaveBeenCalledWith( + "System", + expect.stringContaining("border: 1px solid #f00;"), + undefined, + { noarchive: true } + ); + expect(sendChatSpy).toHaveBeenCalledWith( + "System", + expect.stringContaining("color: #f00;"), + undefined, + { noarchive: true } + ); + }); + }); + + describe("HTML structure", () => { + it("should create a properly structured HTML output", () => { + // Arrange + const chatOutput = new ChatOutput({ + header: "Test Header", + content: "Test Content" + }); + + // Act + chatOutput.send(); + + // Assert + // Check that sendChat is called with properly structured HTML + const sendChatArg = sendChatSpy.mock.calls[0][1]; + + // Test individual components + expect(sendChatArg).toMatch(/^
/); + expect(sendChatArg).toMatch(/

Test Header<\/h3>/); + expect(sendChatArg).toMatch(/

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(); + + for (let i = 0; i < uuidCount; i++) { + uuids.add(UUID.generateUUID()); + } + + // All generated UUIDs should be unique + expect(uuids.size).toBe(uuidCount); + }); + + it("generates UUIDs with consistent length", () => { + for (let i = 0; i < 100; i++) { + expect(UUID.generateUUID().length).toBe(20); + } + }); + + it("generates UUIDs with time component at the beginning", () => { + // Generate two UUIDs with a delay to ensure different timestamps + const uuid1 = UUID.generateUUID(); + + // Force a small delay + const startTime = Date.now(); + while(Date.now() - startTime < 10) { + // Wait a bit to ensure different timestamp + } + + const uuid2 = UUID.generateUUID(); + + // The time components (first 8 chars) should be different + expect(uuid1.substring(0, 8)).not.toBe(uuid2.substring(0, 8)); + }); +}); diff --git a/ChatSetAttr/build/tsconfig.json b/ChatSetAttr/build/tsconfig.json new file mode 100644 index 0000000000..1dc29da429 --- /dev/null +++ b/ChatSetAttr/build/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2024", + "useDefineForClassFields": true, + "module": "ES2022", + "skipLibCheck": true, + "baseUrl": "./", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "files": [ "types/global.d.ts" ], + "include": ["src", "tests"] +} diff --git a/ChatSetAttr/build/types/global.d.ts b/ChatSetAttr/build/types/global.d.ts new file mode 100644 index 0000000000..b9305dfcd3 --- /dev/null +++ b/ChatSetAttr/build/types/global.d.ts @@ -0,0 +1,1302 @@ +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +/** + * Base interface for all Roll20 objects with common methods + */ +interface Roll20Object> { + /** The unique ID of this object */ + id: string; + properties: Prettify; + + /** + * Get an attribute of the object + * @param property Name of the property to get + * @returns Value of the property or undefined if the property doesn't exist + */ + get(property: K | "_type" | "_id"): T[K]; + + /** + * Set attributes of the object + * @param properties Object containing the properties to set and their values + * @returns This object for chaining + */ + set(properties: Partial): this; + + /** + * Remove this object from Roll20 + */ + remove(): void; +} + +// Campaign type with proper properties +type CampaignProperties = { + _id: string; + _type: "campaign"; + turnorder: string; + initiativepage: string | false; + playerpageid: string | false; + playerspecificpages: {[playerId: string]: string} | false; + _journalfolder: string; + _jukeboxfolder: string; + token_markers: string; +}; + +declare type Roll20Campaign = Prettify>; + +// Player type with proper properties +type PlayerProperties = { + _id: string; + _type: "player"; + _d20userid: string; + _displayname: string; + _online: boolean; + _lastpage: string; + _macrobar: string; + speakingas: string; + color: string; + showmacrobar: boolean; +}; + +declare type Roll20Player = Prettify>; + +// Page type with proper properties +type PageProperties = { + _id: string; + _type: "page"; + _zorder: string; + name: string; + width: number; + height: number; + background_color: string; + archived: boolean; + jukeboxtrigger: string; + showdarkness: boolean; + fog_opacity: number; + showgrid: boolean; + grid_opacity: number; + gridcolor: string; + grid_type: "square" | "hex" | "hexr" | "dimetric" | "isometric"; + gridlabels: boolean; + snapping_increment: number; + scale_number: number; + scale_units: string; + diagonaltype: "foure" | "pythagorean" | "threefive" | "manhattan"; + + // Dynamic lighting properties + dynamic_lighting_enabled: boolean; + daylight_mode_enabled: boolean; + daylightModeOpacity: number; + explorer_mode: "basic" | "off"; + force_lighting_refresh: boolean; + lightupdatedrop: boolean; + + // Legacy dynamic lighting properties + showlighting: boolean; + lightenforcelos: boolean; + lightrestrictmove: boolean; + lightglobalillum: boolean; +}; + +declare type Roll20Page = Prettify>; + +// Path type with proper properties +type PathProperties = { + _id: string; + _type: "path"; + _pageid: string; + _path: string; + fill: string; + stroke: string; + rotation: number; + layer: "gmlayer" | "objects" | "map" | "walls"; + stroke_width: number; + width: number; + height: number; + top: number; + left: number; + scaleX: number; + scaleY: number; + barrierType: "wall" | "oneWay" | "transparent"; + oneWayReversed: boolean; + controlledby: string; +}; + +declare type Roll20Path = Prettify>; + +// Text type with proper properties +type TextProperties = { + _id: string; + _type: "text"; + _pageid: string; + top: number; + left: number; + width: number; + height: number; + text: string; + font_size: number; + rotation: number; + color: string; + font_family: string; + layer: "gmlayer" | "objects" | "map" | "walls"; + controlledby: string; +}; + +declare type Roll20Text = Prettify>; + +// Graphic type with proper properties +type GraphicProperties = { + _id: string; + _type: "graphic"; + _subtype: "token" | "card"; + _cardid?: string; + _pageid: string; + imgsrc: string; + represents: string; + left: number; + top: number; + width: number; + height: number; + rotation: number; + layer: "gmlayer" | "objects" | "map" | "walls"; + isdrawing: boolean; + flipv: boolean; + fliph: boolean; + name: string; + gmnotes: string; + tooltip: string; + show_tooltip: boolean; + controlledby: string; + bar1_link: string; + bar2_link: string; + bar3_link: string; + bar1_value: string; + bar2_value: string; + bar3_value: string; + bar1_max: string; + bar2_max: string; + bar3_max: string; + bar_location: "overlap_top" | "overlap_bottom" | "bottom"; + compact_bar: "compact" | ""; + aura1_radius: string; + aura2_radius: string; + aura1_color: string; + aura2_color: string; + aura1_square: boolean; + aura2_square: boolean; + tint_color: string; + statusmarkers: string; + showname: boolean; + showplayers_name: boolean; + showplayers_bar1: boolean; + showplayers_bar2: boolean; + showplayers_bar3: boolean; + showplayers_aura1: boolean; + showplayers_aura2: boolean; + playersedit_name: boolean; + playersedit_bar1: boolean; + playersedit_bar2: boolean; + playersedit_bar3: boolean; + playersedit_aura1: boolean; + playersedit_aura2: boolean; + lastmove: string; + sides: string; + currentSide: number; + lockMovement: boolean; +}; + +declare type Roll20Graphic = Prettify>; + +// Character type with proper properties +type CharacterProperties = { + _id: string; + _type: "character"; + avatar: string; + name: string; + bio: string; + gmnotes: string; + archived: boolean; + inplayerjournals: string; + controlledby: string; + _defaulttoken: string; +}; + +declare type Roll20Character = Prettify>; + +// Attribute type with proper properties +type AttributeProperties = { + _id: string; + _type: "attribute"; + _characterid: string; + name: string; + current: string; + max: string; +}; + +declare type Roll20Attribute = Prettify & { + setWithWorker: (attributes: Partial) => void +}>; + +// Ability type with proper properties +type AbilityProperties = { + _id: string; + _type: "ability"; + _characterid: string; + name: string; + description: string; + action: string; + istokenaction: boolean; +}; + +declare type Roll20Ability = Prettify>; + +// Handout type with proper properties +type HandoutProperties = { + _id: string; + _type: "handout"; + avatar: string; + name: string; + notes: string; + gmnotes: string; + inplayerjournals: string; + archived: boolean; + controlledby: string; +}; + +declare type Roll20Handout = Prettify>; + +// Macro type with proper properties +type MacroProperties = { + _id: string; + _type: "macro"; + _playerid: string; + name: string; + action: string; + visibleto: string; + istokenaction: boolean; +}; + +declare type Roll20Macro = Prettify>; + +// RollableTable type with proper properties +type RollableTableProperties = { + _id: string; + _type: "rollabletable"; + name: string; + showplayers: boolean; +}; + +declare type Roll20Table = Prettify>; + +// TableItem type with proper properties +type TableItemProperties = { + _id: string; + _type: "tableitem"; + _rollabletableid: string; + avatar: string; + name: string; + weight: number; +}; + +declare type Roll20TableItem = Prettify>; + +// Deck type with proper properties +type DeckProperties = { + _id: string; + _type: "deck"; + name: string; + _currentDeck: string; + _currentIndex: number; + _currentCardShown: boolean; + showplayers: boolean; + playerscandraw: boolean; + avatar: string; + shown: boolean; + players_seenumcards: boolean; + players_seefrontofcards: boolean; + gm_seenumcards: boolean; + gm_seefrontofcards: boolean; + infinitecards: boolean; + _cardSequencer: number; + cardsplayed: "faceup" | "facedown"; + defaultheight: string; + defaultwidth: string; + discardpilemode: "none" | "choosebacks" | "choosefronts" | "drawtop" | "drawbottom"; + _discardPile: string; + removedcardsmode: "choosefronts" | string; + removedcards: "none" | string; +}; + +declare type Roll20Deck = Prettify>; + +// Card type with proper properties +type CardProperties = { + name: string; + avatar: string; + _deckid: string; + _type: "card"; + _id: string; + is_removed: "false" | "true"; + tooltip: string; + card_back: string; +}; + +declare type Roll20Card = Prettify>; + +// Hand type with proper properties +type HandProperties = { + currentHand: string; + _type: "hand"; + _parentid: string; + _id: string; + currentView: "bydeck" | "bycard"; +}; + +declare type Roll20Hand = Prettify>; + +// JukeboxTrack type with proper properties +type JukeboxTrackProperties = { + _id: string; + _type: "jukeboxtrack"; + playing: boolean; + softstop: boolean; + title: string; + volume: number; + loop: boolean; +}; + +declare type Roll20JukeboxTrack = Prettify>; + +// CustomFX type with proper properties +type CustomFXProperties = { + _id: string; + _type: "custfx"; + name: string; + definition: Record; +}; + +declare type Roll20CustomFX = Prettify>; + +// Door type with proper properties +type DoorProperties = { + _id: string; + _type: "door"; + color: string; + x: number; + y: number; + isOpen: boolean; + isLocked: boolean; + isSecret: boolean; + path: { + handle0: { x: number; y: number }; + handle1: { x: number; y: number }; + }; +}; + +declare type Roll20Door = Prettify>; + +// Window type with proper properties +type WindowProperties = { + _id: string; + _type: "window"; + color: string; + x: number; + y: number; + isOpen: boolean; + isLocked: boolean; + isSecret: boolean; + path: { + handle0: { x: number; y: number }; + handle1: { x: number; y: number }; + }; + pageid?: string; +}; + +declare type Roll20Window = Prettify>; + +// Refactored: Create a discriminated union based on the _type property in each object's properties +type Roll20ObjectUnion = + | Roll20Campaign + | Roll20Player + | Roll20Page + | Roll20Path + | Roll20Text + | Roll20Graphic + | Roll20Character + | Roll20Attribute + | Roll20Ability + | Roll20Handout + | Roll20Macro + | Roll20Table + | Roll20TableItem + | Roll20Deck + | Roll20Card + | Roll20Hand + | Roll20JukeboxTrack + | Roll20CustomFX + | Roll20Door + | Roll20Window; + +// Type mapping for getObj function +type Roll20ObjectTypeToInstance = { + "campaign": Roll20Campaign; + "player": Roll20Player; + "page": Roll20Page; + "path": Roll20Path; + "text": Roll20Text; + "graphic": Roll20Graphic; + "character": Roll20Character; + "attribute": Roll20Attribute; + "ability": Roll20Ability; + "handout": Roll20Handout; + "macro": Roll20Macro; + "rollabletable": Roll20Table; + "tableitem": Roll20TableItem; + "deck": Roll20Deck; + "card": Roll20Card; + "hand": Roll20Hand; + "jukeboxtrack": Roll20JukeboxTrack; + "custfx": Roll20CustomFX; + "door": Roll20Door; + "window": Roll20Window; +}; + +/** + * Gets a Roll20 object by type and ID + * @param type The type of object to get (e.g., "character", "graphic", "attribute") + * @param id The unique ID of the object to get + * @returns The found object or null if no object with that ID exists of that type + */ +declare function getObj( + type: T, + id: string +): Roll20ObjectTypeToInstance[T] | null; + +/** + * Options for the findObjs function + */ +type FindObjsOptions = { + /** If true, string comparisons are case-insensitive */ + caseInsensitive?: boolean; +}; + +/** + * Gets an array of Roll20 objects that match the specified properties + * @param attrs An object with properties to match against Roll20 objects + * @param options Additional options for the search + * @returns An array of Roll20 objects matching the criteria + * + * @example Find all graphics on the current player page + * const currentPageGraphics = findObjs({ + * _pageid: Campaign().get("playerpageid"), + * _type: "graphic" + * }); + * + * @example Find tokens named "Target" (case-insensitive) + * const targetTokens = findObjs({ + * name: "target" + * }, {caseInsensitive: true}); + */ +declare function findObjs(attrs: Partial & { _type: T }, options?: FindObjsOptions): Roll20ObjectTypeToInstance[T][]; + +/** + * Filters Roll20 objects by executing the callback function on each object + * @param callback A function that takes a Roll20 object and returns true if it should be included + * @returns An array of Roll20 objects for which the callback returned true + * + * @remarks + * It is generally inadvisable to use filterObjs() for most purposes – due to findObjs() having + * built-in indexing for better executing speed, it is almost always better to use findObjs() + * to get objects of the desired type first, then filter them using the native .filter() method for arrays. + * + * @example Find all objects in the top-left 200x200 area + * const results = filterObjs(function(obj) { + * if(obj.get("left") < 200 && obj.get("top") < 200) return true; + * else return false; + * }); + */ +declare function filterObjs(callback: (obj: Roll20Object) => boolean): Roll20Object[]; + +/** + * Returns an array of all objects in the game (all types) + * @returns An array of all Roll20 objects in the game + * + * @remarks + * This is equivalent to calling filterObjs and just returning true for every object. + */ +declare function getAllObjs(): Roll20Object[]; + +/** + * Gets the value of an attribute for a character + * @param character_id The ID of the character + * @param attribute_name The name of the attribute + * @param value_type Optional parameter to specify "current" or "max" (defaults to "current") + * @returns The value of the attribute or the default value from the character sheet if not present + * + * @remarks + * This function only gets the value of the attribute, not the attribute object itself. + * If you need to reference properties other than "current" or "max", or if you need to + * change properties of the attribute, you must use other functions like findObjs. + * + * For Repeating Sections, you can use the format repeating_section_$n_attribute, where n is + * the repeating row number (RowIndex) (starting with zero). + * For example, repeating_spells_$2_name will return the value of name from the third row of repeating_spells. + */ +declare function getAttrByName(character_id: string, attribute_name: string, value_type?: "current" | "max"): string; + +/** + * Gets the value of a sheet item for a character (2024 sheet) + * @param character_id The ID of the character + * @param item_name The name of the sheet item + * @param value_type Optional parameter to specify "current" or "max" (defaults to "current") + * @returns Promise that resolves to the value of the sheet item + * + * @remarks + * This is an asynchronous function that returns a Promise. It is used with the 2024 sheet. + * + * @example Get a character's hit points + * const hp = await getSheetItem("-KxUZ0wYG9gDCGukimEE", "hp"); + * + * @example Get a character's maximum hit points + * const maxHp = await getSheetItem("-KxUZ0wYG9gDCGukimEE", "hp", "max"); + */ +declare function getSheetItem(character_id: string, item_name: string, value_type?: "current" | "max"): Promise; + +/** + * Sets the value of a sheet item for a character (2024 sheet) + * @param character_id The ID of the character + * @param item_name The name of the sheet item + * @param value The value to set + * @param value_type Optional parameter to specify "current" or "max" (defaults to "current") + * @returns Promise that resolves to true if successful + * + * @remarks + * This is an asynchronous function that returns a Promise. It is used with the 2024 sheet. + * + * @example Set a character's hit points + * await setSheetItem("-KxUZ0wYG9gDCGukimEE", "hp", 10); + * + * @example Set a character's maximum hit points + * await setSheetItem("-KxUZ0wYG9gDCGukimEE", "hp", 20, "max"); + */ +declare function setSheetItem(character_id: string, item_name: string, value: any, value_type?: "current" | "max"): Promise; + +/** + * Logs a message to the API console on the Script Editor page + * @param message The message to log (can be any type) + * + * @remarks + * Useful for debugging your scripts and getting a better handle on what's going on inside the API sandbox. + * + * @example Log a simple string + * log("Script initialized"); + * + * @example Log an object + * log(character); + */ +declare function log(message: any): void; + +/** + * Moves an object to the front of its current layer + * @param obj The Roll20 object to move to the front + * + * @example Move a token to the front + * const token = getObj("graphic", "-KxUZ0wYG9gDCGukimEE"); + * toFront(token); + */ +declare function toFront(obj: Roll20Object): void; + +/** + * Moves an object to the back of its current layer + * @param obj The Roll20 object to move to the back + * + * @example Move a token to the back + * const token = getObj("graphic", "-KxUZ0wYG9gDCGukimEE"); + * toBack(token); + */ +declare function toBack(obj: Roll20Object): void; + +/** + * Returns a random integer from 1 to max (inclusive) + * @param max The maximum value (inclusive) + * @returns A random integer from 1 to max + * + * @remarks + * This function accounts for Modulo Bias which ensures that the resulting random numbers are evenly distributed. + * This is the same functionality that Roll20 uses to power its dice rolls, and these numbers have been + * statistically and rigorously proven to be random. + * + * @example Roll a d20 + * const result = randomInteger(20); + */ +declare function randomInteger(max: number): number; + +/** + * Checks if a player is a GM + * @param playerid The ID of the player to check + * @returns True if the player is a GM, false otherwise + * + * @remarks + * The function will always return the correct answer depending on the current moment, + * so even if a GM chooses to re-join as a player or a player is promoted to a GM mid-game, + * playerIsGM() will respond accordingly without any need to clear a cache or restart the API sandbox. + * + * @example Check if the current player is a GM + * if(playerIsGM(msg.playerid)) { + * // Only allow GMs to use this command + * } + */ +declare function playerIsGM(playerid: string): boolean; + +/** + * Sets the default token for a character + * @param character The character object + * @param token The token object to use as the default + * + * @remarks + * Sets the default token for the supplied Character Object to the details of the supplied Token Object. + * Both objects must already exist. This will overwrite any default token currently associated with the character. + * + * @example Set a character's default token + * const character = getObj("character", "-KxUZ0wYG9gDCGukimEE"); + * const token = getObj("graphic", "-AbCdEfGhIjKlMnOpQr"); + * setDefaultTokenForCharacter(character, token); + */ +declare function setDefaultTokenForCharacter(character: Roll20Object, token: Roll20Object): void; + +/** Type definition for FX types */ +type FxType = "beam-acid" | "beam-blood" | "beam-charm" | "beam-death" | "beam-fire" | "beam-frost" | "beam-holy" | + "beam-magic" | "beam-slime" | "beam-smoke" | "beam-water" | + "bomb-acid" | "bomb-blood" | "bomb-charm" | "bomb-death" | "bomb-fire" | "bomb-frost" | "bomb-holy" | + "bomb-magic" | "bomb-slime" | "bomb-smoke" | "bomb-water" | + "breath-acid" | "breath-blood" | "breath-charm" | "breath-death" | "breath-fire" | "breath-frost" | + "breath-holy" | "breath-magic" | "breath-slime" | "breath-smoke" | "breath-water" | + "bubbling-acid" | "bubbling-blood" | "bubbling-charm" | "bubbling-death" | "bubbling-fire" | "bubbling-frost" | + "bubbling-holy" | "bubbling-magic" | "bubbling-slime" | "bubbling-smoke" | "bubbling-water" | + "burn-acid" | "burn-blood" | "burn-charm" | "burn-death" | "burn-fire" | "burn-frost" | "burn-holy" | + "burn-magic" | "burn-slime" | "burn-smoke" | "burn-water" | + "burst-acid" | "burst-blood" | "burst-charm" | "burst-death" | "burst-fire" | "burst-frost" | "burst-holy" | + "burst-magic" | "burst-slime" | "burst-smoke" | "burst-water" | + "explode-acid" | "explode-blood" | "explode-charm" | "explode-death" | "explode-fire" | "explode-frost" | + "explode-holy" | "explode-magic" | "explode-slime" | "explode-smoke" | "explode-water" | + "glow-acid" | "glow-blood" | "glow-charm" | "glow-death" | "glow-fire" | "glow-frost" | "glow-holy" | + "glow-magic" | "glow-slime" | "glow-smoke" | "glow-water" | + "missile-acid" | "missile-blood" | "missile-charm" | "missile-death" | "missile-fire" | "missile-frost" | + "missile-holy" | "missile-magic" | "missile-slime" | "missile-smoke" | "missile-water" | + "nova-acid" | "nova-blood" | "nova-charm" | "nova-death" | "nova-fire" | "nova-frost" | "nova-holy" | + "nova-magic" | "nova-slime" | "nova-smoke" | "nova-water" | + "splatter-acid" | "splatter-blood" | "splatter-charm" | "splatter-death" | "splatter-fire" | "splatter-frost" | + "splatter-holy" | "splatter-magic" | "splatter-slime" | "splatter-smoke" | "splatter-water" | string; + +/** Type definition for a point to be used in FX functions */ +type FxPoint = { + x: number; + y: number; +}; + +/** + * Spawns a brief effect at the specified location + * @param x X coordinate for the effect + * @param y Y coordinate for the effect + * @param type The type of effect or custom effect ID + * @param pageid The ID of the page for the effect (defaults to current player page) + * + * @remarks + * For built-in effects, type should be a string like "beam-color", "bomb-color", etc. where color is + * one of: acid, blood, charm, death, fire, frost, holy, magic, slime, smoke, water. + * For custom effects, type should be the ID of the custfx object for the custom effect. + * + * @example Spawn a fire explosion effect + * spawnFx(200, 300, "explode-fire"); + */ +declare function spawnFx(x: number, y: number, type: FxType | string, pageid?: string): void; + +/** + * Spawns a brief effect between two points + * @param point1 Starting point {x, y} + * @param point2 Ending point {x, y} + * @param type The type of effect or custom effect ID + * @param pageid The ID of the page for the effect (defaults to current player page) + * + * @remarks + * Works like spawnFx, but for effects that can "travel" between two points. + * The following effect types must always use spawnFxBetweenPoints instead of spawnFx: + * beam-color, breath-color, splatter-color + * + * @example Spawn an acid beam between two points + * spawnFxBetweenPoints({x: 100, y: 100}, {x: 400, y: 400}, "beam-acid"); + */ +declare function spawnFxBetweenPoints(point1: FxPoint, point2: FxPoint, type: FxType | string, pageid?: string): void; + +/** + * Spawns an ad-hoc custom effect using the provided JSON definition + * @param x X coordinate for the effect + * @param y Y coordinate for the effect + * @param definitionJSON Javascript object following the JSON specification for Custom FX + * @param pageid The ID of the page for the effect (defaults to current player page) + * + * @example Create a custom gold beam effect + * spawnFxWithDefinition(200, 300, { + * "maxParticles": 100, + * "duration": 100, + * "emissionRate": 3, + * "particleLife": 300, + * "startColour": [255, 215, 0, 1], + * "endColour": [255, 140, 0, 0] + * }); + */ +declare function spawnFxWithDefinition(x: number, y: number, definitionJSON: Record, pageid?: string): void; + +/** + * Sends a "ping" to the tabletop + * @param left X coordinate for the ping + * @param top Y coordinate for the ping + * @param pageid ID of the page to be pinged + * @param playerid ID of the player who performed the ping (defaults to "api" for yellow ping) + * @param moveAll If true, moves player views to the ping location + * @param visibleTo Player IDs who can see or be moved by the ping (single ID, array, or comma-delimited string) + * + * @example Ping a location + * sendPing(200, 300, "-KxUZ0wYG9gDCGukimEE"); + * + * @example Ping and move players to a location + * sendPing(200, 300, "-KxUZ0wYG9gDCGukimEE", null, true); + * + * @example Ping visible only to specific players + * sendPing(200, 300, "-KxUZ0wYG9gDCGukimEE", null, false, "-MNbCnYT1ss5LfD0S2yD,-MNbCnYU5ss5LfR0T2zA"); + */ +declare function sendPing( + left: number, + top: number, + pageid: string, + playerid?: string, + moveAll?: boolean, + visibleTo?: string | string[] +): void; + +/** + * Returns the singleton campaign Roll20 object + * @returns The campaign object for the current game + * + * @example Get the current player page and retrieve the page object + * const currentPageID = Campaign().get('playerpageid'); + * const currentPage = getObj('page', currentPageID); + * + * @example Get the turn order + * const turnOrderJSON = Campaign().get('turnorder'); + * const turnOrder = JSON.parse(turnOrderJSON || "[]"); + */ +declare function Campaign(): Roll20Campaign; + +/** + * Creates a new Roll20 object + * @param type The type of Roll20 object to create + * @param attributes The initial values to use for the Roll20 object's properties + * @returns The Roll20 object that was created + * + * @remarks + * Only the following types may be created: + * 'graphic', 'text', 'path', 'character', 'ability', 'attribute', + * 'handout', 'rollabletable', 'tableitem', and 'macro' + * + * @example Create a character + * const character = createObj("character", { + * name: "New Character", + * bio: "This is a new character created via API" + * }); + * + * @example Create a token on the current player page + * const token = createObj("graphic", { + * _pageid: Campaign().get("playerpageid"), + * imgsrc: "https://s3.amazonaws.com/files.d20.io/images/1234567/abcdefg.png", + * name: "New Token", + * left: 140, + * top: 200, + * width: 70, + * height: 70 + * }); + */ +declare function createObj(type: T, attributes: Partial): Roll20ObjectTypeToInstance[T]; + +/** Type definition for roll results */ + +/** + * Represents the result of an individual die roll. + */ +type IndividualDieResult = { + /** The value rolled on the die */ + v: number; + /** Optional: true if this die was dropped (e.g., due to keep highest/lowest) */ + d?: boolean; +}; + +/** + * Describes modifications applied to a roll, like keeping a certain number of dice. + */ +type RollModsKeep = { + /** How many dice to keep */ + count: number; + /** "h" for highest, "l" for lowest */ + end: "h" | "l"; +}; + +/** + * Represents modifications that can be applied to a roll or group of rolls. + */ +type RollMods = { + /** Optional: Specifies how many dice to keep (highest or lowest) */ + keep?: RollModsKeep; + // Potentially other types of modifications could be added here +}; + +/** + * Represents a standard dice roll (e.g., "1d20", "2d6kh1"). + */ +type StandardRoll = { + /** Discriminator for a regular roll */ + type: "R"; + /** Number of dice rolled */ + dice: number; + /** Number of sides on each die */ + sides: number; + /** Array of results for each die */ + results: IndividualDieResult[]; + /** Optional: modifications like keep highest/lowest */ + mods?: RollMods; +}; + +/** + * Represents a modifier in a roll expression (e.g., "+2", "-1"). + */ +type ModifierRoll = { + /** Discriminator for a modifier */ + type: "M"; + /** The modifier expression string (e.g., "+2") */ + expr: string; +}; + +/** + * Represents an item within the results of a group roll. + * This usually corresponds to the total of a sub-roll within the group. + */ +type GroupResultItem = { + /** The value of this sub-result */ + v: number; + /** Optional: true if this sub-result was dropped from the group's final total */ + d?: boolean; +}; + +/** + * Represents a group of rolls (e.g., "{1d20, 2d8}kh1"). + * The `rolls` property here contains an array of sub-groups, + * each sub-group containing an array of StandardRolls. + */ +type GroupRoll = { + /** Discriminator for a group roll */ + type: "G"; + /** The type of result for the group (e.g., "sum") */ + resultType: string; + /** + * `rolls` is an array of "sub-roll groups". Each sub-roll group is an array of StandardRolls. + * For example, in "{1d6, 1d8}", `rolls` would be `[[StandardRollFor1d6], [StandardRollFor1d8]]`. + */ + rolls: StandardRoll[][]; + /** Results of each sub-roll group before final selection (e.g., keep highest) */ + results: GroupResultItem[]; + /** Optional: modifications applied to the group result (e.g., keep highest sub-total) */ + mods?: RollMods; +}; + +/** + * A union type representing any item that can appear in the `rolls` array + * of the main `Results` object. It can be a standard roll, a modifier, or a group roll. + */ +type RollItem = StandardRoll | ModifierRoll | GroupRoll; + +/** + * Contains the detailed results of a single roll expression. + */ +type Results = { + /** Overall result type (e.g., "sum") */ + resultType: string; + /** Array of individual roll components (dice, modifiers, groups) */ + rolls: RollItem[]; + /** The final total of the roll expression */ + total: number; + /** Type of the overall result object (seems to be consistently "V") */ + type: "V"; +}; + +/** + * Represents a single complete roll entry, including the expression, + * its parsed results, an ID, and a signature. + */ +type RollData = { + /** The original roll expression (e.g., "2d20+5") */ + expression: string; + /** The detailed breakdown and total of the roll */ + results: Results; + /** A unique identifier for this roll */ + rollid: string; + /** A signature, likely for verification or tracking */ + signature: string; +}; + +/** + * Represents a complete roll result, including the original expression, + * parsed results, and any inline rolls that were part of the message. + */ + +type RollResult = RollData[]; + +/** Type definition for a chat message object */ +type Roll20ChatMessage = { + /** The display name of the player or character that sent the message */ + who: string; + /** The ID of the player that sent the message */ + playerid: string; + /** The type of chat message */ + type: "general" | "rollresult" | "gmrollresult" | "emote" | "whisper" | "desc" | "api"; + /** The contents of the chat message */ + content: string; + /** The original text of the roll (for rollresult or gmrollresult types) */ + origRoll?: string; + /** Array of objects containing information about all inline rolls in the message */ + inlinerolls?: RollResult; + /** The name of the template specified (when content contains roll templates) */ + rolltemplate?: string; + /** The player ID of the person the whisper is sent to (for whisper type) */ + target?: string; + /** The display name of the player or character the whisper was sent to (for whisper type) */ + target_name?: string; + /** Array of objects the user had selected when the command was entered (for api type) */ + selected?: Array; +}; + +/** Options for the sendChat function */ +type SendChatOptions = { + /** Whether the API should process the message's API commands */ + noarchive?: boolean; + /** Specifies the speaking player's ID for triggering API scripts that need to identify players */ + playerid?: string; + /** Temporarily uses another character or player for speaking for API event callbacks */ + spritesheetbucket?: string; +}; + +type Roll20ObjectType = "graphic" | "text" | "path" | "character" | "attribute" | "ability" | "handout" | + "macro" | "rollabletable" | "tableitem" | "campaign" | "player" | "page" | + "card" | "hand" | "deck" | "jukeboxtrack" | "custfx" | "door" | "window"; + +type Roll20EventType = + // Campaign events + | "ready" + | "change:campaign:playerpageid" + | "change:campaign:turnorder" + | "change:campaign:initiativepage" + // Chat event + | "chat:message" + // Generic object events + | `change:${Roll20ObjectType}` + | `add:${Roll20ObjectType}` + | `destroy:${Roll20ObjectType}` + // Property-specific events + | `change:${Roll20ObjectType}:${string}`; + +/** + * Sends a chat message + * + * @param speakingAs Who is speaking - can be a plain name string, player ID ("player|-ABC123"), or character ID ("character|-ABC123") + * @param input The message content - can include roll commands and other chat features + * @param callback Optional function to call when the chat message is delivered + * @param options Optional settings for the chat message + * + * @example Send a basic message + * sendChat("GM", "Hello, players!"); + * + * @example Send a message as a character + * sendChat("character|-KxUZ0wYG9gDCGukimEE", "I attack with my sword!"); + * + * @example Send a roll + * sendChat("System", "/roll 3d6", function(ops) { + * const total = ops[0].inlinerolls[0].results.total; + * sendChat("System", "The result is " + total); + * }); + * + * @example Send a whisper + * sendChat("GM", "/w gm This is a secret message only the GM will see"); + */ +declare function sendChat( + speakingAs: string, + input: string, + callback?: (operations: Roll20ChatMessage[]) => void, + options?: SendChatOptions +): void; + +/** + * Registers an event handler for Roll20 events + * + * @param event The name of the event to listen for + * @param callback The function to call when the event occurs + */ +declare function on(event: "ready", callback: () => void): void; +declare function on(event: "chat:message", callback: (msg: Roll20ChatMessage) => void): void; +declare function on( + event: "change:campaign:playerpageid" | "change:campaign:turnorder" | "change:campaign:initiativepage", + callback: (obj: Roll20Campaign) => void +): void; +declare function on( + event: `change:${Roll20ObjectType}`, + callback: (obj: Roll20Object, prev: Record) => void +): void; +declare function on( + event: `add:${Roll20ObjectType}` | `destroy:${Roll20ObjectType}`, + callback: (obj: Roll20Object) => void +): void; +declare function on( + event: `change:${Roll20ObjectType}:${string}`, + callback: (obj: Roll20Object, prev: Record) => void +): void; +declare function on(event: string, callback: (...args: any[]) => void): void; + +/** + * Type definition for cardInfo function options + */ +type CardInfoOptions = { + /** Required. Determines what kind of results are returned. */ + type: "hand" | "graphic" | "discard" | "play" | "deck" | "all" | "card"; + /** Required for all types except 'card'. Determines which deck to return information about. */ + deckid?: string; + /** Required for type 'card'. Determines which card to return information about. */ + cardid?: string; + /** Optional. Determines whether result will include cards in the discard pile. Ignored for types 'card', 'all', and 'discard'. Defaults to 'false' if omitted. */ + discard?: boolean; +}; + +/** + * Type definition for card information object + */ +type CardInfo = { + /** The type of location where the card is found */ + type: "graphic" | "hand" | "deck" | "discard"; + /** The id of the graphic, hand, or deck containing the card. Omitted for type "discard" */ + id?: string; + /** The id of the card */ + cardid: string; + /** The id of the page containing the graphic. Omitted for other types */ + pageid?: string; + /** The id of the player holding the card. Omitted for other types */ + playerid?: string; + /** The position of the card in the hand or discard pile. Omitted for types "graphic" and "deck" */ + index?: number; +}; + +/** + * Type definition for options when playing a card to the table + */ +type PlayCardToTableSettings = { + /** X position in pixels. Defaults to the center of the page. */ + left?: number; + /** Y position in pixels. Defaults to the center of the page. */ + top?: number; + /** Width of token in pixels. Defaults to the deck's defaultwidth, or to 98 if the deck has no default. */ + width?: number; + /** Height of token in pixels. Defaults to the deck's defaultheight, or to 140 if the deck has no default. */ + height?: number; + /** The layer the token will appear on. Defaults to the objects layer. */ + layer?: "gmlayer" | "objects" | "map" | "walls"; + /** The page the token is played to. Defaults to the current player page. */ + pageid?: string; + /** Whether to treat the token as a drawing. Uses the deck "treatasdrawing" as the default. */ + isdrawing?: boolean; + /** Whether the card is played face up or face down, with 0 being face up and 1 being face down. Defaults to the deck's default setting. */ + currentSide?: 0 | 1; +}; + +/** + * Type definition for options when taking a card from a player + */ +type TakeCardFromPlayerOptions = { + /** Specify an index of the currentHand array */ + index?: number; + /** Specify a card by id */ + cardid?: string; + /** Give it a player's id to trigger the steal card dialog for that player */ + steal?: string; +}; + +/** + * Gets information about a group of cards, or one specific card + * @param options Configuration options defining which cards to retrieve information about + * @returns Information about requested cards, false if the function fails, or an empty array if no information is retrieved + * + * @example Get information about a specific card + * const info = cardInfo({type: "card", cardid: "-KxUZ0wYG9gDCGukimEE"}); + * + * @example Get information about all cards in a deck + * const allCards = cardInfo({type: "all", deckid: "-KxUZ0wYG9gDCGukimEE"}); + * + * @example Get information about cards in players' hands + * const handCards = cardInfo({type: "hand", deckid: "-KxUZ0wYG9gDCGukimEE"}); + */ +declare function cardInfo(options: CardInfoOptions): CardInfo | CardInfo[] | false; + +/** + * Recalls cards to a deck + * @param deckid The ID of the deck to recall cards to + * @param type Optional parameter that determines which cards are recalled (defaults to 'all') + * @returns True if successful, false if the function fails + * + * @example Recall all cards to a deck + * recallCards("-KxUZ0wYG9gDCGukimEE"); + * + * @example Recall only cards from hands + * recallCards("-KxUZ0wYG9gDCGukimEE", "hand"); + */ +declare function recallCards(deckid: string, type?: "hand" | "graphic" | "all"): boolean; + +/** + * Shuffles a deck + * @param deckid The ID of the deck to shuffle + * @param discardOrOrder Optional, either a boolean to determine whether to include the discard pile (defaults to true), or an array of card IDs specifying the new order + * @param deckOrder Optional, an array of card IDs specifying the new order (only used if second parameter is a boolean) + * @returns An array of card IDs representing the new deck order if successful, false if the function fails + * + * @example Shuffle a deck including the discard pile + * shuffleDeck("-KxUZ0wYG9gDCGukimEE"); + * + * @example Shuffle only the remaining cards in the deck, excluding the discard pile + * shuffleDeck("-KxUZ0wYG9gDCGukimEE", false); + * + * @example Arrange the deck in a specific order + * const cardIds = _.pluck(cardInfo({type: "deck", deckid: "-KxUZ0wYG9gDCGukimEE", discard: true}), "cardid"); + * shuffleDeck("-KxUZ0wYG9gDCGukimEE", cardIds); + */ +declare function shuffleDeck(deckid: string, discardOrOrder?: boolean | string[], deckOrder?: string[]): string[] | false; + +/** + * Draws a card from a deck + * @param deckid The ID of the deck to draw from + * @param cardid Optional ID of a specific card to draw (if omitted, draws the top card) + * @returns The ID of the drawn card if successful, false if the function fails + * + * @example Draw the top card from a deck + * const drawnCardId = drawCard("-KxUZ0wYG9gDCGukimEE"); + * + * @example Draw a specific card from a deck + * const drawnCardId = drawCard("-KxUZ0wYG9gDCGukimEE", "-AbCdEfGhIjKlMnOpQr"); + */ +declare function drawCard(deckid: string, cardid?: string): string | false; + +/** + * Picks up a card from the table or discard pile + * @param cardid The ID of the card to pick up + * @param fromDiscard Whether to pick up the card from the discard pile (true) or the table (false, default) + * @returns The ID of the card if successful, false if the function fails + * + * @example Pick up a card from the table + * const cardId = pickUpCard("-KxUZ0wYG9gDCGukimEE"); + * + * @example Pick up a card from the discard pile + * const cardId = pickUpCard("-KxUZ0wYG9gDCGukimEE", true); + */ +declare function pickUpCard(cardid: string, fromDiscard?: boolean): string | false; + +/** + * Takes a card from a player's hand + * @param playerid The ID of the player to take a card from + * @param options Optional settings to specify which card to take and how to take it + * @returns The ID of the card if successful, false if the function fails + * + * @example Take a random card from a player's hand + * const cardId = takeCardFromPlayer("-KxUZ0wYG9gDCGukimEE"); + * + * @example Take a specific card from a player's hand + * const cardId = takeCardFromPlayer("-KxUZ0wYG9gDCGukimEE", {cardid: "-AbCdEfGhIjKlMnOpQr"}); + * + * @example Initiate a card steal dialog for a player + * const cardId = takeCardFromPlayer("-KxUZ0wYG9gDCGukimEE", {steal: "-MnOpQrStUvWxYz1234"}); + */ +declare function takeCardFromPlayer(playerid: string, options?: TakeCardFromPlayerOptions): string | false; + +/** + * Gives a card to a player + * @param cardid The ID of the card to give + * @param playerid The ID of the player to give the card to + * @returns The ID of the card if successful, false if the function fails + * + * @example Give a card to a player + * giveCardToPlayer("-KxUZ0wYG9gDCGukimEE", "-AbCdEfGhIjKlMnOpQr"); + */ +declare function giveCardToPlayer(cardid: string, playerid: string): string | false; + +/** + * Plays a card to the table + * @param cardid The ID of the card to play + * @param settings Optional settings for the token appearance and placement + * @returns The ID of the graphic object created if successful, false if the function fails + * + * @example Play a card to the table with default settings + * const tokenId = playCardToTable("-KxUZ0wYG9gDCGukimEE"); + * + * @example Play a card face down at specific coordinates + * const tokenId = playCardToTable("-KxUZ0wYG9gDCGukimEE", { + * left: 300, + * top: 200, + * currentSide: 1 + * }); + */ +declare function playCardToTable(cardid: string, settings?: PlayCardToTableSettings): string | false; + +/** + * Deals a card from the deck to each object in the turn order + * @param deckid The ID of the deck to deal from + * @returns True if successful, false if the function fails + * + * @example Deal cards to turn order items + * dealCardsToTurn("-KxUZ0wYG9gDCGukimEE"); + */ +declare function dealCardsToTurn(deckid: string): boolean; + +/** + * Starts playing a jukebox playlist + * @param playlist_id The ID of the playlist to start playing + * + * @remarks + * Note: Using Jukebox/soundcloud functions will not throw errors, but they no longer do anything. + * See "In Regards to SoundCloud" documentation. + * + * @example Play a jukebox playlist + * playJukeboxPlaylist("-KxUZ0wYG9gDCGukimEE"); + */ +declare function playJukeboxPlaylist(playlist_id: string): void; + +/** + * Stops the currently playing jukebox playlist + * + * @remarks + * Note: Using Jukebox/soundcloud functions will not throw errors, but they no longer do anything. + * See "In Regards to SoundCloud" documentation. + * + * @example Stop the currently playing playlist + * stopJukeboxPlaylist(); + */ +declare function stopJukeboxPlaylist(): void; + +/** + * Global State Object + */ + +// type UniqueIdentifier = string | number | symbol; + +// declare const state: { +// [key: UniqueIdentifier]: { +// [key: string]: any; +// }; +// }; + +// declare const globalconfig: { +// [key: UniqueIdentifier]: { +// [key: string]: any; +// }; +// }; \ No newline at end of file diff --git a/ChatSetAttr/build/vite.config.js b/ChatSetAttr/build/vite.config.js new file mode 100644 index 0000000000..a4d05c8d3b --- /dev/null +++ b/ChatSetAttr/build/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import details from "../script.json"; + +export default defineConfig({ + build: { + target: "node16", + outDir: `../${details.version}`, + emptyOutDir: true, + minify: false, + lib: { + entry: "./src/main.ts", + name: details.name, + formats: ["iife"], + fileName: () => `${details.name}.js`, + }, + }, + plugins: [], +}); \ No newline at end of file diff --git a/ChatSetAttr/build/vitest.config.ts b/ChatSetAttr/build/vitest.config.ts new file mode 100644 index 0000000000..9490b54785 --- /dev/null +++ b/ChatSetAttr/build/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + setupFiles: ["./vitest.globals.ts"], + }, +}); \ No newline at end of file diff --git a/ChatSetAttr/build/vitest.globals.ts b/ChatSetAttr/build/vitest.globals.ts new file mode 100644 index 0000000000..b11cdeca95 --- /dev/null +++ b/ChatSetAttr/build/vitest.globals.ts @@ -0,0 +1,423 @@ +import { vi } from "vitest"; +import _ from "underscore"; + +// Define Roll20 API interfaces +interface Roll20Object { + id: string; + get(property: string): any; + set(property: string, value: any): void; +} + +// Mock Roll20 API classes +export class MockObject implements Roll20Object { + id: string = ""; + controlledby: string = "all"; + + constructor(attributes: Record) { + Object.assign(this, attributes); + } + + get(attr: string): any { + if (this[attr as keyof this] === undefined) { + return ""; + } + return this[attr as keyof this]; + } + + set(attr: string, value: any): void { + (this as any)[attr] = value; + } +} + +export class MockToken extends MockObject { + _type: string = "graphic"; + represents: string; + _pageid: string; + _id: string; + constructor(attributes: Record) { + super(attributes); + this.represents = attributes.represents || ""; + this._pageid = attributes._pageid || ""; + this._id = attributes._id || `token_${Date.now()}_${Math.floor(Math.random() * 10000)}`; + } + get(prop: string): string { + if (prop === "represents") return this.represents; + if (prop === "_pageid") return this._pageid; + if (prop === "_id") return this._id; + return super.get(prop); + } + set(prop: string, value: any): void { + if (prop === "represents") { + this.represents = value; + } else if (prop === "_pageid") { + this._pageid = value; + } else if (prop === "_id") { + console.warn("Setting _id is not allowed"); + return; + } + else { + super.set(prop, value); + } + } +}; + +// Helper class for attributes used in tests +export type AttributeProps = { + _characterid?: string; + characterid?: string; + name: string; + current?: string; + max?: string; +}; + +export class MockAttribute implements Roll20Object { + id: string; + _characterid: string; + _type: string = "attribute"; + name: string; + current?: string; + max?: string; + + constructor(props: AttributeProps) { + this.id = `attr_${Date.now()}_${Math.floor(Math.random() * 10000)}`; + this._characterid = props._characterid || props.characterid || ""; + this.name = props.name; + this.current = props.current || ""; + this.max = props.max; + } + + get(prop: string): string | undefined { + if (prop === "_characterid") return this._characterid; + if (prop === "name") return this.name; + if (prop === "current") return this.current; + if (prop === "max") return this.max; + return ""; + } + + set(prop: string | Record, value?: any): MockAttribute { + if (typeof prop === "object") { + const entries = Object.entries(prop); + for (const [key, val] of entries) { + if (key === "_characterid" || key === "characterid") { + console.warn("Setting _characterid or characterid is not allowed"); + continue; + } + (this as any)[key] = val; + } + } else { + (this as any)[prop] = value; + } + return this; + } + + setWithWorker(prop: Record): MockAttribute { + const entries = Object.entries(prop); + for (const [key, val] of entries) { + if (key === "current" || key === "max") { + if (!this) { + console.error("MockAttribute is not defined"); + console.trace(); + return this; + } + this[key] = val; + } + } + return this; + } + + remove(): void { + const index = global.attributes.indexOf(this); + if (index > -1) { + global.attributes.splice(index, 1); + } + } +} + +// Helper class for characters +export class MockCharacter implements Roll20Object { + id: string; + name: string; + controlledby: string; + type: string; + + constructor(id: string, name: string, controlledby: string = "") { + this.id = id; + this.name = name; + this.controlledby = controlledby; + this.type = "character"; + } + + get(prop: string): string { + if (prop === "name") return this.name; + if (prop === "controlledby") return this.controlledby; + return ""; + } + + set(prop: string, value: any): void { + (this as any)[prop] = value; + } +} + +// Define the global namespace with Roll20 API types +declare global { + var state: Record; + var globalconfig: Record; + var attributes: MockAttribute[]; + var characters: MockCharacter[]; + var selectedTokens: MockToken[]; + var returnObjects: any[]; + var watchers: Record; + var trigger: (event: string, ...args: any[]) => void; + var inputQueue: string[]; + var executeCommand: (command: string, selectedIds?: string[], options?: ExecuteCommandOptions) => Roll20ChatMessage; + var setupTestEnvironment: () => void; +} + +interface ExecuteCommandOptions { + playerId?: string; + playerName?: string; + whisperTo?: string; +} + +// Create global collections for test data +global.attributes = []; +global.characters = []; +global.selectedTokens = []; + +// Basic Roll20 API mocks +global.log = vi.fn(); +global.state = {}; +global.globalconfig = {}; +global.sendChat = vi.fn(); +global._ = _; +global.returnObjects = []; +global.getAttrByName = vi.fn((charId: string, attrName: string, valueType: string = "current") => { + const attr = global.attributes.find(a => a._characterid === charId && a.name === attrName); + return attr ? attr[valueType as keyof MockAttribute]?.toString() || "" : ""; +}); + +global.getSheetItem = vi.fn(async (charId: string, attrName: string, valueType: string = "current") => { + const attr = global.attributes.find(a => a._characterid === charId && a.name === attrName); + if (!attr) return undefined; + const value = valueType === "current" ? attr.get("current") : attr.get("max"); + return value; +}); + +global.setSheetItem = vi.fn(async (charId: string, attrName: string, value: any, valueType: string = "current") => { + const attr = global.attributes.find(a => a._characterid === charId && a.name === attrName); + + if (attr) { + // Update existing attribute + if (valueType === "max") { + attr.max = value?.toString() || ""; + } else { + attr.current = value?.toString() || ""; + } + } else { + // Create new attribute if it doesn't exist + const newAttr = new MockAttribute({ + _characterid: charId, + name: attrName, + }); + + if (valueType === "max") { + newAttr.max = value?.toString() || ""; + } else { + newAttr.current = value?.toString() || ""; + } + + global.attributes.push(newAttr); + } + + return true; +}); + +global.getObj = vi.fn((type: string, id: string) => { + if (type === "character") { + return global.characters.find(c => c.id === id); + } else if (type === "attribute") { + return global.attributes.find(a => a.id === id); + } else if (type === "graphic") { + return global.selectedTokens.find(t => t.id === id); + } + return null; +}) as any; + +global.findObjs = vi.fn((query: Record) => { + if (query._type === "character") { + if (query.name) { + return global.characters.filter(c => c.name.toLowerCase() === query.name.toLowerCase()); + } + return [...global.characters]; + } else if (query._type === "attribute") { + return global.attributes.filter((a) => { + const allMatch = Object.entries(query).every(([key, value]) => { + return a[key as keyof MockAttribute] === value; + }); + return allMatch; + }); + } + return []; +}) as any; + +global.createObj = vi.fn((type: string, props: Record) => { + if (type === "attribute") { + const newAttr = new MockAttribute(props as AttributeProps); + global.attributes.push(newAttr); + return newAttr; + } + if (type === "character") { + const newChar = new MockCharacter(props.id, props.name, props.controlledby); + global.characters.push(newChar); + return newChar; + } + if (type === "graphic") { + const newToken = new MockToken({ + represents: props.represents || "", + _pageid: props._pageid || "", + _id: props._id || `token_${Date.now()}_${Math.floor(Math.random() * 10000)}`, + controlledby: props.controlledby || "all", + ...props + }); + global.selectedTokens.push(newToken); + return newToken; + } + return null; +}) as any; + +global.playerIsGM = vi.fn(() => true); + +global.inputQueue = []; + +// Set up event handling with subscribers +global.watchers = {}; + +global.on = vi.fn((event, callback) => { + global.watchers[event] = global.watchers[event] || []; + global.watchers[event].push(callback); +}); + +// Helper function to execute a chat command +global.executeCommand = (command: string, selectedIds: string[] = [], options: ExecuteCommandOptions = {}): Roll20ChatMessage => { + const { attributes, selectedTokens, inputQueue } = global; + const { playerId = "gm123", playerName = "GM", whisperTo = "" } = options; + + // Replace all @{selected|...} with the respective attribute values + command = command.replace(/@\{selected\|([^}]+)\}/g, (_, attrName) => { + // Get the token and then find its character"s attribute + const charId = selectedIds.length > 0 ? + selectedTokens.find(t => t.id === selectedIds[0])?.represents : null; + + if (!charId) return ""; + + const attr = attributes.find(a => a._characterid === charId && a.name === attrName); + return attr?.current ? attr.current : ""; + }); + + // Replace all @{...} with the respective attribute values + command = command.replace(/@\{(?:([^|}]+)\|)?([^}]+)\}/g, (_, identifier, attrName) => { + if (identifier) { + // Format: @{characterid|attrName} + const attr = attributes.find(a => a._characterid === identifier && a.name === attrName); + return attr?.current ? attr.current : ""; + } else { + // Format: @{attrName} (use first matching attribute) + const attr = attributes.find(a => a.name === attrName); + return attr?.current ? attr.current : ""; + } + }); + + // Replace all ?{...} with values from our input queue + command = command.replace(/\[\[\?{[^}]*\|?\d*}\]\]/g, () => { + return inputQueue.length > 0 ? inputQueue.shift() || "0" : "0"; + }); + + const inlinerolls: RollResult = []; + + // Replace all inline rolls + + command = command.replace(/\[\[([^\]]+)\]\]/g, (whole, inline: string) => { + let expression = inline.trim(); + const rollsData: RollData = { + expression: inline, + results: { + resultType: "sum", + rolls: [], + total: 0, + type: "V", + }, + rollid: `roll_${Date.now()}_${Math.floor(Math.random() * 10000)}`, + signature: "test_signature", + }; + // find all dice notation in the inline roll + const diceNotation = inline.matchAll(/(\d+)?d(\d+)/g); + for (const match of diceNotation) { + const numDice = parseInt(match[1] || "1", 10); + const numSides = parseInt(match[2], 10); + if (isNaN(numDice) || isNaN(numSides) || numDice <= 0 || numSides <= 0) { + continue; // Invalid roll format, skip + } + // Calculate average value for the roll + const averageValue = Math.round(numSides / 2); + const totalValue = numDice * averageValue; + rollsData.results!.rolls.push({ + dice: numDice, + results: Array.from({ length: numDice }).map(() => ({ v: averageValue })), + sides: numSides, + type: "R", + }); + expression = expression.replace(match[0], totalValue.toString()); + } + // resolve total + try { + const total = eval(expression); + rollsData.results!.total = total; + inlinerolls.push(rollsData); + // replace the inline roll with a reference to the inlinerolls array + const index = inlinerolls.length - 1; + return `$[[${index}]]`; + } catch (error) { + console.error("Error evaluating inline roll expression:", expression, error); + return whole; + } + }); + + // Create the message object + const msg: Roll20ChatMessage = { + type: command.startsWith("!") ? "api" : "general", + content: command, + playerid: playerId, + who: whisperTo ? `${playerName} -> ${whisperTo}` : playerName, + selected: selectedIds.map(id => ({ _id: id })) as any as Roll20Graphic["properties"][], + inlinerolls, + }; + + // Notify subscribers first (this simulates the Roll20 event system) + for (const watcher of global.watchers["chat:message"]) { + watcher(msg); + } + + return msg; +}; + +// Helper function to set up test environment +global.setupTestEnvironment = (): void => { + // Reset collections + global.attributes = []; + global.characters = []; + global.selectedTokens = []; + global.inputQueue = []; + global.watchers = {}; + + // Reset returnObjects + global.returnObjects = []; + + // Set up ChatSetAttr state + global.state.ChatSetAttr = { + version: 3, + globalconfigCache: { lastsaved: 0 }, + playersCanModify: false, + playersCanEvaluate: false, + useWorkers: false + }; +}; diff --git a/ChatSetAttr/script.json b/ChatSetAttr/script.json index 4ab4c6dbf7..5e27502cc8 100644 --- a/ChatSetAttr/script.json +++ b/ChatSetAttr/script.json @@ -1,11 +1,12 @@ { "name": "ChatSetAttr", "script": "ChatSetAttr.js", - "version": "1.10", + "version": "1.11", "description": "# ChatSetAttr\n\nThis script is a utility that allows the user to create, modify, or delete character attributes via chat messages or macros. There are several options that determine which attributes are modified, and which characters the attributes are modified for. The script is called by the command **!setattr [--options]** for creating or modifying attributes, or **!delattr [--options]** for deleting attributes.\n\n## Selecting a target\n\nOne of the following options must be specified; they determine which characters are affected by the script.\n\n* **--all** will affect all characters in the game. USE WITH CAUTION. This option will only work for the GM. If you have a large number of characters in your campaign, this will take a while to process all attribute changes.\n* **--allgm** will affect all characters which do not have a controlling player set, which typically will be every character that is not a player character. USE WITH CAUTION. This option will only work for the GM.\n* **--charid charid1, charid2, ...** allows you to supply a list of character ids, and will affect characters whose ids come from this list. Non-GM Players can only affect characters that they control.\n* **--name name1, name2, ...** allows you to supply a list of character names, and will look for a character with this name to affect. Non-GM Players can only affect characters that they control.\n* **--sel** will affect all characters that are represented by tokens you have currently selected.\n\n## Inline commands\n\nIt is possible to use some ChatSetAttr commands in the middle of a roll template, with some limitations. To do so, write the ChatSetAttr command between the properties of a roll template, and end it \"!!!\". If one of the attribute values is a whole roll template property, the first inline roll within that property will be used instead. It is easiest to illustrate how this works in an example:\n\n```\n&{template:default} {{name=Cthulhu}} !modattr --silent --charid @{target|character\\_id} --sanity|-{{Sanity damage=[[2d10+2]]}} --corruption|{{Corruption=Corruption increases by [[1]]}}!!! {{description=Text}}\n```\n\nThis will decrease sanity by 2d10+2 and increase corruption by 1 for the character selected with the --charid @{target|character\\_id} command. **It is crucial** that the ChatSetAttr part of the command is ended by three exclamation marks like in the message above – this is how the script know when to stop interpreting the roll template as part of the ChatSetAttr command.\n\n## Additional options\n\nThese options will have no effect on **!delattr**, except for **--silent**.\n\n* **--silent** will suppress normal output; error messages will still be displayed.\n* **--mute** will suppress normal output as well as error messages (hence **--mute** implies **--silent**).\n* **--replace** will replace the characters < , > , ~ , ; , and ` by the characters [,],-,?, and @ in attribute values. This is useful when you do not want roll20 to evaluate your expression in chat before it is parsed by the script. Alternatively, you can use \\lbrak, \\rbrak, \\n, \\at, and \\ques to create [, ], a newline, @, and ?.\n* **--nocreate** will change the script's default behaviour of creating a new attribute when it cannot find one; instead, the script will display an error message when it cannot find an existing attribute with the given name.\n* **--mod** will add the new value to the existing value instead of replacing it. If the existing value is a number (or empty, which will be treated as 0), the new value will be added to the existing value. If not, an error message will be displayed instead. Try not to apply this option to attributes whose values are not numbers. You can use **!modattr** as a shortcut for **!setattr --mod**.\n* **--modb** works like **--mod**, except that the attribute's current value is kept between 0 and its maximum. You can use **!modbattr** as a shortcut for **!setattr --modb**.\n* **--reset** will simply reset all entered attribute values to the maximum; the values you enter are ignored. You can use **!resetattr** as a shortcut for **!setattr --reset**.\n* **--evaluate** is a GM-only (unless you allow it to be used by players via the configuration) option that will use JavaScript eval() to evaluate the attribute value expressions. This allows you to do math in expressions involving other attributes (see the example below). However, this option is inherently dangerous and prone to errors, so be careful.\n\n## Feedback options\n\nThe script accepts several options that modify the feedback messages sent by the script.\n\n* **--fb-public** will send the output to chat publicly, instead of whispering it to the player who sent the command. Note that error messages will still be whispered.\n* **--fb-from ** will modify the name that appears as the sender in chat messages sent by the script. If not specified, this defaults to \"ChatSetAttr\".\n* **--fb-header ** will replace the title of the message sent by the script - normally, \"Setting Attributes\" or \"Deleting Attributes\" - with a custom string.\n* **--fb-content ** will replace the feedback line for every character with a custom string. This will not work with **!delattr**.\n\nYou can use the following special character sequences in the values of both **--fb-header** and **--fb-content**. Here, **J** is an integer, starting from 0, and refers to the **J**-th attribute you are changing. They will be dynamically replaced as follows:\n\n* \\_NAME**J**\\_: will insert the attribute name.\n* \\_TCUR**J**\\_: will insert what you are changing the current value to (or changing by, if you're using **--mod** or **--modb**).\n* \\_TMAX**J**\\_: will insert what you are changing the maximum value to (or changing by, if you're using **--mod** or **--modb**).\n\nIn addition, there are extra insertion sequence that only make sense in the value of **--fb-content**:\n\n* \\_CHARNAME\\_: will insert the character name.\n* \\_CUR**J**\\_: will insert the final current value of the attribute, for this character.\n* \\_MAX**J**\\_: will insert the final maximum value of the attribute, for this character.\n\n## Attribute Syntax\n\nAttribute options will determine which attributes are set to which value (respectively deleted, in case of !delattr). The syntax for these options is **--name|value** or **--name|value|max**. Here, **name** is the name of the attribute (which is parsed case-insensitively), **value** is the value that the current value of the attribute should be set to, and **max** is the value that the maximum value of the attribute should be set to. Instead of the vertical line ('|'), you may also use '#' (for use inside roll queries, for example).\n\n* Single quotes (') surrounding **value** or **max** will be stripped, as will trailing spaces. If you need to include spaces at the end of a value, enclose the whole expression in single quotes.\n* If you want to use the '|' or '#' characters inside an attribute value, you may escape them with a backslash: use '|' or '#' instead.\n* If the option is of the form **--name|value**, then the maximum value will not be changed.\n* If it is of the form **--name||max**, then the current value will not be changed.\n* You can also just supply **--name|** or **--name** if you just want to create an empty attribute or set it to empty if it already exists, for whatever reason.\n* **value** and **max** are ignored for **!delattr**.\n* If you want to empty the current attribute and set some maximum, use **--name|''|max**.\n* The script can deal with repeating attributes, both by id (e.g. **repeating\\_prefix\\_-ABC123\\_attribute**) and by row index (e.g. **repeating\\_prefix\\_$0\\_attribute**). If you want to create a new repeating row in a repeating section with name **prefix**, use the attribute name **repeating\\_prefix\\_-CREATE\\_name**. If you want to delete a repeating row with **!delattr**, use the attribute name **repeating\\_prefix\\_ID** or **repeating\\_prefix\\_$rowNumber**.\n* You can insert the values of \\_other\\_ attributes into the attributes values to be set via %attribute\\_name%. For example, **--attr1|%attr2%|%attr2_max%** will insert the current and maximum value of **attr2** into those of **attr1**.\n\n## Examples\n\n* **!setattr --sel --Strength|15** will set the Strength attribute for 15 for all selected characters.\n* **!setattr --name John --HP|17|27 --Dex|10** will set HP to 17 out of 27 and Dex to 10 for the character John (only one of them, if more than one character by this name exists).\n* **!delattr --all --gold** will delete the attribute called gold from all characters, if it exists.\n* **!setattr --sel --mod --Strength|5** will increase the Strength attribute of all selected characters by 5, provided that Strength is either empty or has a numerical value - it will fail to have an effect if, for example, Strength has the value 'Very big'.\n* **!setattr --sel --Ammo|%Ammo_max%** will reset the Ammo attribute for the selected characters back to its maximum value.\n* If the current value of attr1 is 3 and the current value of attr2 is 2, **!setattr --sel --evaluate --attr3|2*%attr1% + 7 - %attr2%** will set the current value of attr3 to 15.\n\n## Global configuration\n\nThere are three global configuration options, _playersCanModify_, _playersCanEvaluate_, and _useWorkers_, which can be toggled either on this page or by entering **!setattr-config** in chat. The former two will give players the possibility of modifying characters they don't control or using the **--evaluate** option. You should only activate either of these if you can trust your players not to vandalize your characters or your campaign. The last option will determine if the script triggers sheet workers on use, and should normally be toggled on.\n## Registering observers\n\n**Note:** this section is only intended to be read by script authors. If you are not writing API scripts, you can safely ignore this.\n\nChanges made by API scripts do not trigger the default Roll20 event handlers, by default. While perhaps a sensible choice in order to prevent infinite loops, it is unfortunate if you do want your script to ChatSetAttr-induced attribute changes. To this end, ChatSetAttr offers an observer pattern. You can register your script with ChatSetAttr like you would register Roll20 event handlers, and your handler functions will be called by ChatSetAttr. The syntax is\n\n`ChatSetAttr.registerObserver(event, observer);`\n\nwhere `event` is one of `\"add\"`, `\"change\"`, or `\"destroy\"`, and `observer` is the event handler function (with identical structure like the one you would pass to e.g. a `\"change:attribute\"` event).", "authors": "Jakob", "roll20userid": "726129", - "useroptions": [{ + "useroptions": [ + { "name": "Players can modify all characters", "type": "checkbox", "description": "Select this option to allow all players to use the `--charid` or `--name` parameter to specify characters they don't control to be modified.", @@ -36,5 +37,31 @@ "graphic.represents": "read" }, "conflicts": [], - "previousversions": ["1.9", "1.8", "1.7.1", "1.7", "1.6.2", "1.6.1", "1.6", "1.5", "1.4", "1.3", "1.2.2", "1.2.1", "1.2", "1.1.5", "1.1.4", "1.1.3", "1.1.2", "1.1.1", "1.1", "1.0.2", "1.0.1", "1.0", "0.9.1", "0.9"] -} + "previousversions": [ + "1.10", + "1.9", + "1.8", + "1.7.1", + "1.7", + "1.6.2", + "1.6.1", + "1.6", + "1.5", + "1.4", + "1.3", + "1.2.2", + "1.2.1", + "1.2", + "1.1.5", + "1.1.4", + "1.1.3", + "1.1.2", + "1.1.1", + "1.1", + "1.0.2", + "1.0.1", + "1.0", + "0.9.1", + "0.9" + ] +} \ No newline at end of file